diff options
Diffstat (limited to 'src/client/util')
-rw-r--r-- | src/client/util/CurrentUserUtils.ts | 127 | ||||
-rw-r--r-- | src/client/util/DocumentManager.ts | 102 | ||||
-rw-r--r-- | src/client/util/DragManager.ts | 33 | ||||
-rw-r--r-- | src/client/util/GroupManager.tsx | 155 | ||||
-rw-r--r-- | src/client/util/GroupMemberView.tsx | 4 | ||||
-rw-r--r-- | src/client/util/Import & Export/ImageUtils.ts | 4 | ||||
-rw-r--r-- | src/client/util/LinkManager.ts | 192 | ||||
-rw-r--r-- | src/client/util/SearchUtil.ts | 19 | ||||
-rw-r--r-- | src/client/util/SelectionManager.ts | 8 | ||||
-rw-r--r-- | src/client/util/SerializationHelper.ts | 2 | ||||
-rw-r--r-- | src/client/util/SettingsManager.tsx | 6 | ||||
-rw-r--r-- | src/client/util/SharingManager.scss | 29 | ||||
-rw-r--r-- | src/client/util/SharingManager.tsx | 199 | ||||
-rw-r--r-- | src/client/util/SnappingManager.ts | 10 | ||||
-rw-r--r-- | src/client/util/UndoManager.ts | 2 |
15 files changed, 431 insertions, 461 deletions
diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts index 342954a4e..4f054269f 100644 --- a/src/client/util/CurrentUserUtils.ts +++ b/src/client/util/CurrentUserUtils.ts @@ -1,6 +1,6 @@ import { computed, observable, reaction } from "mobx"; import * as rp from 'request-promise'; -import { DataSym, Doc, DocListCast, DocListCastAsync } from "../../fields/Doc"; +import { DataSym, Doc, DocListCast, DocListCastAsync, AclReadonly } from "../../fields/Doc"; import { Id } from "../../fields/FieldSymbols"; import { List } from "../../fields/List"; import { PrefetchProxy } from "../../fields/Proxy"; @@ -8,12 +8,14 @@ import { RichTextField } from "../../fields/RichTextField"; import { listSpec } from "../../fields/Schema"; import { SchemaHeaderField } from "../../fields/SchemaHeaderField"; import { ComputedField, ScriptField } from "../../fields/ScriptField"; -import { BoolCast, Cast, NumCast, PromiseValue, StrCast } from "../../fields/Types"; +import { BoolCast, Cast, NumCast, PromiseValue, StrCast, DateCast } from "../../fields/Types"; import { nullAudio } from "../../fields/URLField"; +import { SharingPermissions } from "../../fields/util"; import { Utils } from "../../Utils"; import { DocServer } from "../DocServer"; import { Docs, DocumentOptions, DocUtils } from "../documents/Documents"; import { DocumentType } from "../documents/DocumentTypes"; +import { Networking } from "../Network"; import { CollectionDockingView } from "../views/collections/CollectionDockingView"; import { DimUnit } from "../views/collections/collectionMulticolumn/CollectionMulticolumnView"; import { CollectionView, CollectionViewType } from "../views/collections/CollectionView"; @@ -30,8 +32,10 @@ import { Scripting } from "./Scripting"; import { SearchUtil } from "./SearchUtil"; import { SelectionManager } from "./SelectionManager"; import { UndoManager } from "./UndoManager"; +import { SnappingManager } from "./SnappingManager"; +export let resolvedPorts: { server: number, socket: number }; const headerViewVersion = "0.1"; export class CurrentUserUtils { private static curr_id: string; @@ -228,7 +232,7 @@ export class CurrentUserUtils { } else { const curButnTypes = Cast(doc["template-buttons"], Doc, null); DocListCastAsync(curButnTypes.data).then(async curBtns => { - await Promise.all(curBtns!); + curBtns && await Promise.all(curBtns); requiredTypes.map(btype => Doc.AddDocToList(curButnTypes, "data", btype)); }); } @@ -277,7 +281,7 @@ export class CurrentUserUtils { const curNoteTypes = Cast(doc["template-notes"], Doc, null); const requiredTypes = [doc["template-note-Note"] as any as Doc, doc["template-note-Idea"] as any as Doc, doc["template-note-Topic"] as any as Doc];//, doc["template-note-Todo"] as any as Doc]; DocListCastAsync(curNoteTypes.data).then(async curNotes => { - await Promise.all(curNotes!); + curNotes && await Promise.all(curNotes); requiredTypes.map(ntype => Doc.AddDocToList(curNoteTypes, "data", ntype)); }); } @@ -348,7 +352,7 @@ export class CurrentUserUtils { const requiredTypes = [doc["template-icon-view"] as Doc, doc["template-icon-view-img"] as Doc, doc["template-icon-view-button"] as Doc, doc["template-icon-view-col"] as Doc, doc["template-icon-view-rtf"] as Doc]; DocListCastAsync(templateIconsDoc.data).then(async curIcons => { - await Promise.all(curIcons!); + curIcons && await Promise.all(curIcons); requiredTypes.map(ntype => Doc.AddDocToList(templateIconsDoc, "data", ntype)); }); } @@ -401,10 +405,10 @@ export class CurrentUserUtils { selection: { type: "text", anchor: 1, head: 1 }, storedMarks: [] }; - const headerTemplate = Docs.Create.RTFDocument(new RichTextField(JSON.stringify(json), ""), { title: "header", version: headerViewVersion, target: doc, _height: 70, _headerHeight: 12, _headerFontSize: 9, _autoHeight: true, system: true, cloneFieldFilter: new List<string>(["system"]) }, "header"); // text needs to be a space to allow templateText to be created + const headerTemplate = Docs.Create.RTFDocument(new RichTextField(JSON.stringify(json), ""), { title: "header", version: headerViewVersion, target: doc, _height: 70, _headerPointerEvents: "all", _headerHeight: 12, _headerFontSize: 9, _autoHeight: true, system: true, cloneFieldFilter: new List<string>(["system"]) }, "header"); // text needs to be a space to allow templateText to be created headerTemplate[DataSym].layout = "<div style={'height:100%'}>" + - " <FormattedTextBox {...props} fieldKey={'header'} dontSelectOnLoad={'true'} ignoreAutoHeight={'true'} pointerEvents='{this._headerPointerEvents||`none`}' fontSize='{this._headerFontSize}px' height='{this._headerHeight}px' background='{this._headerColor||this.target.userColor}' />" + + " <FormattedTextBox {...props} fieldKey={'header'} dontSelectOnLoad={'true'} ignoreAutoHeight={'true'} pointerEvents='{this._headerPointerEvents||`none`}' fontSize='{this._headerFontSize}px' height='{this._headerHeight}px' background='{this._headerColor||this.target.mySharedDocs.userColor}' />" + " <FormattedTextBox {...props} fieldKey={'text'} position='absolute' top='{(this._headerHeight)*scale}px' height='calc({100/scale}% - {this._headerHeight}px)'/>" + "</div>"; (headerTemplate.proto as Doc).isTemplateDoc = makeTemplate(headerTemplate.proto as Doc, true, "headerView"); @@ -422,9 +426,13 @@ export class CurrentUserUtils { doc.emptyScreenshot = Docs.Create.ScreenshotDocument("", { _width: 400, _height: 200, title: "screen snapshot", system: true, cloneFieldFilter: new List<string>(["system"]) }); } if (doc.emptyAudio === undefined) { - doc.emptyAudio = Docs.Create.AudioDocument(nullAudio, { _width: 200, title: "ready to record audio", system: true, cloneFieldFilter: new List<string>(["system"]) }); + doc.emptyAudio = Docs.Create.AudioDocument(nullAudio, { _width: 200, title: "audio recording", system: true, cloneFieldFilter: new List<string>(["system"]) }); ((doc.emptyAudio as Doc).proto as Doc)["dragFactory-count"] = 0; } + if (doc.emptyNote === undefined) { + doc.emptyNote = Docs.Create.TextDocument("", { _width: 200, title: "text note", system: true, cloneFieldFilter: new List<string>(["system"]) }); + ((doc.emptyNote as Doc).proto as Doc)["dragFactory-count"] = 0; + } if (doc.emptyImage === undefined) { doc.emptyImage = Docs.Create.ImageDocument("https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg", { _width: 250, _nativeWidth: 250, title: "an image of a cat", system: true }); } @@ -444,6 +452,7 @@ export class CurrentUserUtils { this.setupActiveMobileMenu(doc); } return [ + { toolTip: "Tap to create a note in a new pane, drag for a note", title: "Note", icon: "sticky-note", click: 'openOnRight(copyDragFactory(this.clickFactory))', drag: 'copyDragFactory(this.dragFactory)', dragFactory: doc.emptyNote as Doc, noviceMode: true, clickFactory: doc.emptyNote as Doc, }, { toolTip: "Tap to create a collection in a new pane, drag for a collection", title: "Col", icon: "folder", click: 'openOnRight(copyDragFactory(this.clickFactory))', drag: 'copyDragFactory(this.dragFactory)', dragFactory: doc.emptyCollection as Doc, noviceMode: true, clickFactory: doc.emptyPane as Doc, }, { toolTip: "Tap to create a webpage in a new pane, drag for a webpage", title: "Web", icon: "globe-asia", click: 'openOnRight(copyDragFactory(this.dragFactory))', drag: 'copyDragFactory(this.dragFactory)', dragFactory: doc.emptyWebpage as Doc, noviceMode: true }, { toolTip: "Tap to create a progressive slide", title: "Slide", icon: "file", click: 'openOnRight(copyDragFactory(this.dragFactory))', drag: 'copyDragFactory(this.dragFactory)', dragFactory: doc.emptySlide as Doc, noviceMode: true }, @@ -508,10 +517,7 @@ export class CurrentUserUtils { return doc.myItemCreators as Doc; } - static menuBtnDescriptions(doc: Doc): { - title: string, target: Doc, icon: string, click: string, watchedDocuments?: Doc - }[] { - this.setupSharingSidebar(doc); // sets up the right sidebar collection for mobile upload documents and sharing + static async menuBtnDescriptions(doc: Doc) { return [ { title: "Dashboards", target: Cast(doc.myDashboards, Doc, null), icon: "desktop", click: 'selectMainMenu(self)' }, { title: "Recently Closed", target: Cast(doc.myRecentlyClosedDocs, Doc, null), icon: "archive", click: 'selectMainMenu(self)' }, @@ -535,9 +541,10 @@ export class CurrentUserUtils { })) as any as Doc; } } - static setupMenuPanel(doc: Doc) { + static async setupMenuPanel(doc: Doc, sharingDocumentId: string, linkDatabaseId: string) { if (doc.menuStack === undefined) { - const menuBtns = CurrentUserUtils.menuBtnDescriptions(doc).map(({ title, target, icon, click, watchedDocuments }) => + await this.setupSharingSidebar(doc, sharingDocumentId, linkDatabaseId); // sets up the right sidebar collection for mobile upload documents and sharing + const menuBtns = (await CurrentUserUtils.menuBtnDescriptions(doc)).map(({ title, target, icon, click, watchedDocuments }) => Docs.Create.FontIconDocument({ icon, iconShape: "square", @@ -561,7 +568,7 @@ export class CurrentUserUtils { title: "menuItemPanel", childDropAction: "alias", dropConverter: ScriptField.MakeScript("convertToButtons(dragData)", { dragData: DragManager.DocumentDragData.name }), - _backgroundColor: "black", + _backgroundColor: "black", ignoreClick: true, _gridGap: 0, _yMargin: 0, _yPadding: 0, _xMargin: 0, _autoHeight: false, _width: 60, _columnWidth: 60, lockedPosition: true, _chromeStatus: "disabled", system: true @@ -724,7 +731,7 @@ export class CurrentUserUtils { if (doc.myTools === undefined) { const toolsStack = new PrefetchProxy(Docs.Create.StackingDocument([doc.myCreators as Doc, doc.myColorPicker as Doc], { - title: "My Tools", _width: 500, _yMargin: 20, lockedPosition: true, _chromeStatus: "disabled", forceActive: true, system: true, _stayInCollection: true, _hideContextMenu: true, + title: "My Tools", _width: 500, _yMargin: 20, ignoreClick: true, lockedPosition: true, _chromeStatus: "disabled", forceActive: true, system: true, _stayInCollection: true, _hideContextMenu: true, })) as any as Doc; doc.myTools = toolsStack; @@ -738,7 +745,7 @@ export class CurrentUserUtils { doc.myDashboards = new PrefetchProxy(Docs.Create.TreeDocument([], { title: "My Dashboards", _height: 400, treeViewHideTitle: true, _xMargin: 5, _yMargin: 5, _gridGap: 5, forceActive: true, childDropAction: "alias", - treeViewTruncateTitleWidth: 150, treeViewPreventOpen: false, + treeViewTruncateTitleWidth: 150, treeViewPreventOpen: false, ignoreClick: true, lockedPosition: true, boxShadow: "0 0", dontRegisterChildViews: true, targetDropAction: "same", system: true })); const newDashboard = ScriptField.MakeScript(`createNewDashboard(Doc.UserDoc())`); @@ -754,7 +761,7 @@ export class CurrentUserUtils { doc.myPresentations = new PrefetchProxy(Docs.Create.TreeDocument([], { title: "My Presentations", _height: 100, treeViewHideTitle: true, _xMargin: 5, _yMargin: 5, _gridGap: 5, forceActive: true, childDropAction: "alias", - treeViewTruncateTitleWidth: 150, treeViewPreventOpen: false, + treeViewTruncateTitleWidth: 150, treeViewPreventOpen: false, ignoreClick: true, lockedPosition: true, boxShadow: "0 0", dontRegisterChildViews: true, targetDropAction: "same", system: true })); const newPresentations = ScriptField.MakeScript(`createNewPresentation()`); @@ -772,7 +779,7 @@ export class CurrentUserUtils { doc.myRecentlyClosedDocs = new PrefetchProxy(Docs.Create.TreeDocument([], { title: "Recently Closed", _height: 500, treeViewHideTitle: true, _xMargin: 5, _yMargin: 5, _gridGap: 5, forceActive: true, childDropAction: "alias", - treeViewTruncateTitleWidth: 150, treeViewPreventOpen: false, + treeViewTruncateTitleWidth: 150, treeViewPreventOpen: false, ignoreClick: true, lockedPosition: true, boxShadow: "0 0", dontRegisterChildViews: true, targetDropAction: "same", system: true })); const clearAll = ScriptField.MakeScript(`getProto(self).data = new List([])`); @@ -787,7 +794,7 @@ export class CurrentUserUtils { doc.myFilter = new PrefetchProxy(Docs.Create.FilterDocument({ title: "FilterDoc", _height: 500, treeViewHideTitle: true, _xMargin: 5, _yMargin: 5, _gridGap: 5, forceActive: true, childDropAction: "none", - treeViewTruncateTitleWidth: 150, treeViewPreventOpen: false, + treeViewTruncateTitleWidth: 150, treeViewPreventOpen: false, ignoreClick: true, lockedPosition: true, boxShadow: "0 0", dontRegisterChildViews: true, targetDropAction: "same", system: true })); const clearAll = ScriptField.MakeScript(`getProto(self).data = new List([])`); @@ -803,7 +810,7 @@ export class CurrentUserUtils { doc.treeViewExpandedView = "fields"; doc.myUserDoc = new PrefetchProxy(Docs.Create.TreeDocument([doc], { treeViewHideTitle: true, _xMargin: 5, _yMargin: 5, _gridGap: 5, forceActive: true, title: "My UserDoc", - treeViewTruncateTitleWidth: 150, treeViewPreventOpen: false, + treeViewTruncateTitleWidth: 150, treeViewPreventOpen: false, ignoreClick: true, lockedPosition: true, boxShadow: "0 0", dontRegisterChildViews: true, targetDropAction: "same", system: true })) as any as Doc; } @@ -869,9 +876,30 @@ export class CurrentUserUtils { } // Sharing sidebar is where shared documents are contained - static setupSharingSidebar(doc: Doc) { + static async setupSharingSidebar(doc: Doc, sharingDocumentId: string, linkDatabaseId: string) { + if (doc.myLinkDatabase === undefined) { + let linkDocs = Docs.newAccount ? undefined : await DocServer.GetRefField(linkDatabaseId); + if (!linkDocs) { + linkDocs = new Doc(linkDatabaseId, true); + (linkDocs as Doc).author = Doc.CurrentUserEmail; + (linkDocs as Doc).data = new List<Doc>([]); + (linkDocs as Doc)["acl-Public"] = SharingPermissions.Add; + } + doc.myLinkDatabase = new PrefetchProxy(linkDocs); + } if (doc.mySharedDocs === undefined) { - doc.mySharedDocs = new PrefetchProxy(Docs.Create.StackingDocument([], { title: "My SharedDocs", childDropAction: "alias", system: true, contentPointerEvents: "none", childLimitHeight: 0, _yMargin: 50, _gridGap: 15, _showTitle: "title", ignoreClick: true, lockedPosition: true })); + let sharedDocs = Docs.newAccount ? undefined : await DocServer.GetRefField(sharingDocumentId + "outer"); + if (!sharedDocs) { + sharedDocs = Docs.Create.StackingDocument([], { + title: "My SharedDocs", childDropAction: "alias", system: true, contentPointerEvents: "none", childLimitHeight: 0, _yMargin: 50, _gridGap: 15, + _showTitle: "title", ignoreClick: true, lockedPosition: true, + }, sharingDocumentId + "outer", sharingDocumentId); + (sharedDocs as Doc)["acl-Public"] = Doc.GetProto(sharedDocs as Doc)["acl-Public"] = SharingPermissions.Add; + } + if (sharedDocs instanceof Doc) { + sharedDocs.userColor = sharedDocs.userColor || "rgb(202, 202, 202)"; + } + doc.mySharedDocs = new PrefetchProxy(sharedDocs); } } @@ -938,11 +966,18 @@ export class CurrentUserUtils { return doc.clickFuncs as Doc; } - static async updateUserDocument(doc: Doc) { + static async updateUserDocument(doc: Doc, sharingDocumentId: string, linkDatabaseId: string) { + if (!doc.globalGroupDatabase) doc.globalGroupDatabase = Docs.Prototypes.MainGroupDocument(); + const groups = await DocListCastAsync((doc.globalGroupDatabase as Doc).data); + reaction(() => DateCast((doc.globalGroupDatabase as Doc).lastModified), + async () => { + const groups = await DocListCastAsync((doc.globalGroupDatabase as Doc).data); + const mygroups = groups?.filter(group => JSON.parse(StrCast(group.members)).includes(Doc.CurrentUserEmail)) || []; + SnappingManager.SetCachedGroups(["Public", ...mygroups?.map(g => StrCast(g.title))]); + }, { fireImmediately: true }); doc.system = true; doc.noviceMode = doc.noviceMode === undefined ? "true" : doc.noviceMode; doc.title = Doc.CurrentUserEmail; - doc.userColor = doc.userColor || "#12121233"; doc._raiseWhenDragged = true; doc.activeInkPen = doc; doc.activeInkColor = StrCast(doc.activeInkColor, "rgb(0, 0, 0)"); @@ -971,10 +1006,8 @@ export class CurrentUserUtils { this.setupOverlays(doc); // documents in overlay layer this.setupDockedButtons(doc); // the bottom bar of font icons await this.setupSidebarButtons(doc); // the pop-out left sidebar of tools/panels - this.setupMenuPanel(doc); - doc.globalLinkDatabase = Docs.Prototypes.MainLinkDocument(); - doc.globalScriptDatabase = Docs.Prototypes.MainScriptDocument(); - doc.globalGroupDatabase = Docs.Prototypes.MainGroupDocument(); + await this.setupMenuPanel(doc, sharingDocumentId, linkDatabaseId); + if (!doc.globalScriptDatabase) doc.globalScriptDatabase = Docs.Prototypes.MainScriptDocument(); setTimeout(() => this.setupDefaultPresentation(doc), 0); // presentation that's initially triggered @@ -991,13 +1024,18 @@ export class CurrentUserUtils { // Doc.AddDocToList(Cast(doc["template-notes"], Doc, null), "data", deleg); // } // }); + setTimeout(() => DocServer.UPDATE_SERVER_CACHE(), 2500); return doc; } public static async loadCurrentUser() { - return rp.get(Utils.prepend("/getCurrentUser")).then(response => { + return rp.get(Utils.prepend("/getCurrentUser")).then(async response => { if (response) { - const result: { id: string, email: string } = JSON.parse(response); + const result: { id: string, email: string, cacheDocumentIds: string } = JSON.parse(response); + Doc.CurrentUserEmail = result.email; + resolvedPorts = JSON.parse(await Networking.FetchFromServer("/resolvedPorts")); + DocServer.init(window.location.protocol, window.location.hostname, resolvedPorts.socket, result.email); + result.cacheDocumentIds && (await DocServer.GetRefFields(result.cacheDocumentIds.split(";"))); return result; } else { throw new Error("There should be a user! Why does Dash think there isn't one?"); @@ -1005,13 +1043,17 @@ export class CurrentUserUtils { }); } - public static async loadUserDocument({ id, email }: { id: string, email: string }) { + public static async loadUserDocument(id: string) { this.curr_id = id; - Doc.CurrentUserEmail = email; - await rp.get(Utils.prepend("/getUserDocumentId")).then(id => { - if (id && id !== "guest") { - return DocServer.GetRefField(id).then(async field => - Doc.SetUserDoc(await this.updateUserDocument(field instanceof Doc ? field : new Doc(id, true)))); + await rp.get(Utils.prepend("/getUserDocumentIds")).then(ids => { + const { userDocumentId, sharingDocumentId, linkDatabaseId } = JSON.parse(ids); + if (userDocumentId !== "guest") { + return DocServer.GetRefField(userDocumentId).then(async field => { + Docs.newAccount = !(field instanceof Doc); + await Docs.Prototypes.initialize(); + const userDoc = Docs.newAccount ? new Doc(userDocumentId, true) : field as Doc; + return this.updateUserDocument(Doc.SetUserDoc(userDoc), sharingDocumentId, linkDatabaseId); + }); } else { throw new Error("There should be a user id! Why does Dash think there isn't one?"); } @@ -1071,7 +1113,7 @@ export class CurrentUserUtils { const response = await fetch(upload, { method: "POST", body: formData }); const json = await response.json(); if (json !== "error") { - const doc = await DocServer.GetRefField(json); + const doc = Docs.newAccount ? undefined : await DocServer.GetRefField(json); if (doc instanceof Doc) { setTimeout(() => SearchUtil.Search(`{!join from=id to=proto_i}id:link*`, true, {}).then(docs => docs.docs.forEach(d => LinkManager.Instance.addLink(d))), 2000); // need to give solr some time to update so that this query will find any link docs we've added. @@ -1082,6 +1124,9 @@ export class CurrentUserUtils { const disposer = OverlayView.ShowSpinner(); DocListCastAsync(importDocs.data).then(async list => { const results = await DocUtils.uploadFilesToDocs(Array.from(input.files || []), {}); + if (results.length !== input.files?.length) { + alert("Error uploading files - possibly due to unsupported file types"); + } list?.splice(0, 0, ...results); disposer(); }); @@ -1127,8 +1172,9 @@ export class CurrentUserUtils { CurrentUserUtils.openDashboard(userDoc, dashboardDoc); } - public static GetNewTextDoc(title: string, x: number, y: number, width?: number, height?: number) { + public static GetNewTextDoc(title: string, x: number, y: number, width?: number, height?: number, noMargins?: boolean) { const tbox = Docs.Create.TextDocument("", { + _xMargin: noMargins ? 0 : undefined, _yMargin: noMargins ? 0 : undefined, _width: width || 200, _height: height || 100, x: x, y: y, _autoHeight: true, _fontSize: StrCast(Doc.UserDoc().fontSize), _fontFamily: StrCast(Doc.UserDoc().fontFamily), title }); @@ -1147,6 +1193,7 @@ export class CurrentUserUtils { public static get MyRecentlyClosed() { return Cast(Doc.UserDoc().myRecentlyClosedDocs, Doc, null); } public static get MyDashboards() { return Cast(Doc.UserDoc().myDashboards, Doc, null); } public static get EmptyPane() { return Cast(Doc.UserDoc().emptyPane, Doc, null); } + public static get OverlayDocs() { return DocListCast((Doc.UserDoc().myOverlayDocs as Doc)?.data); } } Scripting.addGlobal(function openDragFactory(dragFactory: Doc) { @@ -1165,7 +1212,5 @@ Scripting.addGlobal(function createNewPresentation() { return MainView.Instance. "creates a new presentation when called"); Scripting.addGlobal(function links(doc: any) { return new List(LinkManager.Instance.getAllRelatedLinks(doc)); }, "returns all the links to the document or its annotations", "(doc: any)"); -Scripting.addGlobal(function directLinks(doc: any) { return new List(LinkManager.Instance.getAllDirectLinks(doc)); }, - "returns all the links directly to the document", "(doc: any)"); Scripting.addGlobal(function importDocument() { return CurrentUserUtils.importDocument(); }, "imports files from device directly into the import sidebar"); diff --git a/src/client/util/DocumentManager.ts b/src/client/util/DocumentManager.ts index c3d78a028..a6816c7f9 100644 --- a/src/client/util/DocumentManager.ts +++ b/src/client/util/DocumentManager.ts @@ -1,16 +1,17 @@ -import { action, computed, observable } from 'mobx'; -import { Doc, DocListCastAsync, DocListCast, Opt } from '../../fields/Doc'; +import { action, observable } from 'mobx'; +import { Doc, DocListCast, DocListCastAsync, Opt } from '../../fields/Doc'; import { Id } from '../../fields/FieldSymbols'; import { Cast, NumCast, StrCast } from '../../fields/Types'; +import { returnFalse } from '../../Utils'; +import { DocumentType } from '../documents/DocumentTypes'; import { CollectionDockingView } from '../views/collections/CollectionDockingView'; import { CollectionView } from '../views/collections/CollectionView'; -import { DocumentView, DocFocusFunc } from '../views/nodes/DocumentView'; +import { DocumentView } from '../views/nodes/DocumentView'; import { LinkManager } from './LinkManager'; import { Scripting } from './Scripting'; import { SelectionManager } from './SelectionManager'; -import { DocumentType } from '../documents/DocumentTypes'; -import { TraceMobx } from '../../fields/util'; -import { returnFalse } from '../../Utils'; +import { LinkDocPreview } from '../views/nodes/LinkDocPreview'; +import { FormattedTextBoxComment } from '../views/nodes/formattedText/FormattedTextBoxComment'; export type CreateViewFunc = (doc: Doc, followLinkLocation: string, finished?: () => void) => void; @@ -19,6 +20,7 @@ export class DocumentManager { //global holds all of the nodes (regardless of which collection they're in) @observable public DocumentViews: DocumentView[] = []; + @observable LinkedDocumentViews: { a: DocumentView, b: DocumentView, l: Doc }[] = []; // singleton instance private static _instance: DocumentManager; @@ -32,6 +34,26 @@ export class DocumentManager { private constructor() { } + @action + public AddView = (view: DocumentView) => { + const linksList = DocListCast(view.props.Document.links); + linksList.forEach(link => { + const linkToDoc = link && LinkManager.getOppositeAnchor(link, view.props.Document); + linkToDoc && DocumentManager.Instance.DocumentViews.filter(dv => Doc.AreProtosEqual(dv.props.Document, linkToDoc)).forEach(dv => { + if (dv.props.Document.type !== DocumentType.LINK || dv.props.LayoutTemplateString !== view.props.LayoutTemplateString) { + this.LinkedDocumentViews.push({ a: dv, b: view, l: link }); + } + }); + }); + this.DocumentViews.push(view); + } + public RemoveView = (view: DocumentView) => { + const index = this.DocumentViews.indexOf(view); + index !== -1 && this.DocumentViews.splice(index, 1); + + this.LinkedDocumentViews.slice().forEach(action((pair, i) => pair.a === view || pair.b === view ? this.LinkedDocumentViews.splice(i, 1) : null)); + } + //gets all views public getDocumentViewsById(id: string) { const toReturn: DocumentView[] = []; @@ -88,7 +110,7 @@ export class DocumentManager { public getFirstDocumentView = (toFind: Doc, originatingDoc: Opt<Doc> = undefined): DocumentView | undefined => { const views = this.getDocumentViews(toFind).filter(view => view.props.Document !== originatingDoc); - return views?.find(view => view.props.focus !== returnFalse) || (views.length ? views[0] : undefined); + return views?.find(view => view.ContentDiv?.getBoundingClientRect().width && view.props.focus !== returnFalse) || views?.find(view => view.props.focus !== returnFalse) || (views.length ? views[0] : undefined); } public getDocumentViews(toFind: Doc): DocumentView[] { const toReturn: DocumentView[] = []; @@ -105,39 +127,20 @@ export class DocumentManager { return toReturn; } - @computed - public get LinkedDocumentViews() { - TraceMobx(); - const pairs = DocumentManager.Instance.DocumentViews.reduce((pairs, dv) => { - const linksList = DocListCast(dv.props.Document.links); - pairs.push(...linksList.reduce((pairs, link) => { - const linkToDoc = link && LinkManager.getOppositeAnchor(link, dv.props.Document); - linkToDoc && DocumentManager.Instance.getDocumentViews(linkToDoc).map(docView1 => { - if (dv.props.Document.type !== DocumentType.LINK || dv.props.LayoutTemplateString !== docView1.props.LayoutTemplateString) { - pairs.push({ a: dv, b: docView1, l: link }); - } - }); - return pairs; - }, [] as { a: DocumentView, b: DocumentView, l: Doc }[])); - return pairs; - }, [] as { a: DocumentView, b: DocumentView, l: Doc }[]); - return pairs; - } - - static addRightSplit = (doc: Doc, finished?: () => void) => { + static addView = (doc: Doc, finished?: () => void) => { CollectionDockingView.AddSplit(doc, "right"); finished?.(); } public jumpToDocument = async ( targetDoc: Doc, // document to display willZoom: boolean, // whether to zoom doc to take up most of screen - createViewFunc = DocumentManager.addRightSplit, // how to create a view of the doc if it doesn't exist + createViewFunc = DocumentManager.addView, // how to create a view of the doc if it doesn't exist docContext?: Doc, // context to load that should contain the target linkDoc?: Doc, // link that's being followed closeContextIfNotFound: boolean = false, // after opening a context where the document should be, this determines whether the context should be closed if the Doc isn't actually there originatingDoc: Opt<Doc> = undefined, // doc that initiated the display of the target odoc - finished?: () => void + finished?: () => void, ): Promise<void> => { const getFirstDocView = DocumentManager.Instance.getFirstDocumentView; const focusAndFinish = () => { finished?.(); return false; }; @@ -150,7 +153,7 @@ export class DocumentManager { }; const docView = getFirstDocView(targetDoc, originatingDoc); let annotatedDoc = await Cast(targetDoc.annotationOn, Doc); - if (annotatedDoc && !targetDoc?.isPushpin) { + if (annotatedDoc && annotatedDoc !== originatingDoc?.context && !targetDoc?.isPushpin) { const first = getFirstDocView(annotatedDoc); if (first) { annotatedDoc = first.props.Document; @@ -158,15 +161,23 @@ export class DocumentManager { } } if (docView) { // we have a docView already and aren't forced to create a new one ... just focus on the document. TODO move into view if necessary otherwise just highlight? + const sameContext = annotatedDoc && annotatedDoc === originatingDoc?.context; if (originatingDoc?.isPushpin) { - docView.props.Document.hidden = !docView.props.Document.hidden; + docView.props.focus(docView.props.Document, willZoom, undefined, (didFocus: boolean) => { + if (!didFocus || docView.props.Document.hidden) { + docView.props.Document.hidden = !docView.props.Document.hidden; + } + return focusAndFinish(); + }, sameContext, false);// don't want to focus the container if the source and target are in the same container, so pass 'sameContext' for dontCenter parameter + //finished?.(); } else { docView.select(false); docView.props.Document.hidden && (docView.props.Document.hidden = undefined); - docView.props.focus(docView.props.Document, willZoom, undefined, focusAndFinish); - highlight(); + // @ts-ignore + docView.props.focus(docView.props.Document, willZoom, undefined, focusAndFinish, sameContext, false); } + highlight(); } else { const contextDocs = docContext ? await DocListCastAsync(docContext.data) : undefined; const contextDoc = contextDocs?.find(doc => Doc.AreProtosEqual(doc, targetDoc)) ? docContext : undefined; @@ -177,7 +188,7 @@ export class DocumentManager { highlight(); } else { // otherwise try to get a view of the context of the target const targetDocContextView = getFirstDocView(targetDocContext); - targetDocContext._scrollY = NumCast(targetDocContext._scrollTop, 0); // this will force PDFs to activate and load their annotations / allow scrolling + targetDocContext._scrollY = targetDocContext._scrollPreviewY = NumCast(targetDocContext._scrollTop, 0); // this will force PDFs to activate and load their annotations / allow scrolling if (targetDocContextView) { // we found a context view and aren't forced to create a new one ... focus on the context first.. targetDocContext._viewTransition = "transform 500ms"; targetDocContextView.props.focus(targetDocContextView.props.Document, willZoom); @@ -192,34 +203,31 @@ export class DocumentManager { if (retryDocView) { // we found the target in the context retryDocView.props.focus(targetDoc, willZoom, undefined, focusAndFinish); // focus on the target in the context highlight(); - } - if (delay > 2500) { + } else if (delay > 1500) { // we didn't find the target, so it must have moved out of the context. Go back to just creating it. if (closeContextIfNotFound) targetDocContextView.props.removeDocument?.(targetDocContextView.props.Document); - // targetDoc.layout && createViewFunc(Doc.BrushDoc(targetDoc), finished); // create a new view of the target + if (targetDoc.layout) { + Doc.SetInPlace(targetDoc, "annotationOn", undefined, false); + createViewFunc(Doc.BrushDoc(targetDoc), finished); // create a new view of the target + } } else { setTimeout(() => findView(delay + 250), 250); } }; findView(0); } - } else { // there's no context view so we need to create one first and try again - createViewFunc(targetDocContext); // so first we create the target, but don't pass finished because we still need to create the target - setTimeout(() => { - const finalDocView = getFirstDocView(targetDoc); - const finalDocContextView = getFirstDocView(targetDocContext); - setTimeout(() => // if not, wait a bit to see if the context can be loaded (e.g., a PDF). wait interval heurisitic tries to guess how we're animating based on what's just become visible - this.jumpToDocument(targetDoc, willZoom, createViewFunc, undefined, linkDoc, true, undefined, finished), // pass true this time for closeContextIfNotFound - finalDocView ? 0 : finalDocContextView ? 250 : 2000); // so call jump to doc again and if the doc isn't found, it will be created. - }, 0); + } else { // there's no context view so we need to create one first and try again when that finishes + createViewFunc(targetDocContext, // after creating the context, this calls the finish function that will retry looking for the target + () => this.jumpToDocument(targetDoc, willZoom, createViewFunc, docContext, linkDoc, true /* if we don't find the target, we want to get rid of the context just created */, undefined, finished)); } } } } public async FollowLink(link: Opt<Doc>, doc: Doc, createViewFunc: CreateViewFunc, zoom = false, currentContext?: Doc, finished?: () => void, traverseBacklink?: boolean) { + LinkDocPreview.TargetDoc = undefined; + FormattedTextBoxComment.linkDoc = undefined; const linkDocs = link ? [link] : DocListCast(doc.links); - SelectionManager.DeselectAll(); const firstDocs = linkDocs.filter(linkDoc => Doc.AreProtosEqual(linkDoc.anchor1 as Doc, doc) || Doc.AreProtosEqual((linkDoc.anchor1 as Doc).annotationOn as Doc, doc)); // link docs where 'doc' is anchor1 const secondDocs = linkDocs.filter(linkDoc => Doc.AreProtosEqual(linkDoc.anchor2 as Doc, doc) || Doc.AreProtosEqual((linkDoc.anchor2 as Doc).annotationOn as Doc, doc)); // link docs where 'doc' is anchor2 const fwdLinkWithoutTargetView = firstDocs.find(d => DocumentManager.Instance.getDocumentViews(d.anchor2 as Doc).length === 0); diff --git a/src/client/util/DragManager.ts b/src/client/util/DragManager.ts index 91ffab41d..86e2d339e 100644 --- a/src/client/util/DragManager.ts +++ b/src/client/util/DragManager.ts @@ -129,6 +129,7 @@ export namespace DragManager { treeViewDoc?: Doc; dontHideOnDrop?: boolean; offset: number[]; + canEmbed?: boolean; userDropAction: dropActionType; // the user requested drop action -- this will be honored as specified by modifier keys defaultDropAction?: dropActionType; // an optionally specified default drop action when there is no user drop actionl - this will be honored if there is no user drop action dropAction: dropActionType; // a drop action request by the initiating code. the actual drop action may be different -- eg, if the request is 'alias', but the document is dropped within the same collection, the drop action will be switched to 'move' @@ -200,7 +201,14 @@ export namespace DragManager { } // drag a document and drop it (or make an alias/copy on drop) - export function StartDocumentDrag(eles: HTMLElement[], dragData: DocumentDragData, downX: number, downY: number, options?: DragOptions) { + export function StartDocumentDrag( + eles: HTMLElement[], + dragData: DocumentDragData, + downX: number, + downY: number, + options?: DragOptions, + dropEvent?: () => any + ) { const addAudioTag = (dropDoc: any) => { dropDoc && !dropDoc.creationDate && (dropDoc.creationDate = new DateField); dropDoc instanceof Doc && DocUtils.MakeLinkToActiveAudio(dropDoc); @@ -208,6 +216,7 @@ export namespace DragManager { }; const finishDrag = (e: DragCompleteEvent) => { const docDragData = e.docDragData; + if (dropEvent) dropEvent(); // glr: optional additional function to be called - in this case with presentation trails if (docDragData && !docDragData.droppedDocuments.length) { docDragData.dropAction = dragData.userDropAction || dragData.dropAction; docDragData.droppedDocuments = @@ -324,7 +333,7 @@ export namespace DragManager { if (dragData.dropAction === "none") return; const batch = UndoManager.StartBatch("dragging"); eles = eles.filter(e => e); - CanEmbed = false; + CanEmbed = dragData.canEmbed || false; if (!dragDiv) { dragDiv = document.createElement("div"); dragDiv.className = "dragManager-dragDiv"; @@ -339,7 +348,6 @@ export namespace DragManager { DragManager.Root().appendChild(dragDiv); } dragLabel.style.display = ""; - SnappingManager.SetIsDragging(true); const scaleXs: number[] = []; const scaleYs: number[] = []; const xs: number[] = []; @@ -408,8 +416,15 @@ export namespace DragManager { }); const hideSource = options?.hideSource ? true : false; - eles.map(ele => ele.parentElement && ele.parentElement?.className === dragData.dragDivName ? (ele.parentElement.hidden = hideSource) : (ele.hidden = hideSource)); + eles.forEach(ele => { + if (ele.parentElement && ele.parentElement?.className === dragData.dragDivName) { + ele.parentElement.hidden = hideSource; + } else { + ele.hidden = hideSource; + } + }); + SnappingManager.SetIsDragging(true); let lastX = downX; let lastY = downY; const xFromLeft = downX - elesCont.left; @@ -505,27 +520,25 @@ export namespace DragManager { const hideDragShowOriginalElements = () => { dragLabel.style.display = "none"; dragElements.map(dragElement => dragElement.parentNode === dragDiv && dragDiv.removeChild(dragElement)); - eles.map(ele => ele.parentElement && ele.parentElement?.className === dragData.dragDivName ? (ele.parentElement.hidden = false) : (ele.hidden = false)); + eles.map(ele => ele.parentElement && ele.parentElement?.className === dragData.dragDivName ? (ele.hidden = ele.parentElement.hidden = false) : (ele.hidden = false)); }; const endDrag = action(() => { + hideDragShowOriginalElements(); document.removeEventListener("pointermove", moveHandler, true); document.removeEventListener("pointerup", upHandler); + SnappingManager.SetIsDragging(false); SnappingManager.clearSnapLines(); batch.end(); }); AbortDrag = () => { - hideDragShowOriginalElements(); - SnappingManager.SetIsDragging(false); options?.dragComplete?.(new DragCompleteEvent(true, dragData)); endDrag(); }; const upHandler = (e: PointerEvent) => { - hideDragShowOriginalElements(); dispatchDrag(eles, e, dragData, xFromLeft, yFromTop, xFromRight, yFromBottom, options, finishDrag); - SnappingManager.SetIsDragging(false); - endDrag(); options?.dragComplete?.(new DragCompleteEvent(false, dragData)); + endDrag(); }; document.addEventListener("pointermove", moveHandler, true); document.addEventListener("pointerup", upHandler); diff --git a/src/client/util/GroupManager.tsx b/src/client/util/GroupManager.tsx index cb15b5081..6458de0ed 100644 --- a/src/client/util/GroupManager.tsx +++ b/src/client/util/GroupManager.tsx @@ -5,15 +5,15 @@ import * as React from "react"; import Select from 'react-select'; import * as RequestPromise from "request-promise"; import { Doc, DocListCast, DocListCastAsync, Opt } from "../../fields/Doc"; -import { Cast, StrCast } from "../../fields/Types"; -import { setGroups } from "../../fields/util"; +import { StrCast, Cast } from "../../fields/Types"; import { Utils } from "../../Utils"; -import { DocServer } from "../DocServer"; import { MainViewModal } from "../views/MainViewModal"; import { TaskCompletionBox } from "../views/nodes/TaskCompletedBox"; import "./GroupManager.scss"; import { GroupMemberView } from "./GroupMemberView"; import { SharingManager, User } from "./SharingManager"; +import { listSpec } from "../../fields/Schema"; +import { DateField } from "../../fields/DateField"; /** * Interface for options for the react-select component @@ -34,62 +34,23 @@ export class GroupManager extends React.Component<{}> { @observable private createGroupModalOpen: boolean = false; private inputRef: React.RefObject<HTMLInputElement> = React.createRef(); // the ref for the input box. private createGroupButtonRef: React.RefObject<HTMLButtonElement> = React.createRef(); // the ref for the group creation button - private currentUserGroups: string[] = []; // the list of groups the current user is a member of @observable private buttonColour: "#979797" | "black" = "#979797"; @observable private groupSort: "ascending" | "descending" | "none" = "none"; - private populating: boolean = false; - - constructor(props: Readonly<{}>) { super(props); GroupManager.Instance = this; } - /** - * Populates the list of users and groups. - */ - componentDidMount() { - this.populateUsers(); - this.populateGroups(); - } + componentDidMount() { this.populateUsers(); } /** * Fetches the list of users stored on the database. */ populateUsers = async () => { - if (!this.populating) { - this.populating = true; - runInAction(() => this.users = []); - const userList = await RequestPromise.get(Utils.prepend("/getUsers")); - const raw = JSON.parse(userList) as User[]; - const evaluating = raw.map(async user => { - const userDocument = await DocServer.GetRefField(user.userDocumentId); - if (userDocument instanceof Doc) { - const notificationDoc = await Cast(userDocument.mySharedDocs, Doc); - runInAction(() => { - if (notificationDoc instanceof Doc) { - this.users.push(user.email); - } - }); - } - }); - return Promise.all(evaluating).then(() => this.populating = false); - } - } - - /** - * Populates the list of groups the current user is a member of and sets this list to be used in the GetEffectiveAcl in util.ts - */ - populateGroups = () => { - DocListCastAsync(this.GroupManagerDoc?.data).then(groups => { - groups?.forEach(group => { - const members: string[] = JSON.parse(StrCast(group.members)); - if (members.includes(Doc.CurrentUserEmail)) this.currentUserGroups.push(StrCast(group.groupName)); - }); - this.currentUserGroups.push("Public"); - setGroups(this.currentUserGroups); - }); + const userList = await RequestPromise.get(Utils.prepend("/getUsers")); + const raw = JSON.parse(userList) as User[]; + raw.map(action(user => !this.users.some(umail => umail === user.email) && this.users.push(user.email))); } /** @@ -107,7 +68,6 @@ export class GroupManager extends React.Component<{}> { // SelectionManager.DeselectAll(); this.isOpen = true; this.populateUsers(); - this.populateGroups(); } /** @@ -126,25 +86,24 @@ export class GroupManager extends React.Component<{}> { /** * @returns the database of groups. */ - get GroupManagerDoc(): Doc | undefined { - return Doc.UserDoc().globalGroupDatabase as Doc; - } + @computed get GroupManagerDoc(): Doc | undefined { return Doc.UserDoc().globalGroupDatabase as Doc; } /** * @returns a list of all group documents. */ - getAllGroups(): Doc[] { - const groupDoc = this.GroupManagerDoc; - return groupDoc ? DocListCast(groupDoc.data) : []; - } + @computed get allGroups(): Doc[] { return DocListCast(this.GroupManagerDoc?.data); } + + /** + * @returns the members of the admin group. + */ + @computed get adminGroupMembers(): string[] { return this.getGroup("Admin") ? JSON.parse(StrCast(this.getGroup("Admin")!.members)) : ""; } /** * @returns a group document based on the group name. * @param groupName */ getGroup(groupName: string): Doc | undefined { - const groupDoc = this.getAllGroups().find(group => group.groupName === groupName); - return groupDoc; + return this.allGroups.find(group => group.title === groupName); } /** @@ -152,15 +111,9 @@ export class GroupManager extends React.Component<{}> { */ getGroupMembers(group: string | Doc): string[] { if (group instanceof Doc) return JSON.parse(StrCast(group.members)) as string[]; - else return JSON.parse(StrCast(this.getGroup(group)!.members)) as string[]; + return JSON.parse(StrCast(this.getGroup(group)!.members)) as string[]; } - /** - * @returns the members of the admin group. - */ - get adminGroupMembers(): string[] { - return this.getGroup("Admin") ? JSON.parse(StrCast(this.getGroup("Admin")!.members)) : ""; - } /** * @returns a boolean indicating whether the current user has access to edit group documents. @@ -178,14 +131,11 @@ export class GroupManager extends React.Component<{}> { * @param memberEmails */ createGroupDoc(groupName: string, memberEmails: string[] = []) { - const groupDoc = new Doc; - groupDoc.groupName = groupName.toLowerCase() === "admin" ? "Admin" : groupName; + const name = groupName.toLowerCase() === "admin" ? "Admin" : groupName; + const groupDoc = new Doc("GROUP:" + name, true); + groupDoc.title = name; groupDoc.owners = JSON.stringify([Doc.CurrentUserEmail]); groupDoc.members = JSON.stringify(memberEmails); - if (memberEmails.includes(Doc.CurrentUserEmail)) { - this.currentUserGroups.push(groupName); - setGroups(this.currentUserGroups); - } this.addGroup(groupDoc); } @@ -196,6 +146,7 @@ export class GroupManager extends React.Component<{}> { addGroup(groupDoc: Doc): boolean { if (this.GroupManagerDoc) { Doc.AddDocToList(this.GroupManagerDoc, "data", groupDoc); + this.GroupManagerDoc.lastModified = new DateField; return true; } return false; @@ -205,19 +156,20 @@ export class GroupManager extends React.Component<{}> { * Deletes a group from the database of group documents and @returns whether the group was deleted or not. * @param group */ + @action deleteGroup(group: Doc): boolean { if (group) { if (this.GroupManagerDoc && this.hasEditAccess(group)) { Doc.RemoveDocFromList(this.GroupManagerDoc, "data", group); SharingManager.Instance.removeGroup(group); - const members: string[] = JSON.parse(StrCast(group.members)); + const members = JSON.parse(StrCast(group.members)); if (members.includes(Doc.CurrentUserEmail)) { - const index = this.currentUserGroups.findIndex(groupName => groupName === group.groupName); - index !== -1 && this.currentUserGroups.splice(index, 1); - setGroups(this.currentUserGroups); + const index = DocListCast(this.GroupManagerDoc.data).findIndex(grp => grp === group); + index !== -1 && Cast(this.GroupManagerDoc.data, listSpec(Doc), [])?.splice(index, 1); } + this.GroupManagerDoc.lastModified = new DateField; if (group === this.currentGroup) { - runInAction(() => this.currentGroup = undefined); + this.currentGroup = undefined; } return true; } @@ -232,10 +184,11 @@ export class GroupManager extends React.Component<{}> { */ addMemberToGroup(groupDoc: Doc, email: string) { if (this.hasEditAccess(groupDoc)) { - const memberList: string[] = JSON.parse(StrCast(groupDoc.members)); + const memberList = JSON.parse(StrCast(groupDoc.members)); !memberList.includes(email) && memberList.push(email); groupDoc.members = JSON.stringify(memberList); SharingManager.Instance.shareWithAddedMember(groupDoc, email); + this.GroupManagerDoc && (this.GroupManagerDoc.lastModified = new DateField); } } @@ -246,12 +199,13 @@ export class GroupManager extends React.Component<{}> { */ removeMemberFromGroup(groupDoc: Doc, email: string) { if (this.hasEditAccess(groupDoc)) { - const memberList: string[] = JSON.parse(StrCast(groupDoc.members)); + const memberList = JSON.parse(StrCast(groupDoc.members)); const index = memberList.indexOf(email); if (index !== -1) { const user = memberList.splice(index, 1)[0]; groupDoc.members = JSON.stringify(memberList); SharingManager.Instance.removeMember(groupDoc, email); + this.GroupManagerDoc && (this.GroupManagerDoc.lastModified = new DateField); } } } @@ -278,21 +232,24 @@ export class GroupManager extends React.Component<{}> { */ @action createGroup = () => { - if (!this.inputRef.current?.value) { + const { value } = this.inputRef.current!; + if (!value) { alert("Please enter a group name"); return; } - if (this.inputRef.current.value.toLowerCase() === "admin" && this.getGroup("Admin")) { - alert("You cannot override the Admin group"); - return; + if (["admin", "public", "override"].includes(value.toLowerCase())) { + if (value.toLowerCase() !== "admin" || (value.toLowerCase() === "admin" && this.getGroup("Admin"))) { + alert(`You cannot override the ${value.charAt(0).toUpperCase() + value.slice(1)} group`); + return; + } } - if (this.getGroup(this.inputRef.current.value)) { + if (this.getGroup(value)) { alert("Please select a unique group name"); return; } - this.createGroupDoc(this.inputRef.current.value, this.selectedUsers?.map(user => user.value)); + this.createGroupDoc(value, this.selectedUsers?.map(user => user.value)); this.selectedUsers = null; - this.inputRef.current.value = ""; + this.inputRef.current!.value = ""; this.buttonColour = "#979797"; const { left, width, top } = this.createGroupButtonRef.current!.getBoundingClientRect(); @@ -378,14 +335,13 @@ export class GroupManager extends React.Component<{}> { private get groupInterface() { const sortGroups = (d1: Doc, d2: Doc) => { - const g1 = StrCast(d1.groupName); - const g2 = StrCast(d2.groupName); + const g1 = StrCast(d1.title); + const g2 = StrCast(d2.title); return g1 < g2 ? -1 : g1 === g2 ? 0 : 1; }; - let groups = this.getAllGroups(); - groups = this.groupSort === "ascending" ? groups.sort(sortGroups) : this.groupSort === "descending" ? groups.sort(sortGroups).reverse() : groups; + const groups = this.groupSort === "ascending" ? this.allGroups.sort(sortGroups) : this.groupSort === "descending" ? this.allGroups.sort(sortGroups).reverse() : this.allGroups; return ( <div className="group-interface"> @@ -399,7 +355,7 @@ export class GroupManager extends React.Component<{}> { <div className="group-heading"> <p><b>Manage Groups</b></p> <button onClick={action(() => this.createGroupModalOpen = true)}> - <FontAwesomeIcon icon={"plus-hexagon"} size={"sm"} /> Create Group + <FontAwesomeIcon icon={"plus"} size={"sm"} /> Create Group </button> <div className={"close-button"} onClick={this.close}> <FontAwesomeIcon icon={"times"} color={"black"} size={"lg"} /> @@ -418,9 +374,9 @@ export class GroupManager extends React.Component<{}> { {groups.map(group => <div className="group-row" - key={StrCast(group.groupName)} + key={StrCast(group.title || group.groupName)} > - <div className="group-name" >{group.groupName}</div> + <div className="group-name" >{group.title || group.groupName}</div> <div className="group-info" onClick={action(() => this.currentGroup = group)}> <FontAwesomeIcon icon={"info-circle"} color={"#e8e8e8"} size={"sm"} style={{ backgroundColor: "#1e89d7", borderRadius: "100%", border: "1px solid #1e89d7" }} /> </div> @@ -434,16 +390,13 @@ export class GroupManager extends React.Component<{}> { } render() { - return ( - <MainViewModal - contents={this.groupInterface} - isDisplayed={this.isOpen} - interactive={true} - dialogueBoxStyle={{ zIndex: 1002 }} - overlayStyle={{ zIndex: 1001 }} - closeOnExternalClick={this.close} - /> - ); + return <MainViewModal + contents={this.groupInterface} + isDisplayed={this.isOpen} + interactive={true} + dialogueBoxStyle={{ zIndex: 1002 }} + overlayStyle={{ zIndex: 1001 }} + closeOnExternalClick={this.close} + />; } - }
\ No newline at end of file diff --git a/src/client/util/GroupMemberView.tsx b/src/client/util/GroupMemberView.tsx index 4ead01e9f..927200ed3 100644 --- a/src/client/util/GroupMemberView.tsx +++ b/src/client/util/GroupMemberView.tsx @@ -33,8 +33,8 @@ export class GroupMemberView extends React.Component<GroupMemberViewProps> { <input className="group-title" style={{ marginLeft: !hasEditAccess ? "-14%" : 0 }} - value={StrCast(this.props.group.groupName)} - onChange={e => this.props.group.groupName = e.currentTarget.value} + value={StrCast(this.props.group.title || this.props.group.groupName)} + onChange={e => this.props.group.title = e.currentTarget.value} disabled={!hasEditAccess} > </input> diff --git a/src/client/util/Import & Export/ImageUtils.ts b/src/client/util/Import & Export/ImageUtils.ts index 0d12b39b8..9bd92a316 100644 --- a/src/client/util/Import & Export/ImageUtils.ts +++ b/src/client/util/Import & Export/ImageUtils.ts @@ -1,7 +1,6 @@ import { Doc } from "../../../fields/Doc"; import { ImageField } from "../../../fields/URLField"; -import { Cast, StrCast } from "../../../fields/Types"; -import { Docs } from "../../documents/Documents"; +import { Cast, StrCast, NumCast } from "../../../fields/Types"; import { Networking } from "../../Network"; import { Id } from "../../../fields/FieldSymbols"; import { Utils } from "../../../Utils"; @@ -22,6 +21,7 @@ export namespace ImageUtils { } = await Networking.PostToServer("/inspectImage", { source }); document.exif = error || Doc.Get.FromJson({ data }); const proto = Doc.GetProto(document); + nativeWidth && (document._height = NumCast(document._width) * nativeHeight / nativeWidth); proto["data-nativeWidth"] = nativeWidth; proto["data-nativeHeight"] = nativeHeight; proto["data-path"] = source; diff --git a/src/client/util/LinkManager.ts b/src/client/util/LinkManager.ts index 4f3cfcd03..802b8ae7b 100644 --- a/src/client/util/LinkManager.ts +++ b/src/client/util/LinkManager.ts @@ -1,8 +1,7 @@ import { Doc, DocListCast, Opt } from "../../fields/Doc"; -import { List } from "../../fields/List"; -import { listSpec } from "../../fields/Schema"; import { Cast, StrCast } from "../../fields/Types"; -import { CurrentUserUtils } from "./CurrentUserUtils"; +import { SharingManager } from "./SharingManager"; +import { computedFn } from "mobx-utils"; /* * link doc: @@ -23,181 +22,58 @@ import { CurrentUserUtils } from "./CurrentUserUtils"; export class LinkManager { private static _instance: LinkManager; - public static currentLink: Opt<Doc>; - - public static get Instance(): LinkManager { - return this._instance || (this._instance = new this()); - } - - private constructor() { - } - - // the linkmanagerdoc stores a list of docs representing all linkdocs in 'allLinks' and a list of strings representing all group types in 'allGroupTypes' - // lists of strings representing the metadata keys for each group type is stored under a key that is the same as the group type - public get LinkManagerDoc(): Doc | undefined { - return Doc.UserDoc().globalLinkDatabase as Doc; - } - - public getAllLinks(): Doc[] { - const ldoc = LinkManager.Instance.LinkManagerDoc; - return ldoc ? DocListCast(ldoc.data) : []; - } - - public addLink(linkDoc: Doc): boolean { - if (LinkManager.Instance.LinkManagerDoc) { - Doc.AddDocToList(LinkManager.Instance.LinkManagerDoc, "data", linkDoc); - return true; - } - return false; - } - - public deleteLink(linkDoc: Doc): boolean { - if (LinkManager.Instance.LinkManagerDoc && linkDoc instanceof Doc) { - Doc.RemoveDocFromList(LinkManager.Instance.LinkManagerDoc, "data", linkDoc); - return true; - } - return false; - } - - // finds all links that contain the given anchor - public getAllDirectLinks(anchor: Doc): Doc[] { - const related = LinkManager.Instance.getAllLinks().filter(link => link).filter(link => { - const a1 = Cast(link.anchor1, Doc, null) - const a2 = Cast(link.anchor2, Doc, null); - const protomatch1 = Doc.AreProtosEqual(anchor, a1); - const protomatch2 = Doc.AreProtosEqual(anchor, a2); - return ((a1?.title !== undefined && a2?.title !== undefined) || link.author === Doc.CurrentUserEmail) && (protomatch1 || protomatch2 || Doc.AreProtosEqual(link, anchor)); - }); - return related; - } - // finds all links that contain the given anchor - public getAllRelatedLinks(anchor: Doc): Doc[] { - const related = LinkManager.Instance.getAllDirectLinks(anchor); - DocListCast(anchor[Doc.LayoutFieldKey(anchor) + "-annotations"]).map(anno => { - related.push(...LinkManager.Instance.getAllRelatedLinks(anno)); + public static get Instance(): LinkManager { return this._instance || (this._instance = new this()); } + + public addLink(linkDoc: Doc) { return Doc.AddDocToList(Doc.LinkDBDoc(), "data", linkDoc); } + public deleteLink(linkDoc: Doc) { return Doc.RemoveDocFromList(Doc.LinkDBDoc(), "data", linkDoc); } + public deleteAllLinksOnAnchor(anchor: Doc) { LinkManager.Instance.relatedLinker(anchor).forEach(linkDoc => LinkManager.Instance.deleteLink(linkDoc)); } + + public getAllRelatedLinks(anchor: Doc) { return this.relatedLinker(anchor); } // finds all links that contain the given anchor + public getAllDirectLinks(anchor: Doc): Doc[] { return this.directLinker(anchor); } // finds all links that contain the given anchor + public getAllLinks(): Doc[] { return this.allLinks(); } + + allLinks = computedFn(function allLinks(this: any): Doc[] { + const lset = new Set<Doc>(DocListCast(Doc.LinkDBDoc().data)); + SharingManager.Instance.users.forEach(user => DocListCast(user.linkDatabase?.data).forEach(doc => lset.add(doc))); + return Array.from(lset); + }, true); + + directLinker = computedFn(function directLinker(this: any, anchor: Doc): Doc[] { + return LinkManager.Instance.allLinks().filter(link => { + const a1 = Cast(link?.anchor1, Doc, null); + const a2 = Cast(link?.anchor2, Doc, null); + return link && ((a1?.author !== undefined && a2?.author !== undefined) || link.author === Doc.CurrentUserEmail) && (Doc.AreProtosEqual(anchor, a1) || Doc.AreProtosEqual(anchor, a2) || Doc.AreProtosEqual(link, anchor)); }); - return related; - } - - public deleteAllLinksOnAnchor(anchor: Doc) { - const related = LinkManager.Instance.getAllRelatedLinks(anchor); - related.forEach(linkDoc => LinkManager.Instance.deleteLink(linkDoc)); - } - - public addGroupType(groupType: string): boolean { - if (LinkManager.Instance.LinkManagerDoc) { - LinkManager.Instance.LinkManagerDoc[groupType] = new List<string>([]); - const groupTypes = LinkManager.Instance.getAllGroupTypes(); - groupTypes.push(groupType); - LinkManager.Instance.LinkManagerDoc.allGroupTypes = new List<string>(groupTypes); - return true; - } - return false; - } + }, true); - // removes all group docs from all links with the given group type - public deleteGroupType(groupType: string): boolean { - if (LinkManager.Instance.LinkManagerDoc) { - if (LinkManager.Instance.LinkManagerDoc[groupType]) { - const groupTypes = LinkManager.Instance.getAllGroupTypes(); - const index = groupTypes.findIndex(type => type.toUpperCase() === groupType.toUpperCase()); - if (index > -1) groupTypes.splice(index, 1); - LinkManager.Instance.LinkManagerDoc.allGroupTypes = new List<string>(groupTypes); - LinkManager.Instance.LinkManagerDoc[groupType] = undefined; - LinkManager.Instance.getAllLinks().forEach(async linkDoc => { - const anchor1 = await Cast(linkDoc.anchor1, Doc); - const anchor2 = await Cast(linkDoc.anchor2, Doc); - anchor1 && LinkManager.Instance.removeGroupFromAnchor(linkDoc, anchor1, groupType); - anchor2 && LinkManager.Instance.removeGroupFromAnchor(linkDoc, anchor2, groupType); - }); - } - return true; - } else return false; - } - - public getAllGroupTypes(): string[] { - if (LinkManager.Instance.LinkManagerDoc) { - if (LinkManager.Instance.LinkManagerDoc.allGroupTypes) { - return Cast(LinkManager.Instance.LinkManagerDoc.allGroupTypes, listSpec("string"), []); - } else { - LinkManager.Instance.LinkManagerDoc.allGroupTypes = new List<string>([]); - return []; - } - } - return []; - } - - // gets the groups associates with an anchor in a link - public getAnchorGroups(linkDoc: Doc, anchor?: Doc): Array<Doc> { - if (Doc.AreProtosEqual(anchor, Cast(linkDoc.anchor1, Doc, null))) { - return DocListCast(linkDoc.anchor1Groups); - } else { - return DocListCast(linkDoc.anchor2Groups); - } - } - public addGroupToAnchor(linkDoc: Doc, anchor: Doc, groupDoc: Doc, replace: boolean = false) { - Doc.GetProto(linkDoc).linkRelationship = groupDoc.linkRelationship; - } - - // removes group doc of given group type only from given anchor on given link - public removeGroupFromAnchor(linkDoc: Doc, anchor: Doc, groupType: string) { - Doc.GetProto(linkDoc).linkRelationship = "-ungrouped-"; - } + relatedLinker = computedFn(function relatedLinker(this: any, anchor: Doc): Doc[] { + return DocListCast(anchor[Doc.LayoutFieldKey(anchor) + "-annotations"]).reduce((list, anno) => + [...list, ...LinkManager.Instance.relatedLinker(anno)], + LinkManager.Instance.directLinker(anchor).slice()); + }, true); // returns map of group type to anchor's links in that group type public getRelatedGroupedLinks(anchor: Doc): Map<string, Array<Doc>> { - const related = this.getAllRelatedLinks(anchor); const anchorGroups = new Map<string, Array<Doc>>(); - related.forEach(link => { + this.relatedLinker(anchor).forEach(link => { if (!link.linkRelationship || link?.linkRelationship !== "-ungrouped-") { const group = anchorGroups.get(StrCast(link.linkRelationship)); anchorGroups.set(StrCast(link.linkRelationship), group ? [...group, link] : [link]); - } else { // if link is in no groups then put it in default group const group = anchorGroups.get("*"); anchorGroups.set("*", group ? [...group, link] : [link]); } - }); return anchorGroups; } - // gets a list of strings representing the keys of the metadata associated with the given group type - public getMetadataKeysInGroup(groupType: string): string[] { - if (LinkManager.Instance.LinkManagerDoc) { - return LinkManager.Instance.LinkManagerDoc[groupType] ? Cast(LinkManager.Instance.LinkManagerDoc[groupType], listSpec("string"), []) : []; - } - return []; - } - - public setMetadataKeysForGroup(groupType: string, keys: string[]): boolean { - if (LinkManager.Instance.LinkManagerDoc) { - LinkManager.Instance.LinkManagerDoc[groupType] = new List<string>(keys); - return true; - } - return false; - } - - // returns a list of all metadata docs associated with the given group type - public getAllMetadataDocsInGroup(groupType: string): Array<Doc> { - const md: Doc[] = []; - const allLinks = LinkManager.Instance.getAllLinks(); - allLinks.forEach(linkDoc => { - if (StrCast(linkDoc.linkRelationship).toUpperCase() === groupType.toUpperCase()) { md.push(linkDoc); } - }); - return md; - } - // checks if a link with the given anchors exists public doesLinkExist(anchor1: Doc, anchor2: Doc): boolean { - const allLinks = LinkManager.Instance.getAllLinks(); - const index = allLinks.findIndex(linkDoc => { - return (Doc.AreProtosEqual(Cast(linkDoc.anchor1, Doc, null), anchor1) && Doc.AreProtosEqual(Cast(linkDoc.anchor2, Doc, null), anchor2)) || - (Doc.AreProtosEqual(Cast(linkDoc.anchor1, Doc, null), anchor2) && Doc.AreProtosEqual(Cast(linkDoc.anchor2, Doc, null), anchor1)); - }); - return index !== -1; + return -1 !== LinkManager.Instance.allLinks().findIndex(linkDoc => + (Doc.AreProtosEqual(Cast(linkDoc.anchor1, Doc, null), anchor1) && Doc.AreProtosEqual(Cast(linkDoc.anchor2, Doc, null), anchor2)) || + (Doc.AreProtosEqual(Cast(linkDoc.anchor1, Doc, null), anchor2) && Doc.AreProtosEqual(Cast(linkDoc.anchor2, Doc, null), anchor1))); } // finds the opposite anchor of a given anchor in a link @@ -208,6 +84,8 @@ export class LinkManager { const a2 = Cast(linkDoc.anchor2, Doc, null); if (Doc.AreProtosEqual(anchor, a1)) return a2; if (Doc.AreProtosEqual(anchor, a2)) return a1; + if (Doc.AreProtosEqual(anchor, a1.annotationOn as Doc)) return a2; + if (Doc.AreProtosEqual(anchor, a2.annotationOn as Doc)) return a1; if (Doc.AreProtosEqual(anchor, linkDoc)) return linkDoc; } }
\ No newline at end of file diff --git a/src/client/util/SearchUtil.ts b/src/client/util/SearchUtil.ts index b09eff849..79759a71d 100644 --- a/src/client/util/SearchUtil.ts +++ b/src/client/util/SearchUtil.ts @@ -42,8 +42,9 @@ export namespace SearchUtil { let replacedQuery = query.replace(/type_t:([^ )])/g, (substring, arg) => `{!join from=id to=proto_i}*:* AND ${arg}`); if (options.onlyAliases) { const header = query.match(/_[atnb]?:/) ? replacedQuery : "DEFAULT:" + replacedQuery; - replacedQuery = `{!join from=id to=proto_i}${header}`; + replacedQuery = `{!join from=id to=proto_i}* AND ${header}`; } + //console.log("Q: " + replacedQuery + " fq: " + options.fq); const gotten = await rp.get(rpquery, { qs: { ...options, q: replacedQuery } }); const result: IdSearchResult = gotten.startsWith("<") ? { ids: [], docs: [], numFound: 0, lines: [] } : JSON.parse(gotten); if (!returnDocs) { @@ -59,12 +60,14 @@ export namespace SearchUtil { const fileids = txtresult ? txtresult.ids : []; const newIds: string[] = []; const newLines: string[][] = []; - await Promise.all(fileids.map(async (tr: string, i: number) => { - const docQuery = "fileUpload_t:" + tr.substr(0, 7); //If we just have a filter query, search for * as the query - const docResult = JSON.parse(await rp.get(Utils.prepend("/dashsearch"), { qs: { ...options, q: docQuery } })); - newIds.push(...docResult.ids); - newLines.push(...docResult.ids.map((dr: any) => txtresult.lines[i])); - })); + if (fileids) { + await Promise.all(fileids.map(async (tr: string, i: number) => { + const docQuery = "fileUpload_t:" + tr.substr(0, 7); //If we just have a filter query, search for * as the query + const docResult = JSON.parse(await rp.get(Utils.prepend("/dashsearch"), { qs: { ...options, q: docQuery } })); + newIds.push(...docResult.ids); + newLines.push(...docResult.ids.map((dr: any) => txtresult.lines[i])); + })); + } const theDocs: Doc[] = []; @@ -91,7 +94,7 @@ export namespace SearchUtil { } } - return { docs: theDocs, numFound: result.numFound, highlighting, lines: theLines }; + return { docs: theDocs, numFound: Math.max(0, result.numFound), highlighting, lines: theLines }; } export async function GetAliasesOfDocument(doc: Doc): Promise<Doc[]>; diff --git a/src/client/util/SelectionManager.ts b/src/client/util/SelectionManager.ts index 008ce281c..34e88c7b0 100644 --- a/src/client/util/SelectionManager.ts +++ b/src/client/util/SelectionManager.ts @@ -2,7 +2,6 @@ import { observable, action, runInAction, ObservableMap } from "mobx"; import { Doc, Opt } from "../../fields/Doc"; import { DocumentView } from "../views/nodes/DocumentView"; import { computedFn } from "mobx-utils"; -import { List } from "../../fields/List"; import { CollectionSchemaView } from "../views/collections/CollectionSchemaView"; import { CollectionViewType } from "../views/collections/CollectionView"; @@ -67,15 +66,16 @@ export namespace SelectionManager { manager.SelectSchemaDoc(colSchema, document); } + const IsSelectedCache = computedFn(function isSelected(doc: DocumentView) { // wraapping get() in a computedFn only generates mobx() invalidations when the return value of the function for the specific get parameters has changed + return manager.SelectedDocuments.get(doc) ? true : false; + }); // computed functions, such as used in IsSelected generate errors if they're called outside of a // reaction context. Specifying the context with 'outsideReaction' allows an efficiency feature // to avoid unnecessary mobx invalidations when running inside a reaction. export function IsSelected(doc: DocumentView | undefined, outsideReaction?: boolean): boolean { return !doc ? false : outsideReaction ? manager.SelectedDocuments.get(doc) ? true : false : // get() accesses a hashtable -- setting anything in the hashtable generates a mobx invalidation for every get() - computedFn(function isSelected(doc: DocumentView) { // wraapping get() in a computedFn only generates mobx() invalidations when the return value of the function for the specific get parameters has changed - return manager.SelectedDocuments.get(doc) ? true : false; - })(doc); + IsSelectedCache(doc); } export function DeselectAll(except?: Doc): void { diff --git a/src/client/util/SerializationHelper.ts b/src/client/util/SerializationHelper.ts index 19b217726..00ac6e521 100644 --- a/src/client/util/SerializationHelper.ts +++ b/src/client/util/SerializationHelper.ts @@ -43,7 +43,7 @@ export namespace SerializationHelper { } if (!obj.__type) { - if (ClientUtils.RELEASE) { + if (true || ClientUtils.RELEASE) { console.warn("No property 'type' found in JSON."); return undefined; } else { diff --git a/src/client/util/SettingsManager.tsx b/src/client/util/SettingsManager.tsx index 17e93ad17..9934f26d3 100644 --- a/src/client/util/SettingsManager.tsx +++ b/src/client/util/SettingsManager.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import { ColorState, SketchPicker } from "react-color"; import { Doc } from "../../fields/Doc"; -import { BoolCast, StrCast } from "../../fields/Types"; +import { BoolCast, StrCast, Cast } from "../../fields/Types"; import { addStyleSheet, addStyleSheetRule, Utils } from "../../Utils"; import { GoogleAuthenticationManager } from "../apis/GoogleAuthenticationManager"; import { DocServer } from "../DocServer"; @@ -56,7 +56,7 @@ export class SettingsManager extends React.Component<{}> { @undoBatch changeFontFamily = action((e: React.ChangeEvent) => Doc.UserDoc().fontFamily = (e.currentTarget as any).value); @undoBatch changeFontSize = action((e: React.ChangeEvent) => Doc.UserDoc().fontSize = (e.currentTarget as any).value); @undoBatch switchActiveBackgroundColor = action((color: ColorState) => Doc.UserDoc().activeCollectionBackground = String(color.hex)); - @undoBatch switchUserColor = action((color: ColorState) => Doc.UserDoc().userColor = String(color.hex)); + @undoBatch switchUserColor = action((color: ColorState) => Doc.SharingDoc().userColor = String(color.hex)); @undoBatch playgroundModeToggle = action(() => { this.playgroundMode = !this.playgroundMode; @@ -205,6 +205,6 @@ export class SettingsManager extends React.Component<{}> { isDisplayed={this.isOpen} interactive={true} closeOnExternalClick={this.close} - dialogueBoxStyle={{ width: "600px" }} />; + dialogueBoxStyle={{ width: "600px", background: Cast(Doc.SharingDoc().userColor, "string", null) }} />; } }
\ No newline at end of file diff --git a/src/client/util/SharingManager.scss b/src/client/util/SharingManager.scss index b756716bf..9dc57dd1e 100644 --- a/src/client/util/SharingManager.scss +++ b/src/client/util/SharingManager.scss @@ -40,7 +40,7 @@ .permissions-select { z-index: 1; - margin-left: -100; + margin-left: -115; border: none; outline: none; text-align: justify; // for Edge @@ -59,6 +59,7 @@ margin-top: -17px; margin-bottom: 10px; font-size: 10px; + min-height: 40px; input { height: 10px; @@ -71,21 +72,25 @@ } } - .layoutDoc-acls { + .acl-container { display: flex; - flex-direction: column; float: right; - margin-right: 12; - margin-top: -15; - align-items: center; + align-items: baseline; + margin-top: -12; - label { - font-weight: normal; - font-style: italic; - } + .layoutDoc-acls, + .myDocs-acls { + flex-direction: column; + margin-right: 12; - input { - cursor: pointer; + label { + font-weight: normal; + font-style: italic; + } + + input { + cursor: pointer; + } } } } diff --git a/src/client/util/SharingManager.tsx b/src/client/util/SharingManager.tsx index a53ef3cde..2b13d2a44 100644 --- a/src/client/util/SharingManager.tsx +++ b/src/client/util/SharingManager.tsx @@ -4,10 +4,10 @@ import { observer } from "mobx-react"; import * as React from "react"; import Select from "react-select"; import * as RequestPromise from "request-promise"; -import { AclAdmin, AclPrivate, DataSym, Doc, DocListCast, Opt, AclSym } from "../../fields/Doc"; +import { AclAdmin, AclPrivate, DataSym, Doc, DocListCast, Opt, AclSym, AclAddonly, AclEdit, AclReadonly, DocListCastAsync } from "../../fields/Doc"; import { List } from "../../fields/List"; import { Cast, StrCast } from "../../fields/Types"; -import { distributeAcls, GetEffectiveAcl, SharingPermissions, TraceMobx } from "../../fields/util"; +import { distributeAcls, GetEffectiveAcl, SharingPermissions, TraceMobx, normalizeEmail } from "../../fields/util"; import { Utils } from "../../Utils"; import { DocServer } from "../DocServer"; import { CollectionView } from "../views/collections/CollectionView"; @@ -21,10 +21,12 @@ import { GroupMemberView } from "./GroupMemberView"; import "./SharingManager.scss"; import { SelectionManager } from "./SelectionManager"; import { intersection } from "lodash"; +import { SearchBox } from "../views/search/SearchBox"; export interface User { email: string; - userDocumentId: string; + sharingDocumentId: string; + linkDatabaseId: string; } /** @@ -46,20 +48,20 @@ const groupType = "!groupType/"; const storage = "data"; /** - * A user who also has a notificationDoc. + * A user who also has a sharing doc. */ interface ValidatedUser { - user: User; - notificationDoc: Doc; - userColor: string; + user: User; // database minimal info to identify / communicate with a user (email, sharing doc id) + sharingDoc: Doc; // document to share/message another user + linkDatabase: Doc; + userColor: string; // stored on the sharinDoc, extracted for convenience? } - @observer export class SharingManager extends React.Component<{}> { public static Instance: SharingManager; @observable private isOpen = false; // whether the SharingManager modal is open or not - @observable public users: ValidatedUser[] = []; // the list of users with notificationDocs + @observable public users: ValidatedUser[] = []; // the list of users with sharing docs @observable private targetDoc: Doc | undefined; // the document being shared @observable private targetDocView: DocumentView | undefined; // the DocumentView of the document being shared // @observable private copied = false; @@ -70,18 +72,19 @@ export class SharingManager extends React.Component<{}> { @observable private individualSort: "ascending" | "descending" | "none" = "none"; // sorting options for the list of individuals @observable private groupSort: "ascending" | "descending" | "none" = "none"; // sorting options for the list of groups private shareDocumentButtonRef: React.RefObject<HTMLButtonElement> = React.createRef(); // ref for the share button, used for the position of the popup + private distributeAclsButtonRef: React.RefObject<HTMLButtonElement> = React.createRef(); // ref for the distribute button, used for the position of the popup // if both showUserOptions and showGroupOptions are false then both are displayed @observable private showUserOptions: boolean = false; // whether to show individuals as options when sharing (in the react-select component) @observable private showGroupOptions: boolean = false; // // whether to show groups as options when sharing (in the react-select component) private populating: boolean = false; // whether the list of users is populating or not @observable private layoutDocAcls: boolean = false; // whether the layout doc or data doc's acls are to be used + @observable private myDocAcls: boolean = false; // private get linkVisible() { // return this.sharingDoc ? this.sharingDoc[PublicKey] !== SharingPermissions.None : false; // } public open = (target?: DocumentView, target_doc?: Doc) => { - runInAction(() => this.users = []); this.populateUsers(); runInAction(() => { this.targetDocView = target; @@ -116,30 +119,35 @@ export class SharingManager extends React.Component<{}> { } /** - * Populates the list of validated users (this.users) by adding registered users which have a mySharedDocs. + * Populates the list of validated users (this.users) by adding registered users which have a sharingDocument. */ populateUsers = async () => { if (!this.populating) { this.populating = true; - runInAction(() => this.users = []); const userList = await RequestPromise.get(Utils.prepend("/getUsers")); const raw = JSON.parse(userList) as User[]; + const sharingDocs: ValidatedUser[] = []; const evaluating = raw.map(async user => { const isCandidate = user.email !== Doc.CurrentUserEmail; if (isCandidate) { - const userDocument = await DocServer.GetRefField(user.userDocumentId); - if (userDocument instanceof Doc) { - const notificationDoc = await Cast(userDocument.mySharedDocs, Doc); - const userColor = StrCast(userDocument.userColor); - runInAction(() => { - if (notificationDoc instanceof Doc) { - this.users.push({ user, notificationDoc, userColor }); - } - }); + const sharingDoc = await DocServer.GetRefField(user.sharingDocumentId); + const linkDatabase = await DocServer.GetRefField(user.linkDatabaseId); + if (sharingDoc instanceof Doc && linkDatabase instanceof Doc) { + await DocListCastAsync(linkDatabase.data); + sharingDocs.push({ user, sharingDoc, linkDatabase, userColor: StrCast(sharingDoc.color) }); } } }); - return Promise.all(evaluating).then(() => this.populating = false); + return Promise.all(evaluating).then(() => { + runInAction(() => { + for (const sharer of sharingDocs) { + if (!this.users.find(user => user.user.email === sharer.user.email)) { + this.users.push(sharer); + } + } + }); + this.populating = false; + }); } } @@ -148,17 +156,17 @@ export class SharingManager extends React.Component<{}> { * @param group * @param permission */ - setInternalGroupSharing = (group: Doc | { groupName: string }, permission: string, targetDoc?: Doc) => { + setInternalGroupSharing = (group: Doc | { title: string }, permission: string, targetDoc?: Doc) => { const target = targetDoc || this.targetDoc!; - const key = StrCast(group.groupName).replace(".", "_"); + const key = normalizeEmail(StrCast(group.title)); const acl = `acl-${key}`; const docs = SelectionManager.SelectedDocuments().length < 2 ? [target] : SelectionManager.SelectedDocuments().map(docView => docView.props.Document); docs.forEach(doc => { - doc.author === Doc.CurrentUserEmail && !doc[`acl-${Doc.CurrentUserEmail.replace(".", "_")}`] && distributeAcls(`acl-${Doc.CurrentUserEmail.replace(".", "_")}`, SharingPermissions.Admin, doc); - GetEffectiveAcl(doc) === AclAdmin && distributeAcls(acl, permission as SharingPermissions, doc); + doc.author === Doc.CurrentUserEmail && !doc[`acl-${Doc.CurrentUserEmailNormalized}`] && distributeAcls(`acl-${Doc.CurrentUserEmailNormalized}`, SharingPermissions.Admin, doc); + distributeAcls(acl, permission as SharingPermissions, doc); if (group instanceof Doc) { const members: string[] = JSON.parse(StrCast(group.members)); @@ -167,9 +175,9 @@ export class SharingManager extends React.Component<{}> { // if documents have been shared, add the doc to that list if it doesn't already exist, otherwise create a new list with the doc group.docsShared ? Doc.IndexOf(doc, DocListCast(group.docsShared)) === -1 && (group.docsShared as List<Doc>).push(doc) : group.docsShared = new List<Doc>([doc]); - users.forEach(({ user, notificationDoc }) => { - if (permission !== SharingPermissions.None) Doc.IndexOf(doc, DocListCast(notificationDoc[storage])) === -1 && Doc.AddDocToList(notificationDoc, storage, doc); // add the doc to the notificationDoc if it hasn't already been added - else GetEffectiveAcl(doc, undefined, user.email) === AclPrivate && Doc.IndexOf((doc.aliasOf as Doc || doc), DocListCast(notificationDoc[storage])) !== -1 && Doc.RemoveDocFromList(notificationDoc, storage, (doc.aliasOf as Doc || doc)); // remove the doc from the list if it already exists + users.forEach(({ user, sharingDoc }) => { + if (permission !== SharingPermissions.None) Doc.AddDocToList(sharingDoc, storage, doc); // add the doc to the sharingDoc if it hasn't already been added + else GetEffectiveAcl(doc, user.email) === AclPrivate && Doc.RemoveDocFromList(sharingDoc, storage, (doc.aliasOf as Doc || doc)); // remove the doc from the list if it already exists }); } }); @@ -180,16 +188,26 @@ export class SharingManager extends React.Component<{}> { * @param group * @param emailId */ - shareWithAddedMember = (group: Doc, emailId: string) => { - const user: ValidatedUser = this.users.find(({ user: { email } }) => email === emailId)!; - if (group.docsShared) DocListCast(group.docsShared).forEach(doc => Doc.IndexOf(doc, DocListCast(user.notificationDoc[storage])) === -1 && Doc.AddDocToList(user.notificationDoc, storage, doc)); + shareWithAddedMember = (group: Doc, emailId: string, retry: boolean = true) => { + const user = this.users.find(({ user: { email } }) => email === emailId)!; + const self = this; + if (group.docsShared) { + if (!user) retry && this.populateUsers().then(() => self.shareWithAddedMember(group, emailId, false)); + else { + DocListCastAsync(user.sharingDoc[storage]).then(userdocs => + DocListCastAsync(group.docsShared).then(dl => { + const filtered = dl?.filter(doc => !userdocs?.includes(doc)); + filtered && userdocs?.push(...filtered); + })); + } + } } /** * Called from the properties sidebar to change permissions of a user. */ shareFromPropertiesSidebar = (shareWith: string, permission: SharingPermissions, docs: Doc[]) => { - if (shareWith !== "Public") { + if (shareWith !== "Public" && shareWith !== "Override") { const user = this.users.find(({ user: { email } }) => email === (shareWith === "Me" ? Doc.CurrentUserEmail : shareWith)); docs.forEach(doc => { if (user) this.setInternalSharing(user, permission, doc); @@ -198,7 +216,7 @@ export class SharingManager extends React.Component<{}> { } else { docs.forEach(doc => { - if (GetEffectiveAcl(doc) === AclAdmin) distributeAcls("acl-Public", permission, doc); + if (GetEffectiveAcl(doc) === AclAdmin) distributeAcls(`acl-${shareWith}`, permission, doc); }); } } @@ -212,9 +230,12 @@ export class SharingManager extends React.Component<{}> { const user: ValidatedUser = this.users.find(({ user: { email } }) => email === emailId)!; if (group.docsShared) { - DocListCast(group.docsShared).forEach(doc => { - Doc.IndexOf(doc, DocListCast(user.notificationDoc[storage])) !== -1 && Doc.RemoveDocFromList(user.notificationDoc, storage, doc); // remove the doc only if it is in the list - }); + DocListCastAsync(user.sharingDoc[storage]).then(userdocs => + DocListCastAsync(group.docsShared).then(dl => { + const remaining = userdocs?.filter(doc => !dl?.includes(doc)) || []; + userdocs?.splice(0, userdocs.length, ...remaining); + }) + ); } } @@ -225,14 +246,14 @@ export class SharingManager extends React.Component<{}> { removeGroup = (group: Doc) => { if (group.docsShared) { DocListCast(group.docsShared).forEach(doc => { - const acl = `acl-${StrCast(group.groupName)}`; + const acl = `acl-${StrCast(group.title)}`; distributeAcls(acl, SharingPermissions.None, doc); const members: string[] = JSON.parse(StrCast(group.members)); const users: ValidatedUser[] = this.users.filter(({ user: { email } }) => members.includes(email)); - users.forEach(({ notificationDoc }) => Doc.RemoveDocFromList(notificationDoc, storage, doc)); + users.forEach(({ sharingDoc }) => Doc.RemoveDocFromList(sharingDoc, storage, doc)); }); } } @@ -241,20 +262,18 @@ export class SharingManager extends React.Component<{}> { * Shares the document with a user. */ setInternalSharing = (recipient: ValidatedUser, permission: string, targetDoc?: Doc) => { - const { user, notificationDoc } = recipient; + const { user, sharingDoc } = recipient; const target = targetDoc || this.targetDoc!; - const key = user.email.replace('.', '_'); - const acl = `acl-${key}`; - + const acl = `acl-${normalizeEmail(user.email)}`; + const myAcl = `acl-${Doc.CurrentUserEmailNormalized}`; const docs = SelectionManager.SelectedDocuments().length < 2 ? [target] : SelectionManager.SelectedDocuments().map(docView => docView.props.Document); - docs.forEach(doc => { - doc.author === Doc.CurrentUserEmail && !doc[`acl-${Doc.CurrentUserEmail.replace(".", "_")}`] && distributeAcls(`acl-${Doc.CurrentUserEmail.replace(".", "_")}`, SharingPermissions.Admin, doc); - GetEffectiveAcl(doc) === AclAdmin && distributeAcls(acl, permission as SharingPermissions, doc); + doc.author === Doc.CurrentUserEmail && !doc[myAcl] && distributeAcls(myAcl, SharingPermissions.Admin, doc); + distributeAcls(acl, permission as SharingPermissions, doc); - if (permission !== SharingPermissions.None) Doc.IndexOf(doc, DocListCast(notificationDoc[storage])) === -1 && Doc.AddDocToList(notificationDoc, storage, doc); - else GetEffectiveAcl(doc, undefined, user.email) === AclPrivate && Doc.IndexOf((doc.aliasOf as Doc || doc), DocListCast(notificationDoc[storage])) !== -1 && Doc.RemoveDocFromList(notificationDoc, storage, (doc.aliasOf as Doc || doc)); + if (permission !== SharingPermissions.None) Doc.AddDocToList(sharingDoc, storage, doc); + else GetEffectiveAcl(doc, user.email) === AclPrivate && Doc.RemoveDocFromList(sharingDoc, storage, (doc.aliasOf as Doc || doc)); }); } @@ -285,13 +304,14 @@ export class SharingManager extends React.Component<{}> { /** * Returns the SharingPermissions (Admin, Can Edit etc) access that's used to share */ - private sharingOptions(uniform: boolean) { + private sharingOptions(uniform: boolean, override?: boolean) { const dropdownValues: string[] = Object.values(SharingPermissions); if (!uniform) dropdownValues.unshift("-multiple-"); - return dropdownValues.map(permission => + if (override) dropdownValues.unshift("None"); + return dropdownValues.filter(permission => permission !== SharingPermissions.View).map(permission => ( <option key={permission} value={permission}> - {permission} + {permission === SharingPermissions.Add ? "Can Augment" : permission} </option> ) ); @@ -372,6 +392,25 @@ export class SharingManager extends React.Component<{}> { } } + distributeOverCollection = (targetDoc?: Doc) => { + const AclMap = new Map<symbol, string>([ + [AclPrivate, SharingPermissions.None], + [AclReadonly, SharingPermissions.View], + [AclAddonly, SharingPermissions.Add], + [AclEdit, SharingPermissions.Edit], + [AclAdmin, SharingPermissions.Admin] + ]); + + const target = targetDoc || this.targetDoc!; + + const docs = SelectionManager.SelectedDocuments().length < 2 ? [target] : SelectionManager.SelectedDocuments().map(docView => docView.props.Document); + docs.forEach(doc => { + for (const [key, value] of Object.entries(doc[AclSym])) { + distributeAcls(key, AclMap.get(value)! as SharingPermissions, target); + } + }); + } + /** * Sorting algorithm to sort users. */ @@ -385,8 +424,8 @@ export class SharingManager extends React.Component<{}> { * Sorting algorithm to sort groups. */ sortGroups = (group1: Doc, group2: Doc) => { - const g1 = StrCast(group1.groupName); - const g2 = StrCast(group2.groupName); + const g1 = StrCast(group1.title); + const g2 = StrCast(group2.title); return g1 < g2 ? -1 : g1 === g2 ? 0 : 1; } @@ -395,9 +434,9 @@ export class SharingManager extends React.Component<{}> { */ @computed get sharingInterface() { TraceMobx(); - const groupList = GroupManager.Instance?.getAllGroups() || []; + const groupList = GroupManager.Instance?.allGroups || []; const sortedUsers = this.users.slice().sort(this.sortUsers).map(({ user: { email } }) => ({ label: email, value: indType + email })); - const sortedGroups = groupList.slice().sort(this.sortGroups).map(({ groupName }) => ({ label: StrCast(groupName), value: groupType + StrCast(groupName) })); + const sortedGroups = groupList.slice().sort(this.sortGroups).map(({ title }) => ({ label: StrCast(title), value: groupType + StrCast(title) })); // the next block handles the users shown (individuals/groups/both) const options: GroupedOptions[] = []; @@ -415,21 +454,27 @@ export class SharingManager extends React.Component<{}> { const groups = this.groupSort === "ascending" ? groupList.slice().sort(this.sortGroups) : this.groupSort === "descending" ? groupList.slice().sort(this.sortGroups).reverse() : groupList; // handles the case where multiple documents are selected - const docs = SelectionManager.SelectedDocuments().length < 2 ? + let docs = SelectionManager.SelectedDocuments().length < 2 ? [this.layoutDocAcls ? this.targetDoc : this.targetDoc?.[DataSym]] : SelectionManager.SelectedDocuments().map(docView => this.layoutDocAcls ? docView.props.Document : docView.props.Document?.[DataSym]); + if (this.myDocAcls) { + const newDocs: Doc[] = []; + SearchBox.foreachRecursiveDoc(docs, doc => newDocs.push(doc)); + docs = newDocs.filter(doc => GetEffectiveAcl(doc) === AclAdmin); + } + const targetDoc = docs[0]; // tslint:disable-next-line: no-unnecessary-callback-wrapper - const admin = docs.map(doc => GetEffectiveAcl(doc)).every(acl => acl === AclAdmin); // if the user has admin access to all selected docs + const admin = this.myDocAcls ? Boolean(docs.length) : docs.map(doc => GetEffectiveAcl(doc)).every(acl => acl === AclAdmin); // if the user has admin access to all selected docs // users in common between all docs const commonKeys = intersection(...docs.map(doc => this.layoutDocAcls ? doc?.[AclSym] && Object.keys(doc[AclSym]) : doc?.[DataSym]?.[AclSym] && Object.keys(doc[DataSym][AclSym]))); // the list of users shared with - const userListContents: (JSX.Element | null)[] = users.filter(({ user }) => docs.length > 1 ? commonKeys.includes(`acl-${user.email.replace('.', '_')}`) : true).map(({ user, notificationDoc, userColor }) => { - const userKey = `acl-${user.email.replace('.', '_')}`; + const userListContents: (JSX.Element | null)[] = users.filter(({ user }) => docs.length > 1 ? commonKeys.includes(`acl-${normalizeEmail(user.email)}`) : docs[0]?.author !== user.email).map(({ user, linkDatabase, sharingDoc, userColor }) => { + const userKey = `acl-${normalizeEmail(user.email)}`; const uniform = docs.every(doc => this.layoutDocAcls ? doc?.[AclSym]?.[userKey] === docs[0]?.[AclSym]?.[userKey] : doc?.[DataSym]?.[AclSym]?.[userKey] === docs[0]?.[DataSym]?.[AclSym]?.[userKey]); const permissions = uniform ? StrCast(targetDoc?.[userKey]) : "-multiple-"; @@ -440,17 +485,17 @@ export class SharingManager extends React.Component<{}> { > <span className={"padding"}>{user.email}</span> <div className="edit-actions"> - {admin ? ( + {admin || this.myDocAcls ? ( <select className={"permissions-dropdown"} value={permissions} - onChange={e => this.setInternalSharing({ user, notificationDoc, userColor }, e.currentTarget.value)} + onChange={e => this.setInternalSharing({ user, linkDatabase, sharingDoc, userColor }, e.currentTarget.value)} > {this.sharingOptions(uniform)} </select> ) : ( <div className={"permissions-dropdown"}> - {permissions} + {permissions === SharingPermissions.Add ? "Can Augment" : permissions} </div> )} </div> @@ -486,7 +531,7 @@ export class SharingManager extends React.Component<{}> { <span className={"padding"}>Me</span> <div className="edit-actions"> <div className={"permissions-dropdown"}> - {targetDoc?.[`acl-${Doc.CurrentUserEmail.replace(".", "_")}`]} + {targetDoc?.[`acl-${Doc.CurrentUserEmailNormalized}`]} </div> </div> </div> @@ -495,32 +540,32 @@ export class SharingManager extends React.Component<{}> { // the list of groups shared with - const groupListMap: (Doc | { groupName: string })[] = groups.filter(({ groupName }) => docs.length > 1 ? commonKeys.includes(`acl-${StrCast(groupName).replace('.', '_')}`) : true); - groupListMap.unshift({ groupName: "Public" }); + const groupListMap: (Doc | { title: string })[] = groups.filter(({ title }) => docs.length > 1 ? commonKeys.includes(`acl-${normalizeEmail(StrCast(title))}`) : true); + groupListMap.unshift({ title: "Public" }, { title: "Override" }); const groupListContents = groupListMap.map(group => { - const groupKey = `acl-${StrCast(group.groupName)}`; + const groupKey = `acl-${StrCast(group.title)}`; const uniform = docs.every(doc => this.layoutDocAcls ? doc?.[AclSym]?.[groupKey] === docs[0]?.[AclSym]?.[groupKey] : doc?.[DataSym]?.[AclSym]?.[groupKey] === docs[0]?.[DataSym]?.[AclSym]?.[groupKey]); - const permissions = uniform ? StrCast(targetDoc?.[`acl-${StrCast(group.groupName)}`]) : "-multiple-"; + const permissions = uniform ? StrCast(targetDoc?.[`acl-${StrCast(group.title)}`]) : "-multiple-"; return !permissions ? (null) : ( <div key={groupKey} className={"container"} > - <div className={"padding"}>{group.groupName}</div> + <div className={"padding"}>{group.title}</div> {group instanceof Doc ? (<div className="group-info" onClick={action(() => GroupManager.Instance.currentGroup = group)}> <FontAwesomeIcon icon={"info-circle"} color={"#e8e8e8"} size={"sm"} style={{ backgroundColor: "#1e89d7", borderRadius: "100%", border: "1px solid #1e89d7" }} /> </div>) : (null)} <div className="edit-actions"> - {admin ? ( + {admin || this.myDocAcls ? ( <select className={"permissions-dropdown"} value={permissions} onChange={e => this.setInternalGroupSharing(group, e.currentTarget.value)} > - {this.sharingOptions(uniform)} + {this.sharingOptions(uniform, group.title === "Override")} </select> ) : ( <div className={"permissions-dropdown"}> @@ -572,8 +617,18 @@ export class SharingManager extends React.Component<{}> { <input type="checkbox" onChange={action(() => this.showUserOptions = !this.showUserOptions)} /> <label style={{ marginRight: 10 }}>Individuals</label> <input type="checkbox" onChange={action(() => this.showGroupOptions = !this.showGroupOptions)} /> <label>Groups</label> </div> - <div className="layoutDoc-acls"> - <input type="checkbox" onChange={action(() => this.layoutDocAcls = !this.layoutDocAcls)} checked={this.layoutDocAcls} /> <label>Layout</label> + + <div className="acl-container"> + <div className="myDocs-acls"> + <input type="checkbox" onChange={action(() => this.myDocAcls = !this.myDocAcls)} checked={this.myDocAcls} /> <label>My Docs</label> + </div> + {Doc.UserDoc().noviceMode ? (null) : + <div className="layoutDoc-acls"> + <input type="checkbox" onChange={action(() => this.layoutDocAcls = !this.layoutDocAcls)} checked={this.layoutDocAcls} /> <label>Layout</label> + </div>} + <button className="distribute-button" onClick={() => this.distributeOverCollection()}> + Distribute + </button> </div> </div> } diff --git a/src/client/util/SnappingManager.ts b/src/client/util/SnappingManager.ts index fc07e8ab4..069f81d38 100644 --- a/src/client/util/SnappingManager.ts +++ b/src/client/util/SnappingManager.ts @@ -1,4 +1,5 @@ import { observable, action, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; export namespace SnappingManager { @@ -14,6 +15,9 @@ export namespace SnappingManager { this.horizSnapLines = horizLines; this.vertSnapLines = vertLines; } + + @observable cachedGroups: string[] = []; + @action setCachedGroups(groups: string[]) { this.cachedGroups = groups; } } const manager = new Manager(); @@ -25,5 +29,11 @@ export namespace SnappingManager { export function SetIsDragging(dragging: boolean) { runInAction(() => manager.IsDragging = dragging); } export function GetIsDragging() { return manager.IsDragging; } + + /// bcz; argh!! TODO; These do not belong here, but there were include order problems with leaving them in util.ts + // need to investigate further what caused the mobx update problems and move to a better location. + const getCachedGroupByNameCache = computedFn(function (name: string) { return manager.cachedGroups.includes(name); }, true); + export function GetCachedGroupByName(name: string) { return getCachedGroupByNameCache(name); } + export function SetCachedGroups(groups: string[]) { manager.setCachedGroups(groups); } } diff --git a/src/client/util/UndoManager.ts b/src/client/util/UndoManager.ts index 0f7ad6d0a..569ad8ab4 100644 --- a/src/client/util/UndoManager.ts +++ b/src/client/util/UndoManager.ts @@ -76,7 +76,7 @@ export namespace UndoManager { export let undoStack: UndoBatch[] = observable([]); export let redoStack: UndoBatch[] = observable([]); let currentBatch: UndoBatch | undefined; - let batchCounter = 0; + export let batchCounter = 0; let undoing = false; let tempEvents: UndoEvent[] | undefined = undefined; |