diff options
author | geireann <geireann.lindfield@gmail.com> | 2023-04-20 11:26:50 -0400 |
---|---|---|
committer | geireann <geireann.lindfield@gmail.com> | 2023-04-20 11:26:50 -0400 |
commit | b3c14800dc8d33e2dc99da8c9ed4ce0466fa468c (patch) | |
tree | 2980e2d6663add0a729eb02c23e16df8f2014fdc /src | |
parent | 6c3482a17839928818abaf82bc5be3c794174f4d (diff) | |
parent | 0a66b0f369a13d5f399bf125727aff73cd6fd1b4 (diff) |
Merge branch 'master' of https://github.com/brown-dash/Dash-Web
Diffstat (limited to 'src')
90 files changed, 1734 insertions, 961 deletions
diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts new file mode 100644 index 000000000..4b3960902 --- /dev/null +++ b/src/client/apis/gpt/GPT.ts @@ -0,0 +1,87 @@ +import { Configuration, OpenAIApi } from 'openai'; + +enum GPTCallType { + SUMMARY = 'summary', + COMPLETION = 'completion', + EDIT = 'edit', +} + +type GPTCallOpts = { + model: string; + maxTokens: number; + temp: number; + prompt: string; +}; + +const callTypeMap: { [type: string]: GPTCallOpts } = { + summary: { model: 'text-davinci-003', maxTokens: 256, temp: 0.5, prompt: 'Summarize this text briefly: ' }, + edit: { model: 'text-davinci-003', maxTokens: 256, temp: 0.5, prompt: 'Reword this: ' }, + completion: { model: 'text-davinci-003', maxTokens: 256, temp: 0.5, prompt: '' }, +}; + +/** + * Calls the OpenAI API. + * + * @param inputText Text to process + * @returns AI Output + */ +const gptAPICall = async (inputText: string, callType: GPTCallType) => { + if (callType === GPTCallType.SUMMARY) inputText += '.'; + const opts: GPTCallOpts = callTypeMap[callType]; + try { + const configuration = new Configuration({ + apiKey: process.env.OPENAI_KEY, + }); + const openai = new OpenAIApi(configuration); + const response = await openai.createCompletion({ + model: opts.model, + max_tokens: opts.maxTokens, + temperature: opts.temp, + prompt: `${opts.prompt}${inputText}`, + }); + console.log(response.data.choices[0]); + return response.data.choices[0].text; + } catch (err) { + console.log(err); + return 'Error connecting with API.'; + } +}; + +const gptImageCall = async (prompt: string) => { + try { + const configuration = new Configuration({ + apiKey: process.env.OPENAI_KEY, + }); + const openai = new OpenAIApi(configuration); + const response = await openai.createImage({ + prompt: prompt, + n: 1, + size: '1024x1024', + }); + return response.data.data[0].url; + } catch (err) { + console.error(err); + return; + } +}; + +// const gptEditCall = async (selectedText: string, fullText: string) => { +// try { +// const configuration = new Configuration({ +// apiKey: process.env.OPENAI_KEY, +// }); +// const openai = new OpenAIApi(configuration); +// const response = await openai.createCompletion({ +// model: 'text-davinci-003', +// max_tokens: 256, +// temperature: 0.1, +// prompt: `Replace the phrase ${selectedText} inside of ${fullText}.`, +// }); +// return response.data.choices[0].text.trim(); +// } catch (err) { +// console.log(err); +// return 'Error connecting with API.'; +// } +// }; + +export { gptAPICall, gptImageCall, GPTCallType }; diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 979b294e0..a0149eadf 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -21,6 +21,7 @@ import { Networking } from '../Network'; import { DocumentManager } from '../util/DocumentManager'; import { DragManager, dropActionType } from '../util/DragManager'; import { DirectoryImportBox } from '../util/Import & Export/DirectoryImportBox'; +import { FollowLinkScript } from '../util/LinkFollower'; import { LinkManager } from '../util/LinkManager'; import { ScriptingGlobals } from '../util/ScriptingGlobals'; import { undoBatch, UndoManager } from '../util/UndoManager'; @@ -146,6 +147,8 @@ export class DocumentOptions { _panY?: NUMt = new NumInfo('vertical pan location of a freeform view'); _width?: NUMt = new NumInfo('displayed width of a document'); _height?: NUMt = new NumInfo('displayed height of document'); + 'data-nativeWidth'?: NUMt = new NumInfo('native width of data field contents (e.g., the pixel width of an image)'); + 'data-nativeHeight'?: NUMt = new NumInfo('native height of data field contents (e.g., the pixel height of an image)'); _nativeWidth?: NUMt = new NumInfo('native width of document contents (e.g., the pixel width of an image)'); _nativeHeight?: NUMt = new NumInfo('native height of document contents (e.g., the pixel height of an image)'); _nativeDimModifiable?: BOOLt = new BoolInfo('native dimensions can be modified using document decoration reizers'); @@ -263,7 +266,6 @@ export class DocumentOptions { caption?: RichTextField; opacity?: number; defaultBackgroundColor?: string; - _isLinkButton?: boolean; // marks a document as a button that will follow its primary link when clicked _linkAutoMove?: boolean; // whether link endpoint should move around the edges of a document to make shortest path to other link endpoint hideLinkAnchors?: boolean; // suppresses link anchor dots from being displayed isFolder?: boolean; @@ -508,13 +510,13 @@ export namespace Docs { layout: { view: LinkBox, dataField: defaultDataKey }, options: { childDontRegisterViews: true, - _isLinkButton: true, + onClick: FollowLinkScript(), hideLinkAnchors: true, _height: 150, description: '', showCaption: 'description', backgroundColor: 'lightblue', // lightblue is default color for linking dot and link documents text comment area - _removeDropProperties: new List(['isLinkButton']), + _removeDropProperties: new List(['onClick']), }, }, ], @@ -1285,10 +1287,6 @@ export namespace DocUtils { return rangeFilteredDocs; } - export function DefaultFocus(doc: Doc, options: DocFocusOptions) { - return undefined; - } - export let ActiveRecordings: { props: FieldViewProps; getAnchor: (addAsAnnotation: boolean) => Doc }[] = []; export function MakeLinkToActiveAudio(getSourceDoc: () => Doc | undefined, broadcastEvent = true) { @@ -1299,10 +1297,9 @@ export namespace DocUtils { }); } - export function MakeLink(source: Doc, target: Doc, linkSettings: { linkRelationship?: string; description?: string; linkDisplay?: boolean }, id?: string, allowParCollectionLink?: boolean, showPopup?: number[]) { + export function MakeLink(source: Doc, target: Doc, linkSettings: { linkRelationship?: string; description?: string; linkDisplay?: boolean }, id?: string, showPopup?: number[]) { if (!linkSettings.linkRelationship) linkSettings.linkRelationship = target.type === DocumentType.RTF ? 'Commentary:Comments On' : 'link'; const sv = DocumentManager.Instance.getDocumentView(source); - if (!allowParCollectionLink && sv?.props.ContainingCollectionDoc === target) return; if (target.doc === Doc.UserDoc()) return undefined; const makeLink = action((linkDoc: Doc, showPopup?: number[]) => { @@ -1371,7 +1368,6 @@ export namespace DocUtils { value: 'any', _readOnly_: 'boolean', scriptContext: 'any', - thisContainer: Doc.name, documentView: Doc.name, heading: Doc.name, checked: 'boolean', @@ -1695,7 +1691,7 @@ export namespace DocUtils { _width: 15, _height: 15, _xPadding: 0, - _isLinkButton: true, + onClick: FollowLinkScript(), _timecodeToShow: Cast(doc._timecodeToShow, 'number', null), }); Doc.AddDocToList(context, annotationField, pushpin); diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts index 623aaf357..abf7313a4 100644 --- a/src/client/util/CurrentUserUtils.ts +++ b/src/client/util/CurrentUserUtils.ts @@ -25,6 +25,7 @@ import { OpenWhere } from "../views/nodes/DocumentView"; import { OverlayView } from "../views/OverlayView"; import { DragManager } from "./DragManager"; import { MakeTemplate } from "./DropConverter"; +import { FollowLinkScript } from "./LinkFollower"; import { LinkManager } from "./LinkManager"; import { ScriptingGlobals } from "./ScriptingGlobals"; import { ColorScheme } from "./SettingsManager"; @@ -109,7 +110,7 @@ export class CurrentUserUtils { const tempClicks = DocCast(doc[field]); const reqdClickOpts:DocumentOptions = {_width: 300, _height:200, system: true}; const reqdTempOpts:{opts:DocumentOptions, script: string}[] = [ - { opts: { title: "Open In Target", targetScriptKey: "onChildClick"}, script: "docCast(thisContainer.target).then((target) => target && (target.proto.data = new List([self])))"}, + { opts: { title: "Open In Target", targetScriptKey: "onChildClick"}, script: "docCast(documentView?.props.docViewPath().lastElement()?.rootDoc.target).then((target) => target && (target.proto.data = new List([self])))"}, { opts: { title: "Open Detail On Right", targetScriptKey: "onChildDoubleClick"}, script: `openDoc(self.doubleClickView.${OpenWhere.addRight})`}]; const reqdClickList = reqdTempOpts.map(opts => { const allOpts = {...reqdClickOpts, ...opts.opts}; @@ -271,7 +272,7 @@ export class CurrentUserUtils { {key: "Map", creator: opts => Docs.Create.MapDocument([], opts), opts: { _width: 800, _height: 600, _fitWidth: true, _showSidebar: true, }}, {key: "Screengrab", creator: Docs.Create.ScreenshotDocument, opts: { _width: 400, _height: 200 }}, {key: "WebCam", creator: opts => Docs.Create.WebCamDocument("", opts), opts: { _width: 400, _height: 200, recording:true, system: true, cloneFieldFilter: new List<string>(["system"]) }}, - {key: "Button", creator: Docs.Create.ButtonDocument, opts: { _width: 150, _height: 50, _xPadding: 10, _yPadding: 10, _isLinkButton: true }}, + {key: "Button", creator: Docs.Create.ButtonDocument, opts: { _width: 150, _height: 50, _xPadding: 10, _yPadding: 10, onClick: FollowLinkScript()}}, {key: "Script", creator: opts => Docs.Create.ScriptingDocument(null, opts), opts: { _width: 200, _height: 250, }}, {key: "DataViz", creator: opts => Docs.Create.DataVizDocument("/users/rz/Downloads/addresses.csv", opts), opts: { _width: 300, _height: 300 }}, {key: "Header", creator: headerTemplate, opts: { _width: 300, _height: 70, _headerPointerEvents: "all", _headerHeight: 12, _headerFontSize: 9, _autoHeight: true, treeViewHideUnrendered: true}}, @@ -622,7 +623,7 @@ export class CurrentUserUtils { { title: "Snap\xA0Lines",icon: "th", toolTip: "Show Snap Lines", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"snap lines", funcs: {}, scripts: { onClick: 'showFreeform(self.toolType, _readOnly_)'}}, // Only when floating document is selected in freeform { title: "View\xA0All", icon: "object-group",toolTip: "Fit all Docs to View",btnType: ButtonType.ToggleButton, expertMode: false, toolType:"viewAll", funcs: {}, scripts: { onClick: 'showFreeform(self.toolType, _readOnly_)'}}, // Only when floating document is selected in freeform { title: "Clusters", icon: "braille", toolTip: "Show Doc Clusters", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"clusters", funcs: {}, scripts: { onClick: 'showFreeform(self.toolType, _readOnly_)'}}, // Only when floating document is selected in freeform - { title: "Arrange", icon: "window", toolTip: "Toggle Auto Arrange", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"arrange", funcs: {}, scripts: { onClick: 'showFreeform(self.toolType, _readOnly_)'}}, // Only when floating document is selected in freeform + { title: "Arrange", icon: "window", toolTip: "Toggle Auto Arrange", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"arrange", funcs: {hidden: 'IsNoviceMode()'}, scripts: { onClick: 'showFreeform(self.toolType, _readOnly_)'}}, // Only when floating document is selected in freeform { title: "Reset", icon: "check", toolTip: "Reset View", btnType: ButtonType.ClickButton, expertMode: false, backgroundColor:"transparent", scripts: { onClick: 'resetView()'}}, // Only when floating document is selected in freeform ] } diff --git a/src/client/util/DocumentManager.ts b/src/client/util/DocumentManager.ts index ccf370662..e01457b4f 100644 --- a/src/client/util/DocumentManager.ts +++ b/src/client/util/DocumentManager.ts @@ -1,15 +1,11 @@ -import { loadAsync } from 'jszip'; -import { action, observable, ObservableSet, runInAction } from 'mobx'; +import { action, observable, ObservableSet } from 'mobx'; import { AnimationSym, Doc, Opt } from '../../fields/Doc'; import { Id } from '../../fields/FieldSymbols'; import { listSpec } from '../../fields/Schema'; import { Cast, DocCast, StrCast } from '../../fields/Types'; import { AudioField } from '../../fields/URLField'; -import { emptyFunction } from '../../Utils'; import { CollectionViewType } from '../documents/DocumentTypes'; import { CollectionDockingView } from '../views/collections/CollectionDockingView'; -import { CollectionFreeFormView } from '../views/collections/collectionFreeForm'; -import { CollectionView } from '../views/collections/CollectionView'; import { TabDocView } from '../views/collections/TabDocView'; import { LightboxView } from '../views/LightboxView'; import { MainView } from '../views/MainView'; @@ -39,7 +35,7 @@ export class DocumentManager { private _viewRenderedCbs: { doc: Doc; func: (dv: DocumentView) => any }[] = []; public AddViewRenderedCb = (doc: Opt<Doc>, func: (dv: DocumentView) => any) => { if (doc) { - const dv = this.getDocumentViewById(doc[Id]); + const dv = this.getDocumentView(doc); this._viewRenderedCbs.push({ doc, func }); if (dv) { this.callAddViewFuncs(dv); @@ -129,42 +125,24 @@ export class DocumentManager { return this.getDocumentViewsById(doc[Id]); } - public getDocumentViewById(id: string, preferredCollection?: CollectionView): DocumentView | undefined { - if (!id) return undefined; - let toReturn: DocumentView | undefined; - const passes = preferredCollection ? [preferredCollection, undefined] : [undefined]; - - for (const pass of passes) { - Array.from(DocumentManager.Instance.DocumentViews).map(view => { - if (view.rootDoc[Id] === id && (!pass || view.props.ContainingCollectionView === preferredCollection)) { - toReturn = view; - return; - } - }); - if (!toReturn) { - Array.from(DocumentManager.Instance.DocumentViews).map(view => { - const doc = view.rootDoc.proto; - if (doc && doc[Id] === id && (!pass || view.props.ContainingCollectionView === preferredCollection)) { - toReturn = view; - } - }); - } else { - break; - } - } - - return toReturn; - } - - public getDocumentView(toFind: Doc, preferredCollection?: CollectionView): DocumentView | undefined { - const found = + public getDocumentView(toFind: Doc | undefined, preferredCollection?: DocumentView): DocumentView | undefined { + const doc = + // bcz: this was temporary code used to match documents by data url instead of by id. intended only for repairing the DB // Array.from(DocumentManager.Instance.DocumentViews).find( // dv => // ((dv.rootDoc.data as any)?.url?.href && (dv.rootDoc.data as any)?.url?.href === (toFind.data as any)?.url?.href) || // ((DocCast(dv.rootDoc.annotationOn)?.data as any)?.url?.href && (DocCast(dv.rootDoc.annotationOn)?.data as any)?.url?.href === (DocCast(toFind.annotationOn)?.data as any)?.url?.href) // )?.rootDoc ?? toFind; - return this.getDocumentViewById(found[Id], preferredCollection); + const docViewArray = Array.from(DocumentManager.Instance.DocumentViews); + const passes = !doc ? [] : preferredCollection ? [preferredCollection, undefined] : [undefined]; + return passes.reduce( + (pass, toReturn) => + toReturn ?? + docViewArray.filter(view => view.rootDoc === doc).find(view => !pass || view.props.docViewPath().lastElement() === preferredCollection) ?? + docViewArray.filter(view => Doc.GetProto(view.rootDoc) === doc).find(view => !pass || view.props.docViewPath().lastElement() === preferredCollection), + undefined as Opt<DocumentView> + ); } public getLightboxDocumentView = (toFind: Doc, originatingDoc: Opt<Doc> = undefined): DocumentView | undefined => { @@ -247,7 +225,9 @@ export class DocumentManager { public showDocumentView = async (targetDocView: DocumentView, options: DocFocusOptions) => { const docViewPath = targetDocView.docViewPath.slice(); let rootContextView = docViewPath.shift(); - return rootContextView && this.focusViewsInPath(rootContextView, options, async () => ({ childDocView: docViewPath.shift(), viewSpec: undefined })); + await (rootContextView && this.focusViewsInPath(rootContextView, options, async () => ({ childDocView: docViewPath.shift(), viewSpec: undefined }))); + if (options.toggleTarget && (!options.didMove || targetDocView.rootDoc.hidden)) targetDocView.rootDoc.hidden = !targetDocView.rootDoc.hidden; + else if (options.openLocation?.startsWith(OpenWhere.toggle) && !options.didMove && rootContextView) MainView.addDocTabFunc(rootContextView.rootDoc, options.openLocation); }; // shows a document by first: @@ -267,10 +247,9 @@ export class DocumentManager { const viewIndex = docContextPath.findIndex(doc => this.getDocumentView(doc)); if (viewIndex !== -1) return res(this.getDocumentView(docContextPath[viewIndex])!); options.didMove = true; - docContextPath.some(doc => TabDocView.Activate(doc)) || MainView.addDocTabFunc(docContextPath[0], options.openLocation as OpenWhere); + docContextPath.some(doc => TabDocView.Activate(doc)) || MainView.addDocTabFunc(docContextPath[0], options.openLocation ?? OpenWhere.addRight); this.AddViewRenderedCb(docContextPath[0], dv => res(dv)); }); - docContextPath.shift(); const childViewIterator = async (docView: DocumentView) => { const innerDoc = docContextPath.shift(); @@ -318,15 +297,27 @@ export class DocumentManager { } } } -export function DocFocusOrOpen(doc: Doc, collectionDoc?: Doc) { - const cv = collectionDoc && DocumentManager.Instance.getDocumentView(collectionDoc); - const dv = DocumentManager.Instance.getDocumentView(doc, (cv?.ComponentView as CollectionFreeFormView)?.props.CollectionView); - if (dv) { - DocumentManager.Instance.showDocumentView(dv, { willZoomCentered: true }); - } else { - const context = doc.context !== Doc.MyFilesystem && Cast(doc.context, Doc, null); - const showDoc = context || doc; - DocumentManager.Instance.showDocument(Doc.BestAlias(showDoc), { openLocation: OpenWhere.addRight }, () => DocumentManager.Instance.showDocument(doc, { willZoomCentered: true })); - } +export function DocFocusOrOpen(doc: Doc, options: DocFocusOptions = { willZoomCentered: true, zoomScale: 0, openLocation: OpenWhere.toggleRight }, containingDoc?: Doc) { + const func = () => { + const cv = DocumentManager.Instance.getDocumentView(containingDoc); + const dv = DocumentManager.Instance.getDocumentView(doc, cv); + if (dv && (!containingDoc || dv.props.docViewPath().lastElement()?.Document === containingDoc)) { + DocumentManager.Instance.showDocumentView(dv, options).then(() => dv && Doc.linkFollowHighlight(dv.rootDoc)); + } else { + const container = DocCast(containingDoc ?? doc.context); + const showDoc = !Doc.IsSystem(container) ? container : doc; + options.toggleTarget = undefined; + DocumentManager.Instance.showDocument(showDoc, options, () => DocumentManager.Instance.showDocument(doc, { ...options, openLocation: undefined })).then(() => { + const cv = DocumentManager.Instance.getDocumentView(containingDoc); + const dv = DocumentManager.Instance.getDocumentView(doc, cv); + dv && Doc.linkFollowHighlight(dv.rootDoc); + }); + } + }; + if (doc.hidden) { + doc.hidden = false; + options.toggleTarget = false; + setTimeout(func); + } else func(); } ScriptingGlobals.add(DocFocusOrOpen); diff --git a/src/client/util/DragManager.ts b/src/client/util/DragManager.ts index 7d2aa813f..404c85eb2 100644 --- a/src/client/util/DragManager.ts +++ b/src/client/util/DragManager.ts @@ -3,10 +3,8 @@ import { DateField } from '../../fields/DateField'; import { Doc, Field, Opt, StrListCast } from '../../fields/Doc'; import { List } from '../../fields/List'; import { PrefetchProxy } from '../../fields/Proxy'; -import { listSpec } from '../../fields/Schema'; -import { SchemaHeaderField } from '../../fields/SchemaHeaderField'; import { ScriptField } from '../../fields/ScriptField'; -import { BoolCast, Cast, NumCast, ScriptCast, StrCast } from '../../fields/Types'; +import { BoolCast, ScriptCast, StrCast } from '../../fields/Types'; import { emptyFunction, Utils } from '../../Utils'; import { Docs, DocUtils } from '../documents/Documents'; import * as globalCssVariables from '../views/global/globalCssVariables.scss'; @@ -357,7 +355,7 @@ export namespace DragManager { top: Number.MAX_SAFE_INTEGER, bottom: Number.MIN_SAFE_INTEGER, }; - let rot = 0; + let rot: number[] = []; const docsToDrag = dragData instanceof DocumentDragData ? dragData.draggedDocuments : dragData instanceof AnchorAnnoDragData ? [dragData.dragDocument] : []; const dragElements = eles.map(ele => { // bcz: very hacky -- if dragged element is a freeForm view with a rotation, then extract the rotation in order to apply it to the dragged element @@ -365,9 +363,10 @@ export namespace DragManager { // if the parent isn't a freeform view, then the element's width and height are presumed to match the acutal doc's dimensions (eg, dragging from import sidebar menu) if (ele?.parentElement?.parentElement?.parentElement?.className === 'collectionFreeFormDocumentView-container') { ele = ele.parentElement.parentElement.parentElement; - rot = Number(ele.style.transform.replace(/.*rotate\(([-0-9.e]*)deg\).*/, '$1') || 0); + rot.push(Number(ele.style.transform.replace(/.*rotate\(([-0-9.e]*)deg\).*/, '$1') || 0)); } else { useDim = true; + rot.push(0); } if (!ele.parentNode) dragDiv.appendChild(ele); const dragElement = ele.parentNode === dragDiv ? ele : (ele.cloneNode(true) as HTMLElement); @@ -390,7 +389,7 @@ export namespace DragManager { const rect = ele.getBoundingClientRect(); const w = ele.offsetWidth || rect.width; const h = ele.offsetHeight || rect.height; - const rotR = -((rot < 0 ? rot + 360 : rot) / 180) * Math.PI; + const rotR = -((rot.lastElement() < 0 ? rot.lastElement() + 360 : rot.lastElement()) / 180) * Math.PI; const tl = [0, 0]; const tr = [Math.cos(rotR) * w, Math.sin(-rotR) * w]; const bl = [Math.sin(rotR) * h, Math.cos(-rotR) * h]; @@ -424,7 +423,7 @@ export namespace DragManager { transformOrigin: '0 0', width, height, - transform: `translate(${xs[0]}px, ${ys[0]}px) rotate(${rot}deg) scale(${scaling})`, + transform: `translate(${xs[0]}px, ${ys[0]}px) rotate(${rot.lastElement()}deg) scale(${scaling})`, }); dragLabel.style.transform = `translate(${xs[0]}px, ${ys[0] - 20}px)`; @@ -565,7 +564,7 @@ export namespace DragManager { const moveVec = { x: x - lastPt.x, y: y - lastPt.y }; lastPt = { x, y }; - dragElements.map((dragElement, i) => (dragElement.style.transform = `translate(${(xs[i] += moveVec.x)}px, ${(ys[i] += moveVec.y)}px) rotate(${rot}deg) scale(${scalings[i]})`)); + dragElements.map((dragElement, i) => (dragElement.style.transform = `translate(${(xs[i] += moveVec.x)}px, ${(ys[i] += moveVec.y)}px) rotate(${rot[i]}deg) scale(${scalings[i]})`)); dragLabel.style.transform = `translate(${xs[0]}px, ${ys[0] - 20}px)`; }; const upHandler = (e: PointerEvent) => { diff --git a/src/client/util/DropConverter.ts b/src/client/util/DropConverter.ts index 7c209d1e0..2e1d6ba23 100644 --- a/src/client/util/DropConverter.ts +++ b/src/client/util/DropConverter.ts @@ -33,15 +33,7 @@ function makeTemplate(doc: Doc, first: boolean = true, rename: Opt<string> = und let any = false; docs.forEach(d => { if (!StrCast(d.title).startsWith('-')) { - const params = StrCast(d.title) - .match(/\(([a-zA-Z0-9._\-]*)\)/)?.[1] - .replace('()', ''); - if (params) { - any = makeTemplate(d, false) || any; - d.PARAMS = params; - } else { - any = Doc.MakeMetadataFieldTemplate(d, Doc.GetProto(layoutDoc)) || any; - } + any = Doc.MakeMetadataFieldTemplate(d, Doc.GetProto(layoutDoc)) || any; } else if (d.type === DocumentType.COL || d.data instanceof RichTextField) { any = makeTemplate(d, false) || any; } diff --git a/src/client/util/Import & Export/DirectoryImportBox.tsx b/src/client/util/Import & Export/DirectoryImportBox.tsx index 559958c2b..b9bb22564 100644 --- a/src/client/util/Import & Export/DirectoryImportBox.tsx +++ b/src/client/util/Import & Export/DirectoryImportBox.tsx @@ -14,12 +14,14 @@ import { AcceptableMedia, Upload } from '../../../server/SharedMediaTypes'; import { Utils } from '../../../Utils'; import { GooglePhotos } from '../../apis/google_docs/GooglePhotosClientUtils'; import { Docs, DocumentOptions, DocUtils } from '../../documents/Documents'; +import { DocumentType } from '../../documents/DocumentTypes'; import { Networking } from '../../Network'; import { FieldView, FieldViewProps } from '../../views/nodes/FieldView'; import { DocumentManager } from '../DocumentManager'; import './DirectoryImportBox.scss'; import ImportMetadataEntry, { keyPlaceholder, valuePlaceholder } from './ImportMetadataEntry'; import React = require('react'); +import _ = require('lodash'); const unsupported = ['text/html', 'text/plain']; @@ -155,8 +157,8 @@ export class DirectoryImportBox extends React.Component<FieldViewProps> { x: NumCast(doc.x), y: NumCast(doc.y) + offset, }; - const parent = this.props.ContainingCollectionView; - if (parent) { + const parent = this.props.DocumentView?.().props.docViewPath().lastElement(); + if (parent?.rootDoc.type === DocumentType.COL) { let importContainer: Doc; if (docs.length < 50) { importContainer = Docs.Create.MasonryDocument(docs, options); diff --git a/src/client/util/LinkFollower.ts b/src/client/util/LinkFollower.ts index df61ecece..ba2edb65c 100644 --- a/src/client/util/LinkFollower.ts +++ b/src/client/util/LinkFollower.ts @@ -1,12 +1,13 @@ -import { action, runInAction } from 'mobx'; -import { Doc, DocListCast, Opt } from '../../fields/Doc'; -import { BoolCast, Cast, DocCast, NumCast, StrCast } from '../../fields/Types'; +import { action, observable, runInAction } from 'mobx'; +import { Doc, DocListCast, Field, FieldResult, Opt } from '../../fields/Doc'; +import { ScriptField } from '../../fields/ScriptField'; +import { BoolCast, Cast, DocCast, NumCast, ScriptCast, StrCast } from '../../fields/Types'; import { DocumentType } from '../documents/DocumentTypes'; -import { DocumentDecorations } from '../views/DocumentDecorations'; -import { DocFocusOptions, DocumentViewSharedProps, OpenWhere } from '../views/nodes/DocumentView'; +import { DocFocusOptions, OpenWhere } from '../views/nodes/DocumentView'; import { PresBox } from '../views/nodes/trails'; import { DocumentManager } from './DocumentManager'; import { LinkManager } from './LinkManager'; +import { ScriptingGlobals } from './ScriptingGlobals'; import { SelectionManager } from './SelectionManager'; import { UndoManager } from './UndoManager'; /* @@ -22,18 +23,19 @@ import { UndoManager } from './UndoManager'; * - user defined kvps */ export class LinkFollower { + @observable public static IsFollowing = false; // follows a link - if the target is on screen, it highlights/pans to it. // if the target isn't onscreen, then it will open up the target in the lightbox, or in place // depending on the followLinkLocation property of the source (or the link itself as a fallback); public static FollowLink = (linkDoc: Opt<Doc>, sourceDoc: Doc, altKey: boolean) => { const batch = UndoManager.StartBatch('follow link click'); - runInAction(() => (DocumentDecorations.Instance.overrideBounds = true)); // turn off decoration bounds while following links since animations may occur, and DocDecorations is based on screenToLocal which is not always an observable value + runInAction(() => (LinkFollower.IsFollowing = true)); // turn off decoration bounds while following links since animations may occur, and DocDecorations is based on screenToLocal which is not always an observable value LinkFollower.traverseLink( linkDoc, sourceDoc, action(() => { batch.end(); - Doc.AddUnHighlightWatcher(action(() => (DocumentDecorations.Instance.overrideBounds = false))); + Doc.AddUnHighlightWatcher(action(() => (LinkFollower.IsFollowing = false))); }), altKey ? true : undefined ); @@ -73,7 +75,7 @@ export class LinkFollower { zoomTime: NumCast(sourceDoc.followLinkTransitionTime, 500), zoomScale: Cast(sourceDoc.followLinkZoomScale, 'number', null), easeFunc: StrCast(sourceDoc.followLinkEase, 'ease') as any, - openLocation: StrCast(sourceDoc.followLinkLocation, OpenWhere.lightbox), + openLocation: StrCast(sourceDoc.followLinkLocation, OpenWhere.lightbox) as OpenWhere, effect: sourceDoc, zoomTextSelections: BoolCast(sourceDoc.followLinkZoomText), }; @@ -118,3 +120,15 @@ export class LinkFollower { }); } } + +ScriptingGlobals.add(function followLink(doc: Doc, altKey: boolean) { + SelectionManager.DeselectAll(); + LinkFollower.FollowLink(undefined, doc, altKey); +}); + +export function FollowLinkScript() { + return ScriptField.MakeScript('followLink(this,altKey)', { altKey: 'boolean' }); +} +export function IsFollowLinkScript(field: FieldResult<Field>) { + return ScriptCast(field)?.script.originalScript.includes('followLink('); +} diff --git a/src/client/util/ReplayMovements.ts b/src/client/util/ReplayMovements.ts index d5bffc5e2..40261985a 100644 --- a/src/client/util/ReplayMovements.ts +++ b/src/client/util/ReplayMovements.ts @@ -7,6 +7,7 @@ import { CollectionDockingView } from '../views/collections/CollectionDockingVie import { DocServer } from '../DocServer'; import { Movement, Presentation } from './TrackMovements'; import { OpenWhereMod } from '../views/nodes/DocumentView'; +import { returnTransparent } from '../../Utils'; export class ReplayMovements { private timers: NodeJS.Timeout[] | null; @@ -60,17 +61,11 @@ export class ReplayMovements { return; } - let docIdtoDoc: Map<string, Doc> = new Map(); - try { - docIdtoDoc = await this.loadPresentation(presentation); - } catch { - console.error('[recordingApi.ts] setVideoBox(): error loading presentation - no replay movements'); - throw 'error loading docs from server'; - } + const docIdtoDoc = this.loadPresentation(presentation); this.videoBoxDisposeFunc = reaction( () => ({ playing: videoBox._playing, timeViewed: videoBox.player?.currentTime || 0 }), - ({ playing, timeViewed }) => (playing ? this.playMovements(presentation, docIdtoDoc, timeViewed) : this.pauseMovements()) + ({ playing, timeViewed }) => (playing ? this.playMovements(presentation, timeViewed) : this.pauseMovements()) ); this.videoBox = videoBox; }; @@ -93,45 +88,33 @@ export class ReplayMovements { this.pauseMovements(); }; - loadPresentation = async (presentation: Presentation) => { + loadPresentation = (presentation: Presentation) => { const { movements } = presentation; if (movements === null) { throw '[recordingApi.ts] followMovements() failed: no presentation data'; } // generate a set of all unique docIds - const docIds = new Set<string>(); - for (const { docId } of movements) { - if (!docIds.has(docId)) docIds.add(docId); - } - - const docIdtoDoc = new Map<string, Doc>(); - - let refFields = await DocServer.GetRefFields([...docIds.keys()]); - for (const docId in refFields) { - if (!refFields[docId]) { - throw `one field was undefined`; - } - docIdtoDoc.set(docId, refFields[docId] as Doc); + const docs = new Set<Doc>(); + for (const { doc } of movements) { + if (!docs.has(doc)) docs.add(doc); } - // console.info('loadPresentation refFields', refFields, docIdtoDoc); - return docIdtoDoc; + return docs; }; // returns undefined if the docView isn't open on the screen - getCollectionFFView = (docId: string) => { - const isInView = DocumentManager.Instance.getDocumentViewById(docId); + getCollectionFFView = (doc: Doc) => { + const isInView = DocumentManager.Instance.getDocumentView(doc); if (isInView) { return isInView.ComponentView as CollectionFreeFormView; } }; // will open the doc in a tab then return the CollectionFFView that holds it - openTab = (docId: string, docIdtoDoc: Map<string, Doc>) => { - const doc = docIdtoDoc.get(docId); - if (doc == undefined) { - console.error(`docIdtoDoc did not contain docId ${docId}`); + openTab = (doc: Doc) => { + if (doc === undefined) { + console.error(`doc undefined`); return undefined; } // console.log('openTab', docId, doc); @@ -149,13 +132,12 @@ export class ReplayMovements { document.Document._panY = panY; }; - getFirstMovements = (movements: Movement[]): Map<string, Movement> => { + getFirstMovements = (movements: Movement[]): Map<Doc, Movement> => { if (movements === null) return new Map(); // generate a set of all unique docIds - const docIdtoFirstMove = new Map(); + const docIdtoFirstMove = new Map<Doc, Movement>(); for (const move of movements) { - const { docId } = move; - if (!docIdtoFirstMove.has(docId)) docIdtoFirstMove.set(docId, move); + if (!docIdtoFirstMove.has(move.doc)) docIdtoFirstMove.set(move.doc, move); } return docIdtoFirstMove; }; @@ -165,7 +147,7 @@ export class ReplayMovements { Doc.UserDoc().presentationMode = 'none'; }; - public playMovements = (presentation: Presentation, docIdtoDoc: Map<string, Doc>, timeViewed: number = 0) => { + public playMovements = (presentation: Presentation, timeViewed: number = 0) => { // console.info('playMovements', presentation, timeViewed, docIdtoDoc); if (presentation.movements === null || presentation.movements.length === 0) { @@ -183,13 +165,13 @@ export class ReplayMovements { const handleFirstMovements = () => { // if the first movement is a closed tab, open it const firstMovement = filteredMovements[0]; - const isClosed = this.getCollectionFFView(firstMovement.docId) === undefined; - if (isClosed) this.openTab(firstMovement.docId, docIdtoDoc); + const isClosed = this.getCollectionFFView(firstMovement.doc) === undefined; + if (isClosed) this.openTab(firstMovement.doc); // for the open tabs, set it to the first move const docIdtoFirstMove = this.getFirstMovements(filteredMovements); - for (const [docId, firstMove] of docIdtoFirstMove) { - const colFFView = this.getCollectionFFView(docId); + for (const [doc, firstMove] of docIdtoFirstMove) { + const colFFView = this.getCollectionFFView(doc); if (colFFView) this.zoomAndPan(firstMove, colFFView); } }; @@ -200,12 +182,12 @@ export class ReplayMovements { const timeDiff = movement.time - timeViewed * 1000; return setTimeout(() => { - const collectionFFView = this.getCollectionFFView(movement.docId); + const collectionFFView = this.getCollectionFFView(movement.doc); if (collectionFFView) { this.zoomAndPan(movement, collectionFFView); } else { // tab wasn't open - open it and play the movement - const openedColFFView = this.openTab(movement.docId, docIdtoDoc); + const openedColFFView = this.openTab(movement.doc); console.log('openedColFFView', openedColFFView); openedColFFView && this.zoomAndPan(movement, openedColFFView); } diff --git a/src/client/util/SelectionManager.ts b/src/client/util/SelectionManager.ts index 313c255a0..bfad93334 100644 --- a/src/client/util/SelectionManager.ts +++ b/src/client/util/SelectionManager.ts @@ -1,4 +1,3 @@ -import { ModalManager } from '@material-ui/core'; import { action, observable, ObservableMap } from 'mobx'; import { computedFn } from 'mobx-utils'; import { Doc, Opt } from '../../fields/Doc'; @@ -64,8 +63,9 @@ export namespace SelectionManager { export function DeselectView(docView?: DocumentView): void { manager.DeselectView(docView); } - export function SelectView(docView: DocumentView, ctrlPressed: boolean): void { - manager.SelectView(docView, ctrlPressed); + export function SelectView(docView: DocumentView | undefined, ctrlPressed: boolean): void { + if (!docView) DeselectAll(); + else manager.SelectView(docView, ctrlPressed); } export function SelectSchemaViewDoc(document: Opt<Doc>, deselectAllFirst?: boolean): void { if (deselectAllFirst) manager.DeselectAll(); diff --git a/src/client/util/SharingManager.tsx b/src/client/util/SharingManager.tsx index 3c05af4bb..a73eda04c 100644 --- a/src/client/util/SharingManager.tsx +++ b/src/client/util/SharingManager.tsx @@ -370,11 +370,10 @@ export class SharingManager extends React.Component<{}> { const docs = SelectionManager.Views().length > 1 ? SelectionManager.Views().map(docView => docView.props.Document) : [this.targetDoc]; return ( <span - className={'focus-span'} + className="focus-span" title={title} onClick={() => { - let context: Opt<CollectionView>; - if (this.targetDoc && this.targetDocView && docs.length === 1 && (context = this.targetDocView.props.ContainingCollectionView)) { + if (this.targetDoc && this.targetDocView && docs.length === 1) { DocumentManager.Instance.showDocument(this.targetDoc, { willZoomCentered: true }); } }} diff --git a/src/client/util/TrackMovements.ts b/src/client/util/TrackMovements.ts index 4a2ccd706..2f16307b3 100644 --- a/src/client/util/TrackMovements.ts +++ b/src/client/util/TrackMovements.ts @@ -9,7 +9,7 @@ export type Movement = { panX: number; panY: number; scale: number; - docId: string; + doc: Doc; }; export type Presentation = { @@ -28,7 +28,7 @@ export class TrackMovements { private tracking: boolean; private absoluteStart: number; // instance variable for holding the FFViews and their disposers - private recordingFFViews: Map<string, IReactionDisposer> | null; + private recordingFFViews: Map<Doc, IReactionDisposer> | null; private tabChangeDisposeFunc: IReactionDisposer | null; // create static instance and getter for global use @@ -55,33 +55,33 @@ export class TrackMovements { return this.currentPresentation.movements === null; } - private addRecordingFFView(doc: Doc, key: string = doc[Id]): void { + private addRecordingFFView(doc: Doc): void { // console.info('adding dispose func : docId', key, 'doc', doc); if (this.recordingFFViews === null) { console.warn('addFFView on null RecordingApi'); return; } - if (this.recordingFFViews.has(key)) { - console.warn('addFFView : key already in map'); + if (this.recordingFFViews.has(doc)) { + console.warn('addFFView : doc already in map'); return; } const disposeFunc = reaction( () => ({ x: NumCast(doc.panX, -1), y: NumCast(doc.panY, -1), scale: NumCast(doc.viewScale, 0) }), - res => res.x !== -1 && res.y !== -1 && this.tracking && this.trackMovement(res.x, res.y, key, res.scale) + res => res.x !== -1 && res.y !== -1 && this.tracking && this.trackMovement(res.x, res.y, doc, res.scale) ); - this.recordingFFViews?.set(key, disposeFunc); + this.recordingFFViews?.set(doc, disposeFunc); } - private removeRecordingFFView = (key: string) => { + private removeRecordingFFView = (doc: Doc) => { // console.info('removing dispose func : docId', key); if (this.recordingFFViews === null) { console.warn('removeFFView on null RecordingApi'); return; } - this.recordingFFViews.get(key)?.(); - this.recordingFFViews.delete(key); + this.recordingFFViews.get(doc)?.(); + this.recordingFFViews.delete(doc); }; // in the case where only one tab was changed (updates not across dashboards), set only one to true @@ -90,15 +90,15 @@ export class TrackMovements { // so that the size comparisons are correct, we must filter to only the FFViews const isFFView = (doc: Doc) => doc && 'viewType' in doc && doc.viewType === 'freeform'; - const tabbedFFViews = new Set<string>(); + const tabbedFFViews = new Set<Doc>(); for (const DashDoc of tabbedDocs) { - if (isFFView(DashDoc)) tabbedFFViews.add(DashDoc[Id]); + if (isFFView(DashDoc)) tabbedFFViews.add(DashDoc); } // new tab was added - need to add it if (tabbedFFViews.size > this.recordingFFViews.size) { for (const DashDoc of tabbedDocs) { - if (!this.recordingFFViews.has(DashDoc[Id])) { + if (!this.recordingFFViews.has(DashDoc)) { if (isFFView(DashDoc)) { this.addRecordingFFView(DashDoc); @@ -110,9 +110,9 @@ export class TrackMovements { } // tab was removed - need to remove it from recordingFFViews else if (tabbedFFViews.size < this.recordingFFViews.size) { - for (const [key] of this.recordingFFViews) { - if (!tabbedFFViews.has(key)) { - this.removeRecordingFFView(key); + for (const [doc] of this.recordingFFViews) { + if (!tabbedFFViews.has(doc)) { + this.removeRecordingFFView(doc); if (onlyOne) return; } } @@ -214,7 +214,7 @@ export class TrackMovements { } }; - private trackMovement = (panX: number, panY: number, docId: string, scale: number = 0) => { + private trackMovement = (panX: number, panY: number, doc: Doc, scale: number = 0) => { // ensure we are recording to track if (!this.tracking) { console.error('[recordingApi.ts] trackMovements(): tracking is false'); @@ -231,7 +231,7 @@ export class TrackMovements { // get the time const time = new Date().getTime() - this.absoluteStart; // make new movement object - const movement: Movement = { time, panX, panY, scale, docId }; + const movement: Movement = { time, panX, panY, scale, doc }; // add that movement to the current presentation data's movement array this.currentPresentation.movements && this.currentPresentation.movements.push(movement); diff --git a/src/client/views/DashboardView.tsx b/src/client/views/DashboardView.tsx index 2b586b0e2..dee161931 100644 --- a/src/client/views/DashboardView.tsx +++ b/src/client/views/DashboardView.tsx @@ -109,17 +109,8 @@ export class DashboardView extends React.Component { /> </div> <div className="button-bar"> - <Button text="Cancel" borderRadius={10} hoverStyle={'gray'} fontSize={FontSize.SECONDARY} onClick={this.abortCreateNewDashboard} /> - <Button - text="Create" - borderRadius={10} - backgroundColor={Colors.LIGHT_BLUE} - hoverStyle={'darken'} - fontSize={FontSize.SECONDARY} - onClick={() => { - this.createNewDashboard(this.newDashboardName!, this.newDashboardColor); - }} - /> + <Button text="Cancel" onClick={this.abortCreateNewDashboard} /> + <Button text="Create" onClick={() => this.createNewDashboard(this.newDashboardName!, this.newDashboardColor)} /> </div> </div> ); @@ -163,7 +154,7 @@ export class DashboardView extends React.Component { <div className="dashboard-view"> <div className="left-menu"> <div className="new-dashboard-button"> - <Button icon={<FaPlus />} hoverStyle="darken" backgroundColor={Colors.LIGHT_BLUE} size={Size.MEDIUM} fontSize={FontSize.HEADER} text="New" onClick={() => this.setNewDashboardName('')} borderRadius={50} /> + <Button icon={<FaPlus />} size={Size.MEDIUM} text="New" onClick={() => this.setNewDashboardName('')} /> </div> <div className={`text-button ${this.selectedDashboardGroup === DashboardGroup.MyDashboards && 'selected'}`} onClick={() => this.selectDashboardGroup(DashboardGroup.MyDashboards)}> My Dashboards @@ -196,7 +187,7 @@ export class DashboardView extends React.Component { e.stopPropagation(); this.onContextMenu(dashboard, e); }}> - <IconButton isCircle={true} size={Size.SMALL} hoverStyle="gray" icon={<FontAwesomeIcon color="black" size="lg" icon="bars" />} /> + <Button size={Size.SMALL} icon={<FontAwesomeIcon color="black" size="lg" icon="bars" />} /> </div> </div> </div> diff --git a/src/client/views/DocComponent.tsx b/src/client/views/DocComponent.tsx index 9fc1487a0..a59189fd2 100644 --- a/src/client/views/DocComponent.tsx +++ b/src/client/views/DocComponent.tsx @@ -52,7 +52,6 @@ export function DocComponent<P extends DocComponentProps>() { interface ViewBoxBaseProps { Document: Doc; DataDoc?: Doc; - ContainingCollectionDoc: Opt<Doc>; DocumentView?: () => DocumentView; fieldKey: string; isSelected: (outsideReaction?: boolean) => boolean; diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx index e83ea00cf..8c2ced55a 100644 --- a/src/client/views/DocumentButtonBar.tsx +++ b/src/client/views/DocumentButtonBar.tsx @@ -1,7 +1,7 @@ import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Tooltip } from '@material-ui/core'; -import { action, computed, observable, runInAction, trace } from 'mobx'; +import { action, computed, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import { Doc } from '../../fields/Doc'; import { RichTextField } from '../../fields/RichTextField'; @@ -11,6 +11,7 @@ import { GoogleAuthenticationManager } from '../apis/GoogleAuthenticationManager import { Pulls, Pushes } from '../apis/google_docs/GoogleApiClientUtils'; import { Docs, DocUtils } from '../documents/Documents'; import { DragManager } from '../util/DragManager'; +import { IsFollowLinkScript } from '../util/LinkFollower'; import { SelectionManager } from '../util/SelectionManager'; import { SharingManager } from '../util/SharingManager'; import { undoBatch, UndoManager } from '../util/UndoManager'; @@ -24,11 +25,9 @@ import { DocumentLinksButton } from './nodes/DocumentLinksButton'; import { DocumentView, DocumentViewInternal, OpenWhere, OpenWhereMod } from './nodes/DocumentView'; import { DashFieldView } from './nodes/formattedText/DashFieldView'; import { GoogleRef } from './nodes/formattedText/FormattedTextBox'; +import { PinProps } from './nodes/trails'; import { TemplateMenu } from './TemplateMenu'; import React = require('react'); -import { DocumentType } from '../documents/DocumentTypes'; -import { FontIconBox } from './nodes/button/FontIconBox'; -import { PinProps } from './nodes/trails'; const higflyout = require('@hig/flyout'); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; @@ -238,11 +237,12 @@ export class DocumentButtonBar extends React.Component<{ views: () => (DocumentV </Tooltip> ); }; + const followLink = IsFollowLinkScript(targetDoc?.onClick); return !targetDoc ? null : ( <Tooltip title={<div className="dash-tooltip">Set onClick to follow primary link</div>}> <div className="documentButtonBar-icon documentButtonBar-follow" - style={{ backgroundColor: targetDoc.isLinkButton ? Colors.LIGHT_BLUE : Colors.DARK_GRAY, color: targetDoc.isLinkButton ? Colors.BLACK : Colors.WHITE }} + style={{ backgroundColor: followLink ? Colors.LIGHT_BLUE : Colors.DARK_GRAY, color: followLink ? Colors.BLACK : Colors.WHITE }} onClick={undoBatch(e => this.props.views().map(view => view?.docView?.toggleFollowLink(undefined, false)))}> <div className="documentButtonBar-followTypes"> {followBtn( @@ -384,7 +384,7 @@ export class DocumentButtonBar extends React.Component<{ views: () => (DocumentV .views() .filter(v => v) .map(dv => dv!.rootDoc); - TabDocView.PinDoc(docs, { pinAudioPlay: true, pinDocLayout: e.shiftKey, pinData: {dataview: e.altKey}, activeFrame: Cast(docs.lastElement()?.activeFrame, 'number', null) }); + TabDocView.PinDoc(docs, { pinAudioPlay: true, pinDocLayout: e.shiftKey, pinData: { dataview: e.altKey }, activeFrame: Cast(docs.lastElement()?.activeFrame, 'number', null) }); e.stopPropagation(); }}> <div className="documentButtonBar-pinTypes"> @@ -593,7 +593,7 @@ export class DocumentButtonBar extends React.Component<{ views: () => (DocumentV <LinkPopup key="popup" showPopup={this._showLinkPopup} - linkCreated={link => (link.linkDisplay = !this.props.views().lastElement()?.rootDoc.isLinkButton)} + linkCreated={link => (link.linkDisplay = !IsFollowLinkScript(this.props.views().lastElement()?.rootDoc.onClick))} linkCreateAnchor={() => this.props.views().lastElement()?.ComponentView?.getAnchor?.(true)} linkFrom={() => this.props.views().lastElement()?.rootDoc} /> diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 9bc583ce5..042e8f6d0 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -32,6 +32,8 @@ import { FormattedTextBox } from './nodes/formattedText/FormattedTextBox'; import { ImageBox } from './nodes/ImageBox'; import React = require('react'); import { RichTextField } from '../../fields/RichTextField'; +import { LinkFollower } from '../util/LinkFollower'; +import _ = require('lodash'); @observer export class DocumentDecorations extends React.Component<{ PanelWidth: number; PanelHeight: number; boundsLeft: number; boundsTop: number }, { value: string }> { @@ -83,10 +85,9 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number; P ); } - @observable overrideBounds = false; @computed get Bounds() { - if (this.overrideBounds) return { x: 0, y: 0, r: 0, b: 0 }; + if (LinkFollower.IsFollowing) return { x: 0, y: 0, r: 0, b: 0 }; const views = SelectionManager.Views(); return views .filter(dv => dv.props.renderDepth > 0) @@ -303,8 +304,7 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number; P SelectionManager.DeselectAll(); }; - onSelectorClick = () => SelectionManager.Views()?.[0]?.props.ContainingCollectionView?.props.select(false); - + onSelectorClick = () => SelectionManager.Views()?.[0]?.props.docViewPath?.().lastElement()?.select(false); /** * Handles setting up events when user clicks on the border radius editor * @param e PointerEvent @@ -744,7 +744,7 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number; P seldocview.props.hideDeleteButton || seldocview.rootDoc.hideDeleteButton || SelectionManager.Views().some(docView => { - const collectionAcl = docView.props.ContainingCollectionView ? GetEffectiveAcl(docView.props.ContainingCollectionDoc?.[DataSym]) : AclEdit; + const collectionAcl = docView.props.docViewPath()?.lastElement() ? GetEffectiveAcl(docView.props.docViewPath().lastElement().rootDoc[DataSym]) : AclEdit; return docView.rootDoc.stayInCollection || (collectionAcl !== AclAdmin && collectionAcl !== AclEdit && GetEffectiveAcl(docView.rootDoc) !== AclAdmin); }); @@ -781,7 +781,7 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number; P const useLock = bounds.r - bounds.x > 135 && seldocview.props.CollectionFreeFormDocumentView; const useRotation = !hideResizers && seldocview.rootDoc.type !== DocumentType.EQUATION && seldocview.props.CollectionFreeFormDocumentView; // when do we want an object to not rotate? - const rotation = NumCast(seldocview.rootDoc._rotation); + const rotation = SelectionManager.Views().length == 1 ? NumCast(seldocview.rootDoc._rotation) : 0; const resizerScheme = colorScheme ? 'documentDecorations-resizer' + colorScheme : ''; @@ -828,7 +828,7 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number; P left: bounds.x - this._resizeBorderWidth / 2, top: bounds.y - this._resizeBorderWidth / 2, pointerEvents: DocumentDecorations.Instance.AddToSelection || this.Interacting ? 'none' : 'all', - display: SelectionManager.Views().length <= 1 ? 'none' : undefined, + display: SelectionManager.Views().length <= 1 || hideDecorations ? 'none' : undefined, }} onPointerDown={this.onBackgroundDown} onContextMenu={e => { @@ -865,7 +865,7 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number; P <div key="b" className={`documentDecorations-bottomResizer ${resizerScheme}`} onPointerDown={this.onPointerDown} onContextMenu={e => e.preventDefault()} /> <div key="br" className={`documentDecorations-bottomRightResizer ${resizerScheme}`} onPointerDown={this.onPointerDown} onContextMenu={e => e.preventDefault()} /> - {seldocview.props.renderDepth <= 1 || !seldocview.props.ContainingCollectionView ? null : topBtn('selector', 'arrow-alt-circle-up', undefined, this.onSelectorClick, 'tap to select containing document')} + {seldocview.props.renderDepth <= 1 || !seldocview.props.docViewPath().lastElement() ? null : topBtn('selector', 'arrow-alt-circle-up', undefined, this.onSelectorClick, 'tap to select containing document')} </> )} {useRounding && ( @@ -908,7 +908,7 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number; P {this._isRotating ? null : ( <Tooltip enterDelay={750} title={<div className="dash-tooltip">tap to set rotate center, drag to rotate</div>}> <div className="documentDecorations-rotation" style={{ pointerEvents: 'all' }} onPointerDown={this.onRotateDown} onContextMenu={e => e.preventDefault()}> - <IconButton icon={<FaUndo />} isCircle={true} hoverStyle={'lighten'} backgroundColor={Colors.DARK_GRAY} color={Colors.LIGHT_GRAY} /> + <IconButton icon={<FaUndo />} color={Colors.LIGHT_GRAY} /> </div> </Tooltip> )} diff --git a/src/client/views/GestureOverlay.tsx b/src/client/views/GestureOverlay.tsx index 0feccb742..13faae783 100644 --- a/src/client/views/GestureOverlay.tsx +++ b/src/client/views/GestureOverlay.tsx @@ -1010,14 +1010,12 @@ export class GestureOverlay extends Touchable<GestureOverlayProps> { renderDepth={0} styleProvider={returnEmptyString} docViewPath={returnEmptyDoclist} - focus={DocUtils.DefaultFocus} + focus={emptyFunction} whenChildContentsActiveChanged={emptyFunction} bringToFront={emptyFunction} docRangeFilters={returnEmptyFilter} docFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} /> ); }; diff --git a/src/client/views/InkStrokeProperties.ts b/src/client/views/InkStrokeProperties.ts index e6df0801c..156825f41 100644 --- a/src/client/views/InkStrokeProperties.ts +++ b/src/client/views/InkStrokeProperties.ts @@ -373,10 +373,11 @@ export class InkStrokeProperties { snapToAllCurves = (screenDragPt: { X: number; Y: number }, inkView: DocumentView, snapData: { nearestPt: { X: number; Y: number }; distance: number }, ink: InkData, controlIndex: number) => { const containingCollection = inkView.props.CollectionFreeFormDocumentView?.().props.CollectionFreeFormView; + const containingDocView = inkView.props.CollectionFreeFormDocumentView?.().props.DocumentView?.(); containingCollection?.childDocs .filter(doc => doc.type === DocumentType.INK) .forEach(doc => { - const testInkView = DocumentManager.Instance.getDocumentView(doc, containingCollection?.props.CollectionView); + const testInkView = DocumentManager.Instance.getDocumentView(doc, containingDocView); const snapped = testInkView?.ComponentView?.snapPt?.(screenDragPt, doc === inkView.rootDoc ? this.excludeSelfSnapSegs(ink, controlIndex) : []); if (snapped && snapped.distance < snapData.distance) { const snappedInkPt = doc === inkView.rootDoc ? snapped.nearestPt : inkView.ComponentView?.ptFromScreen?.(testInkView?.ComponentView?.ptToScreen?.(snapped.nearestPt) ?? { X: 0, Y: 0 }); // convert from snapped ink coordinate system to dragged ink coordinate system by converting to/from screen space diff --git a/src/client/views/LightboxView.tsx b/src/client/views/LightboxView.tsx index 976c8763e..69eec8456 100644 --- a/src/client/views/LightboxView.tsx +++ b/src/client/views/LightboxView.tsx @@ -266,8 +266,6 @@ export class LightboxView extends React.Component<LightboxViewProps> { docFilters={this.docFilters} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} addDocument={undefined} removeDocument={undefined} whenChildContentsActiveChanged={emptyFunction} @@ -275,7 +273,7 @@ export class LightboxView extends React.Component<LightboxViewProps> { pinToPres={TabDocView.PinDoc} bringToFront={emptyFunction} onBrowseClick={MainView.Instance.exploreMode} - focus={DocUtils.DefaultFocus} + focus={emptyFunction} /> </GestureOverlay> </div> diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index b0888df68..4cbf8a811 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -243,6 +243,7 @@ export class MainView extends React.Component { library.add( ...[ + fa.faExclamationCircle, fa.faEdit, fa.faTrash, fa.faTrashAlt, @@ -275,6 +276,7 @@ export class MainView extends React.Component { fa.faHandPointRight, fa.faCompass, fa.faSnowflake, + fa.faStar, fa.faMicrophone, fa.faKeyboard, fa.faQuestion, @@ -312,6 +314,8 @@ export class MainView extends React.Component { fa.faArrowUp, fa.faBolt, fa.faBullseye, + fa.faTurnUp, + fa.faTurnDown, fa.faCaretUp, fa.faCat, fa.faCheck, @@ -322,6 +326,7 @@ export class MainView extends React.Component { fa.faClone, fa.faCloudUploadAlt, fa.faCommentAlt, + fa.faCommentDots, fa.faCompressArrowsAlt, fa.faCut, fa.faEllipsisV, @@ -568,6 +573,7 @@ export class MainView extends React.Component { @computed get exploreMode() { return () => (this._exploreMode ? ScriptField.MakeScript('CollectionBrowseClick(documentView, clientX, clientY)', { documentView: 'any', clientX: 'number', clientY: 'number' })! : undefined); } + waitForDoubleClick = () => (this._exploreMode ? 'never' : undefined); headerBarScreenXf = () => new Transform(-this.leftScreenOffsetOfMainDocView - this.leftMenuFlyoutWidth(), -this.headerBarDocHeight(), 1); mainScreenToLocalXf = () => new Transform(-this.leftScreenOffsetOfMainDocView - this.leftMenuFlyoutWidth(), -this.topOfMainDocContent, 1); @computed get headerBarDocView() { @@ -594,14 +600,12 @@ export class MainView extends React.Component { PanelWidth={this.headerBarDocWidth} PanelHeight={this.headerBarDocHeight} renderDepth={0} - focus={DocUtils.DefaultFocus} + focus={emptyFunction} whenChildContentsActiveChanged={emptyFunction} bringToFront={emptyFunction} docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} /> </div> ); @@ -625,14 +629,12 @@ export class MainView extends React.Component { ScreenToLocalTransform={this._hideUI ? this.mainScreenToLocalXf : Transform.Identity} PanelWidth={this.mainDocViewWidth} PanelHeight={this.mainDocViewHeight} - focus={DocUtils.DefaultFocus} + focus={emptyFunction} whenChildContentsActiveChanged={emptyFunction} bringToFront={emptyFunction} docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} suppressSetHeight={true} renderDepth={this._hideUI ? 0 : -1} /> @@ -726,14 +728,12 @@ export class MainView extends React.Component { renderDepth={0} isContentActive={returnTrue} scriptContext={CollectionDockingView.Instance?.props.Document} - focus={DocUtils.DefaultFocus} + focus={emptyFunction} whenChildContentsActiveChanged={emptyFunction} bringToFront={emptyFunction} docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} /> </div> {this.docButtons} @@ -757,7 +757,7 @@ export class MainView extends React.Component { PanelHeight={this.leftMenuHeight} renderDepth={0} docViewPath={returnEmptyDoclist} - focus={DocUtils.DefaultFocus} + focus={emptyFunction} styleProvider={DefaultStyleProvider} isContentActive={returnTrue} whenChildContentsActiveChanged={emptyFunction} @@ -765,8 +765,6 @@ export class MainView extends React.Component { docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} scriptContext={this} /> </div> @@ -890,7 +888,6 @@ export class MainView extends React.Component { isSelected={returnFalse} docViewPath={returnEmptyDoclist} moveDocument={this.moveButtonDoc} - CollectionView={undefined} addDocument={this.addButtonDoc} addDocTab={MainView.addDocTabFunc} pinToPres={emptyFunction} @@ -899,13 +896,11 @@ export class MainView extends React.Component { PanelWidth={this.leftMenuFlyoutWidth} PanelHeight={this.leftMenuFlyoutHeight} renderDepth={0} - focus={DocUtils.DefaultFocus} + focus={emptyFunction} whenChildContentsActiveChanged={emptyFunction} docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} /> {['watching', 'recording'].includes(String(this.userDoc?.presentationMode) ?? '') ? <div style={{ border: '.5rem solid green', padding: '5px' }}>{StrCast(this.userDoc?.presentationMode)}</div> : <></>} </div> diff --git a/src/client/views/MarqueeAnnotator.tsx b/src/client/views/MarqueeAnnotator.tsx index 30867a774..8fd2b87cc 100644 --- a/src/client/views/MarqueeAnnotator.tsx +++ b/src/client/views/MarqueeAnnotator.tsx @@ -14,6 +14,8 @@ import { DocumentView } from './nodes/DocumentView'; import { FormattedTextBox } from './nodes/formattedText/FormattedTextBox'; import { AnchorMenu } from './pdf/AnchorMenu'; import React = require('react'); +import { ScriptField } from '../../fields/ScriptField'; +import { FollowLinkScript } from '../util/LinkFollower'; const _global = (window /* browser */ || global) /* node */ as any; export interface MarqueeAnnotatorProps { @@ -146,7 +148,12 @@ export class MarqueeAnnotator extends React.Component<MarqueeAnnotatorProps> { const scale = this.props.scaling?.() || 1; const anno = savedAnnos[0]; const containerOffset = this.props.containerOffset?.() || [0, 0]; - const marqueeAnno = Docs.Create.FreeformDocument([], { _isLinkButton: isLinkButton, backgroundColor: color, annotationOn: this.props.rootDoc, title: 'Annotation on ' + this.props.rootDoc.title }); + const marqueeAnno = Docs.Create.FreeformDocument([], { + onClick: isLinkButton ? FollowLinkScript() : undefined, + backgroundColor: color, + annotationOn: this.props.rootDoc, + title: 'Annotation on ' + this.props.rootDoc.title, + }); marqueeAnno.x = NumCast(this.props.docView.props.Document.panXMin) + (parseInt(anno.style.left || '0') - containerOffset[0]) / scale / NumCast(this.props.docView.props.Document._viewScale, 1); marqueeAnno.y = NumCast(this.props.docView.props.Document.panYMin) + (parseInt(anno.style.top || '0') - containerOffset[1]) / scale / NumCast(this.props.docView.props.Document._viewScale, 1) + NumCast(this.props.scrollTop); marqueeAnno._height = parseInt(anno.style.height || '0') / scale / NumCast(this.props.docView.props.Document._viewScale, 1); @@ -199,7 +206,7 @@ export class MarqueeAnnotator extends React.Component<MarqueeAnnotatorProps> { return textRegionAnno; }; @action - highlight = (color: string, isLinkButton: boolean, savedAnnotations?: ObservableMap<number, HTMLDivElement[]>, addAsAnnotation?: boolean) => { + highlight = (color: string, isLinkButton: boolean, savedAnnotations?: ObservableMap<number, HTMLDivElement[]>, addAsAnnotation?: boolean, summarize?: boolean) => { // creates annotation documents for current highlights const effectiveAcl = GetEffectiveAcl(this.props.rootDoc[DataSym]); const annotationDoc = [AclAugment, AclSelfEdit, AclEdit, AclAdmin].includes(effectiveAcl) && this.makeAnnotationDocument(color, isLinkButton, savedAnnotations); diff --git a/src/client/views/OverlayView.tsx b/src/client/views/OverlayView.tsx index ec22128d4..bdc48d03a 100644 --- a/src/client/views/OverlayView.tsx +++ b/src/client/views/OverlayView.tsx @@ -1,4 +1,4 @@ -import { action, computed, observable, trace } from 'mobx'; +import { action, computed, observable } from 'mobx'; import { observer } from 'mobx-react'; import { computedFn } from 'mobx-utils'; import * as React from 'react'; @@ -10,15 +10,13 @@ import { emptyFunction, returnEmptyDoclist, returnEmptyFilter, returnFalse, retu import { DocUtils } from '../documents/Documents'; import { DocumentType } from '../documents/DocumentTypes'; import { DragManager } from '../util/DragManager'; -import { ScriptingGlobals } from '../util/ScriptingGlobals'; import { Transform } from '../util/Transform'; import { CollectionFreeFormLinksView } from './collections/collectionFreeForm/CollectionFreeFormLinksView'; import { LightboxView } from './LightboxView'; import { MainView } from './MainView'; import { DocumentView } from './nodes/DocumentView'; import './OverlayView.scss'; -import { ScriptingRepl } from './ScriptingRepl'; -import { DefaultStyleProvider, testDocProps } from './StyleProvider'; +import { DefaultStyleProvider } from './StyleProvider'; export type OverlayDisposer = () => void; @@ -223,7 +221,7 @@ export class OverlayView extends React.Component { isDocumentActive={returnTrue} isContentActive={returnTrue} whenChildContentsActiveChanged={emptyFunction} - focus={DocUtils.DefaultFocus} + focus={emptyFunction} styleProvider={DefaultStyleProvider} docViewPath={returnEmptyDoclist} addDocTab={d.type === DocumentType.PRES ? MainView.addDocTabFunc : returnFalse} @@ -231,8 +229,6 @@ export class OverlayView extends React.Component { docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} /> </div> ); diff --git a/src/client/views/Palette.tsx b/src/client/views/Palette.tsx index 954529bc9..3ad28c418 100644 --- a/src/client/views/Palette.tsx +++ b/src/client/views/Palette.tsx @@ -1,12 +1,12 @@ -import { IReactionDisposer, observable, reaction } from "mobx"; -import { observer } from "mobx-react"; -import * as React from "react"; -import { Doc } from "../../fields/Doc"; -import { NumCast } from "../../fields/Types"; -import { emptyFunction, returnEmptyDoclist, returnEmptyFilter, returnEmptyString, returnFalse, returnTrue, emptyPath } from "../../Utils"; -import { Transform } from "../util/Transform"; -import { DocumentView } from "./nodes/DocumentView"; -import "./Palette.scss"; +import { IReactionDisposer, observable, reaction } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { Doc } from '../../fields/Doc'; +import { NumCast } from '../../fields/Types'; +import { emptyFunction, returnEmptyDoclist, returnEmptyFilter, returnEmptyString, returnFalse, returnTrue, emptyPath } from '../../Utils'; +import { Transform } from '../util/Transform'; +import { DocumentView } from './nodes/DocumentView'; +import './Palette.scss'; export interface PaletteProps { x: number; @@ -23,20 +23,20 @@ export default class Palette extends React.Component<PaletteProps> { componentDidMount = () => { this._selectedDisposer = reaction( () => NumCast(this.props.thumbDoc.selectedIndex), - (i) => this._selectedIndex = i, + i => (this._selectedIndex = i), { fireImmediately: true } ); - } + }; componentWillUnmount = () => { this._selectedDisposer?.(); - } + }; render() { return ( <div className="palette-container" style={{ transform: `translate(${this.props.x}px, ${this.props.y}px)` }}> <div className="palette-thumb" style={{ transform: `translate(${this.props.thumb[0] - this.props.x}px, ${this.props.thumb[1] - this.props.y}px)` }}> - <div className="palette-thumbContent" style={{ transform: `translate(-${(this._selectedIndex * 50) + 10}px, 0px)` }}> + <div className="palette-thumbContent" style={{ transform: `translate(-${this._selectedIndex * 50 + 10}px, 0px)` }}> <DocumentView Document={this.props.thumbDoc} DataDoc={undefined} @@ -59,12 +59,11 @@ export default class Palette extends React.Component<PaletteProps> { docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} /> + /> <div className="palette-cover" style={{ transform: `translate(${Math.max(0, this._selectedIndex) * 50.75 + 23}px, 0px)` }}></div> </div> </div> </div> ); } -}
\ No newline at end of file +} diff --git a/src/client/views/PropertiesButtons.tsx b/src/client/views/PropertiesButtons.tsx index 11e9dd9c9..98dcf4f21 100644 --- a/src/client/views/PropertiesButtons.tsx +++ b/src/client/views/PropertiesButtons.tsx @@ -21,6 +21,7 @@ import { DocumentView, OpenWhere } from './nodes/DocumentView'; import { pasteImageBitmap } from './nodes/WebBoxRenderer'; import './PropertiesButtons.scss'; import React = require('react'); +import { IsFollowLinkScript } from '../util/LinkFollower'; const higflyout = require('@hig/flyout'); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; @@ -176,7 +177,11 @@ export class PropertiesButtons extends React.Component<{}, {}> { '_showTitle', on => 'Switch between title styles', on => 'text-width', - (dv, doc) => ((dv?.rootDoc || doc)._showTitle = !(dv?.rootDoc || doc)._showTitle ? 'title' : (dv?.rootDoc || doc)._showTitle === 'title' ? 'title:hover' : undefined) + (dv, doc) => { + const tdoc = dv?.rootDoc || doc; + const newtitle = !tdoc._showTitle ? 'title' : tdoc._showTitle === 'title' ? 'title:hover' : ''; + tdoc._showTitle = newtitle; + } ); } @computed get autoHeightButton() { @@ -289,6 +294,7 @@ export class PropertiesButtons extends React.Component<{}, {}> { .filter(dv => dv.docView) .map(dv => dv.docView!) .forEach(docView => { + const linkButton = IsFollowLinkScript(docView.props.Document.onClick); docView.noOnClick(); switch (onClick) { case 'enterPortal': @@ -299,11 +305,11 @@ export class PropertiesButtons extends React.Component<{}, {}> { break; case 'linkInPlace': docView.toggleFollowLink(false, false); - docView.props.Document.followLinkLocation = docView.props.Document._isLinkButton ? OpenWhere.lightbox : undefined; + docView.props.Document.followLinkLocation = linkButton ? OpenWhere.lightbox : undefined; break; case 'linkOnRight': docView.toggleFollowLink(false, false); - docView.props.Document.followLinkLocation = docView.props.Document._isLinkButton ? OpenWhere.addRight : undefined; + docView.props.Document.followLinkLocation = linkButton ? OpenWhere.addRight : undefined; break; } }); @@ -326,7 +332,7 @@ export class PropertiesButtons extends React.Component<{}, {}> { ]; const list = buttonList.map(value => { const click = () => this.handleOptionChange(value[0]); - const linkButton = BoolCast(this.selectedDoc._isLinkButton); + const linkButton = IsFollowLinkScript(this.selectedDoc.onClick); const followLoc = this.selectedDoc._followLinkLocation; const linkedToLightboxView = () => LinkManager.Links(this.selectedDoc).some(link => LinkManager.getOppositeAnchor(link, this.selectedDoc)?._isLightbox); diff --git a/src/client/views/PropertiesDocBacklinksSelector.tsx b/src/client/views/PropertiesDocBacklinksSelector.tsx index 00c3400cb..935d551c6 100644 --- a/src/client/views/PropertiesDocBacklinksSelector.tsx +++ b/src/client/views/PropertiesDocBacklinksSelector.tsx @@ -25,7 +25,7 @@ export class PropertiesDocBacklinksSelector extends React.Component<PropertiesDo if (otherdoc) { otherdoc.hidden = false; - this.props.addDocTab(Doc.IsPrototype(otherdoc) ? Doc.MakeDelegate(otherdoc) : otherdoc, (OpenWhere.toggle + ':' + OpenWhereMod.right) as OpenWhere); + this.props.addDocTab(Doc.IsPrototype(otherdoc) ? Doc.MakeDelegate(otherdoc) : otherdoc, OpenWhere.toggleRight); } }; diff --git a/src/client/views/PropertiesDocContextSelector.tsx b/src/client/views/PropertiesDocContextSelector.tsx index 2c7da5931..c14d7ef73 100644 --- a/src/client/views/PropertiesDocContextSelector.tsx +++ b/src/client/views/PropertiesDocContextSelector.tsx @@ -22,9 +22,9 @@ export class PropertiesDocContextSelector extends React.Component<PropertiesDocC @computed get _docs() { if (!this.props.DocView) return []; const target = this.props.DocView.props.Document; - const targetContext = this.props.DocView.props.ContainingCollectionDoc; + const targetContext = this.props.DocView.props.docViewPath().lastElement()?.rootDoc; const aliases = DocListCast(target.aliases); - const containerProtos = aliases.filter(alias => alias.context && alias.context instanceof Doc && Cast(alias.context, Doc, null) !== targetContext).reduce((set, alias) => set.add(Cast(alias.context, Doc, null)), new Set<Doc>()); + const containerProtos = aliases.filter(alias => alias.context && alias.context instanceof Doc).reduce((set, alias) => set.add(Cast(alias.context, Doc, null)), new Set<Doc>()); const containerSets = Array.from(containerProtos.keys()).map(container => DocListCast(container.aliases)); const containers = containerSets.reduce((p, set) => { set.map(s => p.add(s)); @@ -42,19 +42,14 @@ export class PropertiesDocContextSelector extends React.Component<PropertiesDocC return doclayouts .filter(doc => !Doc.AreProtosEqual(doc, CollectionDockingView.Instance?.props.Document)) .filter(doc => !Doc.IsSystem(doc)) + .filter(doc => doc !== targetContext) .map(doc => ({ col: doc, target })); } getOnClick = (col: Doc, target: Doc) => { if (!this.props.DocView) return; col = Doc.IsPrototype(col) ? Doc.MakeDelegate(col) : col; - if (col._viewType === CollectionViewType.Freeform) { - col._panX = NumCast(target.x) + NumCast(target._width) / 2; - col._panY = NumCast(target.y) + NumCast(target._height) / 2; - } - col.hidden = false; - this.props.addDocTab(col, (OpenWhere.toggle + ':' + OpenWhereMod.right) as OpenWhere); - setTimeout(() => DocFocusOrOpen(Doc.GetProto(this.props.DocView!.props.Document), col), 100); + DocFocusOrOpen(Doc.GetProto(this.props.DocView.props.Document), undefined, col); }; render() { diff --git a/src/client/views/PropertiesView.tsx b/src/client/views/PropertiesView.tsx index fbc7d7696..6582c3f2a 100644 --- a/src/client/views/PropertiesView.tsx +++ b/src/client/views/PropertiesView.tsx @@ -341,8 +341,6 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionDoc={undefined} - ContainingCollectionView={undefined} addDocument={returnFalse} moveDocument={undefined} removeDocument={returnFalse} diff --git a/src/client/views/SidebarAnnos.tsx b/src/client/views/SidebarAnnos.tsx index 2d2b0f83e..b0aab968e 100644 --- a/src/client/views/SidebarAnnos.tsx +++ b/src/client/views/SidebarAnnos.tsx @@ -243,7 +243,6 @@ export class SidebarAnnos extends React.Component<FieldViewProps & ExtraProps> { removeDocument={this.removeDocument} moveDocument={this.moveDocument} addDocument={this.addDocument} - CollectionView={undefined} ScreenToLocalTransform={this.screenToLocalTransform} renderDepth={this.props.renderDepth + 1} viewType={CollectionViewType.Stacking} diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx index f7b9420a7..5f16e0ebd 100644 --- a/src/client/views/StyleProvider.tsx +++ b/src/client/views/StyleProvider.tsx @@ -1,13 +1,15 @@ import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Tooltip } from '@material-ui/core'; import { Shadows } from 'browndash-components'; import { action, runInAction } from 'mobx'; import { extname } from 'path'; import { Doc, Opt, StrListCast } from '../../fields/Doc'; -import { BoolCast, Cast, ImageCast, NumCast, StrCast } from '../../fields/Types'; +import { BoolCast, Cast, DocCast, ImageCast, NumCast, StrCast } from '../../fields/Types'; import { DashColor, lightOrDark, Utils } from '../../Utils'; import { CollectionViewType, DocumentType } from '../documents/DocumentTypes'; import { DocFocusOrOpen, DocumentManager } from '../util/DocumentManager'; +import { IsFollowLinkScript } from '../util/LinkFollower'; import { LinkManager } from '../util/LinkManager'; import { SelectionManager } from '../util/SelectionManager'; import { ColorScheme, SettingsManager } from '../util/SettingsManager'; @@ -16,15 +18,12 @@ import { TreeSort } from './collections/TreeView'; import { Colors } from './global/globalEnums'; import { InkingStroke } from './InkingStroke'; import { MainView } from './MainView'; -import { DocumentViewProps } from './nodes/DocumentView'; +import { DocumentViewProps, OpenWhere } from './nodes/DocumentView'; import { FieldViewProps } from './nodes/FieldView'; +import { KeyValueBox } from './nodes/KeyValueBox'; import { SliderBox } from './nodes/SliderBox'; import './StyleProvider.scss'; import React = require('react'); -import { KeyValueBox } from './nodes/KeyValueBox'; -import { listSpec } from '../../fields/Schema'; -import { AudioField } from '../../fields/URLField'; -import { Tooltip } from '@material-ui/core'; export enum StyleProp { TreeViewIcon = 'treeViewIcon', @@ -279,7 +278,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<DocumentViewProps case StyleProp.BoxShadow: { if (!doc || opacity() === 0 || doc.noShadow) return undefined; // if it's not visible, then no shadow) if (doc.boxShadow === 'standard') return Shadows.STANDARD_SHADOW; - if (doc?.isLinkButton && LinkManager.Links(doc).length && ![DocumentType.LINK, DocumentType.INK].includes(doc.type as any)) return StrCast(doc?._linkButtonShadow, 'lightblue 0em 0em 1em'); + if (IsFollowLinkScript(doc?.onClick) && LinkManager.Links(doc).length && ![DocumentType.LINK, DocumentType.INK].includes(doc.type as any)) return StrCast(doc?._linkButtonShadow, 'lightblue 0em 0em 1em'); switch (doc?.type) { case DocumentType.COL: return StrCast( @@ -296,7 +295,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<DocumentViewProps default: return doc.z ? `#9c9396 ${StrCast(doc?.boxShadow, '10px 10px 0.9vw')}` // if it's a floating doc, give it a big shadow - : props?.ContainingCollectionDoc?._useClusters + : props?.docViewPath().lastElement()?.rootDoc._useClusters ? `${backgroundCol()} ${StrCast(doc.boxShadow, `0vw 0vw ${(lockedPosition() ? 100 : 50) / (docProps?.NativeDimScaling?.() || 1)}px`)}` // if it's just in a cluster, make the shadown roughly match the cluster border extent : NumCast(doc.group, -1) !== -1 ? `gray ${StrCast(doc.boxShadow, `0vw 0vw ${(lockedPosition() ? 100 : 50) / (docProps?.NativeDimScaling?.() || 1)}px`)}` // if it's just in a cluster, make the shadown roughly match the cluster border extent @@ -317,7 +316,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<DocumentViewProps return undefined; // fixes problem with tree view elements getting pointer events when the tree view is not active case StyleProp.Decorations: const lock = () => { - if (props?.ContainingCollectionDoc?._viewType === CollectionViewType.Freeform) { + if (props?.docViewPath().lastElement()?.rootDoc?._viewType === CollectionViewType.Freeform) { return doc?.pointerEvents !== 'none' ? null : ( <div className="styleProvider-lock" onClick={() => toggleLockedPosition(doc)}> <FontAwesomeIcon icon={'lock'} style={{ color: 'red' }} size="lg" /> @@ -384,10 +383,7 @@ export function DashboardStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps return doc._viewType === CollectionViewType.Docking ? null : ( <> {DashboardToggleButton(doc, 'hidden', 'eye-slash', 'eye', () => { - doc.hidden = doc.hidden ? undefined : true; - if (!doc.hidden) { - DocFocusOrOpen(doc, props?.ContainingCollectionDoc); - } + DocFocusOrOpen(doc, { toggleTarget: true, willZoomCentered: true, zoomScale: 0 }, DocCast(doc?.context ?? doc?.annotationOn)); })} </> ); diff --git a/src/client/views/TemplateMenu.tsx b/src/client/views/TemplateMenu.tsx index 45db240a9..1c816aa05 100644 --- a/src/client/views/TemplateMenu.tsx +++ b/src/client/views/TemplateMenu.tsx @@ -113,9 +113,6 @@ export class TemplateMenu extends React.Component<TemplateMenuProps> { {templateMenu} <CollectionTreeView Document={Doc.MyTemplates} - CollectionView={undefined} - ContainingCollectionDoc={undefined} - ContainingCollectionView={undefined} styleProvider={DefaultStyleProvider} setHeight={returnFalse} docViewPath={returnEmptyDoclist} diff --git a/src/client/views/collections/CollectionCarouselView.tsx b/src/client/views/collections/CollectionCarouselView.tsx index c0220f804..0e4556eb4 100644 --- a/src/client/views/collections/CollectionCarouselView.tsx +++ b/src/client/views/collections/CollectionCarouselView.tsx @@ -62,7 +62,6 @@ export class CollectionCarouselView extends CollectionSubView() { onClick={this.onContentClick} hideCaptions={showCaptions ? true : false} renderDepth={this.props.renderDepth + 1} - ContainingCollectionView={this.props.CollectionView} LayoutTemplate={this.props.childLayoutTemplate} LayoutTemplateString={this.props.childLayoutString} Document={curDoc.layout} diff --git a/src/client/views/collections/CollectionMenu.tsx b/src/client/views/collections/CollectionMenu.tsx index c83f4e689..2154016bd 100644 --- a/src/client/views/collections/CollectionMenu.tsx +++ b/src/client/views/collections/CollectionMenu.tsx @@ -120,7 +120,6 @@ export class CollectionMenu extends AntimodeMenu<CollectionMenuProps> { isSelected={returnFalse} docViewPath={returnEmptyDoclist} moveDocument={returnFalse} - CollectionView={undefined} addDocument={returnFalse} addDocTab={returnFalse} pinToPres={emptyFunction} @@ -134,8 +133,6 @@ export class CollectionMenu extends AntimodeMenu<CollectionMenuProps> { docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} /> </div> ); @@ -246,23 +243,6 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewMenu immediate: undoBatch((source: Doc[]) => {}), initialize: emptyFunction, }; - _openLinkInCommand = { - params: ['target', 'container'], - title: 'link follow target', - script: `{ if (self.container?.length) { - getProto(self.target).linkContainer = self.container[0]; - getProto(self.target).isLinkButton = true; - getProto(self.target).onClick = makeScript("getProto(self.linkContainer).data = new List([self.links[0]?.anchor2])"); - }}`, - immediate: undoBatch((container: Doc[]) => { - if (container.length) { - Doc.GetProto(this.target).linkContainer = container[0]; - Doc.GetProto(this.target).isLinkButton = true; - Doc.GetProto(this.target).onClick = ScriptField.MakeScript('getProto(self.linkContainer).data = new List([self.links[0]?.anchor2])'); - } - }), - initialize: emptyFunction, - }; _viewCommand = { params: ['target'], title: 'bookmark view', @@ -328,7 +308,7 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewMenu return Doc.noviceMode ? undefined : [this._templateCommand, this._narrativeCommand]; } @computed get _doc_commands() { - return Doc.noviceMode ? undefined : [this._openLinkInCommand, this._onClickCommand]; + return Doc.noviceMode ? undefined : [this._onClickCommand]; } @computed get _tree_commands() { return undefined; @@ -659,8 +639,8 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewMenu key="float" style={{ backgroundColor: this.props.docView.layoutDoc.z ? '121212' : undefined, - pointerEvents: this.props.docView.props.ContainingCollectionDoc?._viewType !== CollectionViewType.Freeform ? 'none' : undefined, - color: this.props.docView.props.ContainingCollectionDoc?._viewType !== CollectionViewType.Freeform ? 'dimgrey' : undefined, + pointerEvents: this.props.docView.props.docViewPath().lastElement()?.rootDoc?._viewType !== CollectionViewType.Freeform ? 'none' : undefined, + color: this.props.docView.props.docViewPath().lastElement()?.rootDoc?._viewType !== CollectionViewType.Freeform ? 'dimgrey' : undefined, }} onClick={undoBatch(() => this.props.docView.props.CollectionFreeFormDocumentView?.().float())}> <FontAwesomeIcon icon={['fab', 'buffer']} size={'lg'} /> @@ -1349,7 +1329,7 @@ export class Collection3DCarouselViewChrome extends React.Component<CollectionVi return ( <div className="collection3DCarouselViewChrome-cont"> <div className="collection3DCarouselViewChrome-scrollSpeed-cont"> - {FormattedTextBox.Focused ? <RichTextMenu /> : null} + {/* {FormattedTextBox.Focused ? <RichTextMenu /> : null} */} <div className="collectionStackingViewChrome-scrollSpeed-label">AUTOSCROLL SPEED:</div> <div className="collection3DCarouselViewChrome-scrollSpeed"> <EditableView GetValue={() => StrCast(this.scrollSpeed)} oneLine SetValue={this.setValue} contents={this.scrollSpeed ? this.scrollSpeed : 1000} /> diff --git a/src/client/views/collections/CollectionNoteTakingView.tsx b/src/client/views/collections/CollectionNoteTakingView.tsx index cb5be990d..99d4d0bee 100644 --- a/src/client/views/collections/CollectionNoteTakingView.tsx +++ b/src/client/views/collections/CollectionNoteTakingView.tsx @@ -162,7 +162,7 @@ export class CollectionNoteTakingView extends CollectionSubView() { } @action - moveDocument = (doc: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean): boolean => { + moveDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (doc: Doc | Doc[], annotationKey?: string) => boolean, annotationKey?: string): boolean => { return this.props.removeDocument?.(doc) && addDocument?.(doc) ? true : false; }; @@ -215,7 +215,7 @@ export class CollectionNoteTakingView extends CollectionSubView() { blockPointerEventsWhenDragging = () => (this.docsDraggedRowCol.length ? 'none' : undefined); // getDisplayDoc returns the rules for displaying a document in this view (ie. DocumentView) getDisplayDoc(doc: Doc, width: () => number) { - const dataDoc = !doc.isTemplateDoc && !doc.isTemplateForField && !doc.PARAMS ? undefined : this.props.DataDoc; + const dataDoc = !doc.isTemplateDoc && !doc.isTemplateForField ? undefined : this.props.DataDoc; const height = () => this.getDocHeight(doc); let dref: Opt<DocumentView>; const noteTakingDocTransform = () => this.getDocTransform(doc, dref); @@ -255,8 +255,6 @@ export class CollectionNoteTakingView extends CollectionSubView() { hideTitle={this.props.childHideTitle?.()} docRangeFilters={this.childDocRangeFilters} searchFilterDocs={this.searchFilterDocs} - ContainingCollectionDoc={this.props.CollectionView?.props.Document} - ContainingCollectionView={this.props.CollectionView} addDocument={this.props.addDocument} moveDocument={this.props.moveDocument} removeDocument={this.props.removeDocument} @@ -293,7 +291,7 @@ export class CollectionNoteTakingView extends CollectionSubView() { getDocHeight(d?: Doc) { if (!d || d.hidden) return 0; const childLayoutDoc = Doc.Layout(d, this.props.childLayoutTemplate?.()); - const childDataDoc = !d.isTemplateDoc && !d.isTemplateForField && !d.PARAMS ? undefined : this.props.DataDoc; + const childDataDoc = !d.isTemplateDoc && !d.isTemplateForField ? undefined : this.props.DataDoc; const maxHeight = (lim => (lim === 0 ? this.props.PanelWidth() : lim === -1 ? 10000 : lim))(NumCast(this.layoutDoc.childLimitHeight, -1)); const nw = Doc.NativeWidth(childLayoutDoc, childDataDoc) || (!(childLayoutDoc._fitWidth || this.props.childFitWidth?.(d)) ? d[WidthSym]() : 0); const nh = Doc.NativeHeight(childLayoutDoc, childDataDoc) || (!(childLayoutDoc._fitWidth || this.props.childFitWidth?.(d)) ? d[HeightSym]() : 0); @@ -632,8 +630,6 @@ export class CollectionNoteTakingView extends CollectionSubView() { docFilters={this.props.docFilters} docRangeFilters={this.props.docRangeFilters} searchFilterDocs={this.props.searchFilterDocs} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} /> </div> ); diff --git a/src/client/views/collections/CollectionPileView.tsx b/src/client/views/collections/CollectionPileView.tsx index ba90ed8cd..fd9b0c0ce 100644 --- a/src/client/views/collections/CollectionPileView.tsx +++ b/src/client/views/collections/CollectionPileView.tsx @@ -30,7 +30,7 @@ export class CollectionPileView extends CollectionSubView() { // pileups are designed to go away when they are empty. this._disposers.selected = reaction( () => this.childDocs.length, - num => !num && this.props.ContainingCollectionView?.removeDocument(this.props.Document) + num => !num && this.props.CollectionFreeFormDocumentView?.().props.removeDocument?.(this.props.Document) ); } componentWillUnmount() { diff --git a/src/client/views/collections/CollectionStackedTimeline.tsx b/src/client/views/collections/CollectionStackedTimeline.tsx index 642d29d2a..22a575989 100644 --- a/src/client/views/collections/CollectionStackedTimeline.tsx +++ b/src/client/views/collections/CollectionStackedTimeline.tsx @@ -7,14 +7,14 @@ import { Id } from '../../../fields/FieldSymbols'; import { List } from '../../../fields/List'; import { listSpec } from '../../../fields/Schema'; import { ComputedField, ScriptField } from '../../../fields/ScriptField'; -import { Cast, NumCast } from '../../../fields/Types'; +import { Cast, NumCast, ScriptCast } from '../../../fields/Types'; import { ImageField } from '../../../fields/URLField'; import { emptyFunction, formatTime, returnEmptyDoclist, returnEmptyFilter, returnFalse, returnNone, returnTrue, returnZero, setupMoveUpEvents, smoothScrollHorizontal, StopEvent } from '../../../Utils'; import { Docs } from '../../documents/Documents'; import { DocumentType } from '../../documents/DocumentTypes'; import { DocumentManager } from '../../util/DocumentManager'; import { DragManager } from '../../util/DragManager'; -import { LinkFollower } from '../../util/LinkFollower'; +import { FollowLinkScript, IsFollowLinkScript, LinkFollower } from '../../util/LinkFollower'; import { LinkManager } from '../../util/LinkManager'; import { ScriptingGlobals } from '../../util/ScriptingGlobals'; import { SelectionManager } from '../../util/SelectionManager'; @@ -406,7 +406,7 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack backgroundColor: 'rgba(128, 128, 128, 0.5)', useLinkSmallAnchor: true, hideLinkButton: true, - _isLinkButton: true, + onClick: FollowLinkScript(), annotationOn: rootDoc, _timelineLabel: true, borderRounding: anchorEndTime === undefined ? '100%' : undefined, @@ -450,7 +450,7 @@ export class CollectionStackedTimeline extends CollectionSubView<CollectionStack @action clickAnchor = (anchorDoc: Doc, clientX: number) => { - if (anchorDoc.isLinkButton) { + if (IsFollowLinkScript(anchorDoc.onClick)) { LinkFollower.FollowLink(undefined, anchorDoc, false); } const seekTimeInSeconds = this.anchorStart(anchorDoc) - 0.05; @@ -815,8 +815,6 @@ class StackedTimelineAnchor extends React.Component<StackedTimelineAnchorProps> whenChildContentsActiveChanged={emptyFunction} focus={focusFunc} isContentActive={returnFalse} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} searchFilterDocs={returnEmptyDoclist} docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} diff --git a/src/client/views/collections/CollectionStackingView.tsx b/src/client/views/collections/CollectionStackingView.tsx index 1e02fc9d4..bdad325d5 100644 --- a/src/client/views/collections/CollectionStackingView.tsx +++ b/src/client/views/collections/CollectionStackingView.tsx @@ -230,7 +230,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection isAnyChildContentActive = () => this.props.isAnyChildContentActive(); @action - moveDocument = (doc: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean): boolean => { + moveDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (doc: Doc | Doc[]) => boolean): boolean => { return this.props.removeDocument?.(doc) && addDocument?.(doc) ? true : false; }; createRef = (ele: HTMLDivElement | null) => { @@ -305,7 +305,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection // this is what renders the document that you see on the screen // called in Children: this actually adds a document to our children list getDisplayDoc(doc: Doc, width: () => number, count: number) { - const dataDoc = !doc.isTemplateDoc && !doc.isTemplateForField && !doc.PARAMS ? undefined : this.props.DataDoc; + const dataDoc = !doc.isTemplateDoc && !doc.isTemplateForField ? undefined : this.props.DataDoc; const height = () => this.getDocHeight(doc); let dref: Opt<DocumentView>; @@ -323,7 +323,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection styleProvider={this.styleProvider} docViewPath={this.props.docViewPath} fitWidth={this.props.childFitWidth} - isContentActive={doc.isLinkButton ? this.isChildButtonContentActive : this.isChildContentActive} + isContentActive={doc.onClick ? this.isChildButtonContentActive : this.isChildContentActive} onKey={this.onKeyDown} onBrowseClick={this.props.onBrowseClick} isDocumentActive={this.isContentActive} @@ -346,8 +346,6 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection hideTitle={this.props.childHideTitle?.()} docRangeFilters={this.childDocRangeFilters} searchFilterDocs={this.searchFilterDocs} - ContainingCollectionDoc={this.props.CollectionView?.props.Document} - ContainingCollectionView={this.props.CollectionView} xPadding={NumCast(this.layoutDoc._childXPadding, this.props.childXPadding)} yPadding={NumCast(this.layoutDoc._childYPadding, this.props.childYPadding)} addDocument={this.props.addDocument} @@ -381,7 +379,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection getDocHeight(d?: Doc) { if (!d || d.hidden) return 0; const childLayoutDoc = Doc.Layout(d, this.props.childLayoutTemplate?.()); - const childDataDoc = !d.isTemplateDoc && !d.isTemplateForField && !d.PARAMS ? undefined : this.props.DataDoc; + const childDataDoc = !d.isTemplateDoc && !d.isTemplateForField ? undefined : this.props.DataDoc; const maxHeight = (lim => (lim === 0 ? this.props.PanelWidth() : lim === -1 ? 10000 : lim))(NumCast(this.layoutDoc.childLimitHeight, -1)); const nw = Doc.NativeWidth(childLayoutDoc, childDataDoc) || (!(childLayoutDoc._fitWidth || this.props.childFitWidth?.(d)) ? d[WidthSym]() : 0); const nh = Doc.NativeHeight(childLayoutDoc, childDataDoc) || (!(childLayoutDoc._fitWidth || this.props.childFitWidth?.(d)) ? d[HeightSym]() : 0); @@ -678,8 +676,6 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection docFilters={this.props.docFilters} docRangeFilters={this.props.docRangeFilters} searchFilterDocs={this.props.searchFilterDocs} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} /> </div> ); diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index 132ed6fb6..5581ac8fe 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -19,7 +19,6 @@ import { DocComponent } from '../DocComponent'; import React = require('react'); export interface SubCollectionViewProps extends CollectionViewProps { - CollectionView: Opt<CollectionView>; isAnyChildContentActive: () => boolean; } @@ -201,8 +200,9 @@ export function CollectionSubView<X>(moreProps?: X) { } } - addDocument = (doc: Doc | Doc[]) => this.props.addDocument?.(doc) || false; - + addDocument = (doc: Doc | Doc[], annotationKey?: string) => this.props.addDocument?.(doc, annotationKey) || false; + removeDocument = (doc: Doc | Doc[], annotationKey?: string) => this.props.removeDocument?.(doc, annotationKey) || false; + moveDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (doc: Doc | Doc[], annotationKey?: string) => boolean, annotationKey?: string) => this.props.moveDocument?.(doc, targetCollection, addDocument); @action protected onInternalDrop(e: Event, de: DragManager.DropEvent): boolean { const docDragData = de.complete.docDragData; diff --git a/src/client/views/collections/CollectionTreeView.tsx b/src/client/views/collections/CollectionTreeView.tsx index 4a11e8f0b..f81c17a7b 100644 --- a/src/client/views/collections/CollectionTreeView.tsx +++ b/src/client/views/collections/CollectionTreeView.tsx @@ -164,7 +164,7 @@ export class CollectionTreeView extends CollectionSubView<Partial<collectionTree this.props.removeDocument?.(doc); if (ind > 0) { FormattedTextBox.SelectOnLoad = prev[Id]; - DocumentManager.Instance.getDocumentView(prev, this.props.CollectionView)?.select(false); + DocumentManager.Instance.getDocumentView(prev, this.props.DocumentView?.())?.select(false); } return true; } @@ -242,8 +242,6 @@ export class CollectionTreeView extends CollectionSubView<Partial<collectionTree docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionDoc={this.doc} - ContainingCollectionView={this.props.CollectionView} addDocument={returnFalse} moveDocument={returnFalse} removeDocument={returnFalse} @@ -272,7 +270,7 @@ export class CollectionTreeView extends CollectionSubView<Partial<collectionTree this, this.doc, this.props.DataDoc, - this.props.ContainingCollectionDoc, + undefined, undefined, addDoc, this.remove, @@ -331,10 +329,10 @@ export class CollectionTreeView extends CollectionSubView<Partial<collectionTree isDocumentActive={returnTrue} addDocument={this.props.addDocument} moveDocument={this.props.moveDocument} + removeDocument={this.props.removeDocument} addDocTab={this.props.addDocTab} pinToPres={emptyFunction} rootSelected={this.props.isSelected} - removeDocument={this.props.removeDocument} ScreenToLocalTransform={Transform.Identity} PanelWidth={this.return35} PanelHeight={this.return35} @@ -347,8 +345,6 @@ export class CollectionTreeView extends CollectionSubView<Partial<collectionTree docFilters={this.props.docFilters} docRangeFilters={this.props.docRangeFilters} searchFilterDocs={this.props.searchFilterDocs} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} /> </div> ); @@ -378,10 +374,10 @@ export class CollectionTreeView extends CollectionSubView<Partial<collectionTree onChildClick = () => this.props.onChildClick?.() || ScriptCast(this.doc.onChildClick); panelWidth = () => Math.max(0, this.props.PanelWidth() - 2 * this.marginX() * (this.props.NativeDimScaling?.() || 1)); - addAnnotationDocument = (doc: Doc | Doc[]) => this.props.CollectionView?.addDocument(doc, `${this.props.fieldKey}-annotations`) || false; - remAnnotationDocument = (doc: Doc | Doc[]) => this.props.CollectionView?.removeDocument(doc, `${this.props.fieldKey}-annotations`) || false; + addAnnotationDocument = (doc: Doc | Doc[]) => this.addDocument(doc, `${this.props.fieldKey}-annotations`) || false; + remAnnotationDocument = (doc: Doc | Doc[]) => this.removeDocument(doc, `${this.props.fieldKey}-annotations`) || false; moveAnnotationDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (document: Doc | Doc[], annotationKey?: string) => boolean) => - this.props.CollectionView?.moveDocument(doc, targetCollection, addDocument, `${this.props.fieldKey}-annotations`) || false; + this.moveDocument(doc, targetCollection, addDocument, `${this.props.fieldKey}-annotations`) || false; @observable _headerHeight = 0; @computed get content() { diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index bc25ad43a..790aa765d 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -99,18 +99,6 @@ export class CollectionView extends ViewBoxAnnotatableComponent<ViewBoxAnnotatab return viewField as any as CollectionViewType; } - showIsTagged = () => { - return null; - // this section would display an icon in the bototm right of a collection to indicate that all - // photos had been processed through Google's content analysis API and Google's tags had been - // assigned to the documents googlePhotosTags field. - // const children = DocListCast(this.rootDoc[this.props.fieldKey]); - // const imageProtos = children.filter(doc => Cast(doc.data, ImageField)).map(Doc.GetProto); - // const allTagged = imageProtos.length > 0 && imageProtos.every(image => image.googlePhotosTags); - // return !allTagged ? (null) : <img id={"google-tags"} src={"/assets/google_tags.png"} />; - //this.isContentActive(); - }; - screenToLocalTransform = () => (this.props.renderDepth ? this.props.ScreenToLocalTransform() : this.props.ScreenToLocalTransform().scale(this.props.PanelWidth() / this.bodyPanelWidth())); // prettier-ignore private renderSubView = (type: CollectionViewType | undefined, props: SubCollectionViewProps) => { @@ -138,26 +126,25 @@ export class CollectionView extends ViewBoxAnnotatableComponent<ViewBoxAnnotatab } }; - setupViewTypes(category: string, func: (viewType: CollectionViewType) => Doc, addExtras: boolean) { - const subItems: ContextMenuProps[] = []; - subItems.push({ description: 'Freeform', event: () => func(CollectionViewType.Freeform), icon: 'signature' }); - if (addExtras && CollectionView._safeMode) { - ContextMenu.Instance.addItem({ description: 'Test Freeform', event: () => func(CollectionViewType.Invalid), icon: 'project-diagram' }); - } - subItems.push({ description: 'Schema', event: () => func(CollectionViewType.Schema), icon: 'th-list' }); - subItems.push({ description: 'Tree', event: () => func(CollectionViewType.Tree), icon: 'tree' }); - subItems.push({ description: 'Stacking', event: () => (func(CollectionViewType.Stacking)._autoHeight = true), icon: 'ellipsis-v' }); - subItems.push({ description: 'Notetaking', event: () => (func(CollectionViewType.NoteTaking)._autoHeight = true), icon: 'ellipsis-v' }); - subItems.push({ description: 'Multicolumn', event: () => func(CollectionViewType.Multicolumn), icon: 'columns' }); - subItems.push({ description: 'Multirow', event: () => func(CollectionViewType.Multirow), icon: 'columns' }); - subItems.push({ description: 'Masonry', event: () => func(CollectionViewType.Masonry), icon: 'columns' }); - subItems.push({ description: 'Carousel', event: () => func(CollectionViewType.Carousel), icon: 'columns' }); - subItems.push({ description: '3D Carousel', event: () => func(CollectionViewType.Carousel3D), icon: 'columns' }); - !Doc.noviceMode && subItems.push({ description: 'Pivot/Time', event: () => func(CollectionViewType.Time), icon: 'columns' }); - !Doc.noviceMode && subItems.push({ description: 'Map', event: () => func(CollectionViewType.Map), icon: 'globe-americas' }); - subItems.push({ description: 'Grid', event: () => func(CollectionViewType.Grid), icon: 'th-list' }); - + setupViewTypes(category: string, func: (viewType: CollectionViewType) => Doc) { if (!Doc.IsSystem(this.rootDoc) && this.rootDoc._viewType !== CollectionViewType.Docking && !this.rootDoc.isGroup && !this.rootDoc.annotationOn) { + // prettier-ignore + const subItems: ContextMenuProps[] = [ + { description: 'Freeform', event: () => func(CollectionViewType.Freeform), icon: 'signature' }, + { description: 'Schema', event: () => func(CollectionViewType.Schema), icon: 'th-list' }, + { description: 'Tree', event: () => func(CollectionViewType.Tree), icon: 'tree' }, + { description: 'Stacking', event: () => (func(CollectionViewType.Stacking)._autoHeight = true), icon: 'ellipsis-v' }, + { description: 'Notetaking', event: () => (func(CollectionViewType.NoteTaking)._autoHeight = true), icon: 'ellipsis-v' }, + { description: 'Multicolumn', event: () => func(CollectionViewType.Multicolumn), icon: 'columns' }, + { description: 'Multirow', event: () => func(CollectionViewType.Multirow), icon: 'columns' }, + { description: 'Masonry', event: () => func(CollectionViewType.Masonry), icon: 'columns' }, + { description: 'Carousel', event: () => func(CollectionViewType.Carousel), icon: 'columns' }, + { description: '3D Carousel', event: () => func(CollectionViewType.Carousel3D), icon: 'columns' }, + { description: 'Pivot/Time', event: () => func(CollectionViewType.Time), icon: 'columns' }, + { description: 'Map', event: () => func(CollectionViewType.Map), icon: 'globe-americas' }, + { description: 'Grid', event: () => func(CollectionViewType.Grid), icon: 'th-list' }, + ]; + const existingVm = ContextMenu.Instance.findByDescription(category); const catItems = existingVm && 'subitems' in existingVm ? existingVm.subitems : []; catItems.push({ description: 'Add a Perspective...', addDivider: true, noexpand: true, subitems: subItems, icon: 'eye' }); @@ -169,16 +156,13 @@ export class CollectionView extends ViewBoxAnnotatableComponent<ViewBoxAnnotatab const cm = ContextMenu.Instance; if (cm && !e.isPropagationStopped()) { // need to test this because GoldenLayout causes a parallel hierarchy in the React DOM for its children and the main document view7 - this.setupViewTypes( - 'UI Controls...', - vtype => { + !Doc.noviceMode && + this.setupViewTypes('UI Controls...', vtype => { const newRendition = Doc.MakeAlias(this.rootDoc); newRendition._viewType = vtype; this.props.addDocTab(newRendition, OpenWhere.addRight); return newRendition; - }, - false - ); + }); const options = cm.findByDescription('Options...'); const optionItems = options && 'subitems' in options ? options.subitems : []; @@ -191,24 +175,6 @@ export class CollectionView extends ViewBoxAnnotatableComponent<ViewBoxAnnotatab } !Doc.noviceMode && optionItems.push({ description: `${this.rootDoc._isLightbox ? 'Unset' : 'Set'} is Lightbox`, event: () => (this.rootDoc._isLightbox = !this.rootDoc._isLightbox), icon: 'project-diagram' }); - // if (!Doc.noviceMode && false) { - // optionItems.push({ - // description: 'Create Branch', - // event: async () => this.props.addDocTab(await BranchCreate(this.rootDoc), OpenWhere.addRight), - // icon: 'project-diagram', - // }); - // optionItems.push({ - // description: 'Pull Master', - // event: () => BranchTask(this.rootDoc, 'pull'), - // icon: 'project-diagram', - // }); - // optionItems.push({ - // description: 'Merge Branches', - // event: () => BranchTask(this.rootDoc, 'merge'), - // icon: 'project-diagram', - // }); - // } - !options && cm.addItem({ description: 'Options...', subitems: optionItems, icon: 'hand-point-right' }); if (!Doc.noviceMode && !this.rootDoc.annotationOn) { @@ -253,10 +219,6 @@ export class CollectionView extends ViewBoxAnnotatableComponent<ViewBoxAnnotatab childHideResizeHandles = () => this.props.childHideResizeHandles?.() ?? BoolCast(this.Document.childHideResizeHandles); childHideDecorationTitle = () => this.props.childHideDecorationTitle?.() ?? BoolCast(this.Document.childHideDecorationTitle); childLayoutTemplate = () => this.props.childLayoutTemplate?.() || Cast(this.rootDoc.childLayoutTemplate, Doc, null); - @computed get childLayoutString() { - return StrCast(this.rootDoc.childLayoutString, this.props.childLayoutString); - } - isContentActive = (outsideReaction?: boolean) => this.props.isContentActive() || this.isAnyChildContentActive(); render() { @@ -273,14 +235,15 @@ export class CollectionView extends ViewBoxAnnotatableComponent<ViewBoxAnnotatab PanelHeight: this.props.PanelHeight, ScreenToLocalTransform: this.screenToLocalTransform, childLayoutTemplate: this.childLayoutTemplate, - childLayoutString: this.childLayoutString, + childLayoutString: StrCast(this.rootDoc.childLayoutString, this.props.childLayoutString), childHideResizeHandles: this.childHideResizeHandles, childHideDecorationTitle: this.childHideDecorationTitle, - CollectionView: this, }; return ( - <div className={'collectionView'} onContextMenu={this.onContextMenu} style={{ pointerEvents: this.props.ContainingCollectionDoc?._viewType === CollectionViewType.Freeform && this.rootDoc._lockedPosition ? 'none' : undefined }}> - {this.showIsTagged()} + <div + className="collectionView" + onContextMenu={this.onContextMenu} + style={{ pointerEvents: this.props.DocumentView?.()?.props.docViewPath().lastElement()?.rootDoc?._viewType === CollectionViewType.Freeform && this.rootDoc._lockedPosition ? 'none' : undefined }}> {this.renderSubView(this.collectionViewType, props)} </div> ); diff --git a/src/client/views/collections/TabDocView.tsx b/src/client/views/collections/TabDocView.tsx index 4ad09628f..4bbc3bb44 100644 --- a/src/client/views/collections/TabDocView.tsx +++ b/src/client/views/collections/TabDocView.tsx @@ -425,9 +425,8 @@ export class TabDocView extends React.Component<TabDocViewProps> { hideTitle={this.props.keyValue} Document={this._document} DataDoc={!Doc.AreProtosEqual(this._document[DataSym], this._document) ? this._document[DataSym] : undefined} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} onBrowseClick={MainView.Instance.exploreMode} + waitForDoubleClickToClick={MainView.Instance.waitForDoubleClick} isContentActive={returnTrue} isDocumentActive={returnFalse} PanelWidth={this.PanelWidth} @@ -578,9 +577,6 @@ export class TabMinimapView extends React.Component<TabMinimapViewProps> { <div className="miniMap" style={{ width: miniSize, height: miniSize, background: this.props.background() }}> <CollectionFreeFormView Document={this.props.document} - CollectionView={undefined} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} docViewPath={returnEmptyDoclist} childLayoutTemplate={this.childLayoutTemplate} // bcz: Ugh .. should probably be rendering a CollectionView or the minimap should be part of the collectionFreeFormView to avoid having to set stuff like this. noOverlay={true} // don't render overlay Docs since they won't scale @@ -602,7 +598,7 @@ export class TabMinimapView extends React.Component<TabMinimapViewProps> { ScreenToLocalTransform={Transform.Identity} renderDepth={0} whenChildContentsActiveChanged={emptyFunction} - focus={DocUtils.DefaultFocus} + focus={emptyFunction} styleProvider={TabMinimapView.miniStyleProvider} addDocTab={this.props.addDocTab} pinToPres={TabDocView.PinDoc} diff --git a/src/client/views/collections/TreeView.tsx b/src/client/views/collections/TreeView.tsx index 75e76019e..8fb610b87 100644 --- a/src/client/views/collections/TreeView.tsx +++ b/src/client/views/collections/TreeView.tsx @@ -42,13 +42,13 @@ export interface TreeViewProps { prevSibling?: Doc; document: Doc; dataDoc?: Doc; - containerCollection: Doc; + treeViewParent: Doc; renderDepth: number; dropAction: dropActionType; addDocTab: (doc: Doc, where: OpenWhere) => boolean; panelWidth: () => number; panelHeight: () => number; - addDocument: (doc: Doc | Doc[], relativeTo?: Doc, before?: boolean) => boolean; + addDocument: (doc: Doc | Doc[], annotationKey?: string, relativeTo?: Doc, before?: boolean) => boolean; removeDoc: ((doc: Doc | Doc[]) => boolean) | undefined; moveDocument: DragManager.MoveFunction; isContentActive: (outsideReaction?: boolean) => boolean; @@ -143,7 +143,7 @@ export class TreeView extends React.Component<TreeViewProps> { return this.validExpandViewTypes.includes(StrCast(this.doc.treeViewExpandedView)) ? StrCast(this.doc.treeViewExpandedView) : this.defaultExpandedView; } @computed get MAX_EMBED_HEIGHT() { - return NumCast(this.props.containerCollection.maxEmbedHeight, 200); + return NumCast(this.props.treeViewParent.maxEmbedHeight, 200); } @computed get dataDoc() { return this.props.document.treeViewChildrenOnRoot ? this.doc : this.doc[DataSym]; @@ -194,7 +194,7 @@ export class TreeView extends React.Component<TreeViewProps> { const ind = this.dataDoc[key].indexOf(doc); const res = (doc instanceof Doc ? [doc] : doc).reduce((flg, doc) => flg && Doc.RemoveDocFromList(this.dataDoc, key, doc), true); - res && ind > 0 && DocumentManager.Instance.getDocumentView(this.dataDoc[key][ind - 1], this.props.treeView.props.CollectionView)?.select(false); + res && ind > 0 && DocumentManager.Instance.getDocumentView(this.dataDoc[key][ind - 1], this.props.treeView.props.DocumentView?.())?.select(false); return res; }; @@ -373,7 +373,7 @@ export class TreeView extends React.Component<TreeViewProps> { dropping: boolean = false; dropDocuments(droppedDocuments: Doc[], before: boolean, inside: number | boolean, dropAction: dropActionType, removeDocument: DragManager.RemoveFunction | undefined, moveDocument: DragManager.MoveFunction | undefined, forceAdd: boolean) { - const parentAddDoc = (doc: Doc | Doc[]) => this.props.addDocument(doc, undefined, before); + const parentAddDoc = (doc: Doc | Doc[]) => this.props.addDocument(doc, undefined, undefined, before); const localAdd = (doc: Doc | Doc[]) => { const innerAdd = (doc: Doc) => { const dataIsComputed = ComputedField.WithoutComputed(() => FieldValue(this.dataDoc[this.fieldKey])) instanceof ComputedField; @@ -385,7 +385,7 @@ export class TreeView extends React.Component<TreeViewProps> { }; const addDoc = inside ? localAdd : parentAddDoc; const move = (!dropAction || dropAction === 'proto' || dropAction === 'move' || dropAction === 'same') && moveDocument; - const canAdd = (!this.props.treeView.outlineMode && !StrCast((inside ? this.props.document : this.props.containerCollection)?.freezeChildren).includes('add')) || forceAdd; + const canAdd = (!this.props.treeView.outlineMode && !StrCast((inside ? this.props.document : this.props.treeViewParent)?.freezeChildren).includes('add')) || forceAdd; if (canAdd) { this.props.parentTreeView instanceof TreeView && (this.props.parentTreeView.dropping = true); const res = UndoManager.RunInTempBatch(() => droppedDocuments.reduce((added, d) => (move ? move(d, undefined, addDoc) || (dropAction === 'proto' ? addDoc(d) : false) : addDoc(d)) || added, false)); @@ -406,7 +406,7 @@ export class TreeView extends React.Component<TreeViewProps> { getTransform = () => this.refTransform(this._tref.current); embeddedPanelWidth = () => this.props.panelWidth() / (this.props.treeView.props.NativeDimScaling?.() || 1); embeddedPanelHeight = () => { - const layoutDoc = (temp => temp && Doc.expandTemplateLayout(temp, this.props.document, ''))(this.props.treeView.props.childLayoutTemplate?.()) || this.layoutDoc; + const layoutDoc = (temp => temp && Doc.expandTemplateLayout(temp, this.props.document))(this.props.treeView.props.childLayoutTemplate?.()) || this.layoutDoc; return Math.min( layoutDoc[HeightSym](), this.MAX_EMBED_HEIGHT, @@ -416,7 +416,7 @@ export class TreeView extends React.Component<TreeViewProps> { return layoutDoc._fitWidth ? !Doc.NativeHeight(layoutDoc) ? NumCast(layoutDoc._height) - : Math.min((this.embeddedPanelWidth() * NumCast(layoutDoc.scrollHeight, Doc.NativeHeight(layoutDoc))) / (Doc.NativeWidth(layoutDoc) || NumCast(this.props.containerCollection._height))) + : Math.min((this.embeddedPanelWidth() * NumCast(layoutDoc.scrollHeight, Doc.NativeHeight(layoutDoc))) / (Doc.NativeWidth(layoutDoc) || NumCast(this.props.treeViewParent._height))) : (this.embeddedPanelWidth() * layoutDoc[HeightSym]()) / layoutDoc[WidthSym](); })() ); @@ -452,7 +452,7 @@ export class TreeView extends React.Component<TreeViewProps> { this, doc, undefined, - this.props.containerCollection, + this.props.treeViewParent, this.props.prevSibling, addDoc, remDoc, @@ -596,7 +596,7 @@ export class TreeView extends React.Component<TreeViewProps> { this, this.layoutDoc, this.dataDoc, - this.props.containerCollection, + this.props.treeViewParent, this.props.prevSibling, addDoc, remDoc, @@ -656,7 +656,7 @@ export class TreeView extends React.Component<TreeViewProps> { this.onCheckedClick?.script.run( { this: this.doc.isTemplateForField && this.props.dataDoc ? this.props.dataDoc : this.doc, - heading: this.props.containerCollection.title, + heading: this.props.treeViewParent.title, checked: this.doc.treeViewChecked === 'check' ? 'x' : this.doc.treeViewChecked === 'x' ? 'remove' : 'check', containingTreeView: this.props.treeView.props.Document, }, @@ -724,9 +724,11 @@ export class TreeView extends React.Component<TreeViewProps> { }; @observable headerEleWidth = 0; - @computed get headerElements() { + @computed get titleButtons() { + const customHeaderButtons = this.props.styleProvider?.(this.doc, this.props.treeView.props, StyleProp.Decorations); return this.props.treeViewHideHeaderFields() || this.doc.treeViewHideHeaderFields ? null : ( <> + {customHeaderButtons} {/* e.g.,. hide button is set by dashboardStyleProvider */} {this.doc.hideContextMenu ? null : ( <FontAwesomeIcon title="context menu" @@ -779,9 +781,7 @@ export class TreeView extends React.Component<TreeViewProps> { return StrListCast(this.doc.childContextMenuLabels).map((label, i) => ({ script: customScripts[i], filter: customFilters[i], icon: icons[i], label })); }; - onChildClick = () => { - return this.props.onChildClick?.() ?? (this._editTitleScript?.() || ScriptField.MakeFunction(`DocFocusOrOpen(self)`)!); - }; + onChildClick = () => this.props.onChildClick?.() ?? (this._editTitleScript?.() || ScriptField.MakeFunction(`DocFocusOrOpen(self)`)!); onChildDoubleClick = () => ScriptCast(this.props.treeView.Document.treeViewChildDoubleClick, !this.props.treeView.outlineMode ? this._openScript?.() : null); @@ -906,7 +906,7 @@ export class TreeView extends React.Component<TreeViewProps> { styleProvider={this.titleStyleProvider} enableDragWhenActive={true} onClickScriptDisable="never" // tree docViews have a script to show fields, etc. - docViewPath={returnEmptyDoclist} + docViewPath={this.props.treeView.props.docViewPath} treeViewDoc={this.props.treeView.props.Document} addDocument={undefined} addDocTab={this.props.addDocTab} @@ -937,12 +937,8 @@ export class TreeView extends React.Component<TreeViewProps> { docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionView={this.props.treeView.props.CollectionView} - ContainingCollectionDoc={this.props.treeView.props.Document} /> ); - - const buttons = this.props.styleProvider?.(this.doc, { ...this.props.treeView.props, ContainingCollectionDoc: this.props.parentTreeView?.doc }, StyleProp.Decorations + (Doc.IsSystem(this.props.containerCollection) ? ':afterHeader' : '')); return ( <> <div @@ -958,8 +954,7 @@ export class TreeView extends React.Component<TreeViewProps> { {view} </div> <div className="treeView-rightButtons" ref={action((r: any) => r && (this.headerEleWidth = r.getBoundingClientRect().width))}> - {buttons} {/* hide and lock buttons */} - {this.headerElements} + {this.titleButtons} </div> </> ); @@ -1017,8 +1012,6 @@ export class TreeView extends React.Component<TreeViewProps> { docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionDoc={this.props.containerCollection} - ContainingCollectionView={undefined} addDocument={this.props.addDocument} moveDocument={this.move} removeDocument={this.props.removeDoc} @@ -1131,7 +1124,7 @@ export class TreeView extends React.Component<TreeViewProps> { childDocs: Doc[], treeView: CollectionTreeView, parentTreeView: CollectionTreeView | TreeView | undefined, - containerCollection: Doc, + treeViewParent: Doc, dataDoc: Doc | undefined, parentCollectionDoc: Doc | undefined, containerPrevSibling: Doc | undefined, @@ -1162,19 +1155,19 @@ export class TreeView extends React.Component<TreeViewProps> { hierarchyIndex?: number[], renderCount?: number ) { - const viewSpecScript = Cast(containerCollection.viewSpecScript, ScriptField); + const viewSpecScript = Cast(treeViewParent.viewSpecScript, ScriptField); if (viewSpecScript) { childDocs = childDocs.filter(d => viewSpecScript.script.run({ doc: d }, console.log).result); } - const docs = TreeView.sortDocs(childDocs, StrCast(containerCollection.treeViewSortCriterion, TreeSort.None)); + const docs = TreeView.sortDocs(childDocs, StrCast(treeViewParent.treeViewSortCriterion, TreeSort.None)); const rowWidth = () => panelWidth() - treeBulletWidth() * (treeView.props.NativeDimScaling?.() || 1); const treeViewRefs = new Map<Doc, TreeView | undefined>(); return docs .filter(child => child instanceof Doc) .map((child, i) => { if (renderCount && i > renderCount) return null; - const pair = Doc.GetLayoutDataDocPair(containerCollection, dataDoc, child); + const pair = Doc.GetLayoutDataDocPair(treeViewParent, dataDoc, child); if (!pair.layout || pair.data instanceof Promise) { return null; } @@ -1192,11 +1185,8 @@ export class TreeView extends React.Component<TreeViewProps> { } }; const indent = i === 0 ? undefined : (editTitle: boolean) => dentDoc(editTitle, docs[i - 1], undefined, treeViewRefs.get(docs[i - 1])); - const outdent = - parentCollectionDoc?._viewType !== CollectionViewType.Tree - ? undefined - : (editTitle: boolean) => dentDoc(editTitle, parentCollectionDoc, containerPrevSibling, parentTreeView instanceof TreeView ? parentTreeView.props.parentTreeView : undefined); - const addDocument = (doc: Doc | Doc[], relativeTo?: Doc, before?: boolean) => add(doc, relativeTo ?? docs[i], before !== undefined ? before : false); + const outdent = !parentCollectionDoc ? undefined : (editTitle: boolean) => dentDoc(editTitle, parentCollectionDoc, containerPrevSibling, parentTreeView instanceof TreeView ? parentTreeView.props.parentTreeView : undefined); + const addDocument = (doc: Doc | Doc[], annotationKey?: string, relativeTo?: Doc, before?: boolean) => add(doc, relativeTo ?? docs[i], before !== undefined ? before : false); const childLayout = Doc.Layout(pair.layout); const rowHeight = () => { const aspect = Doc.NativeAspect(childLayout); @@ -1208,7 +1198,7 @@ export class TreeView extends React.Component<TreeViewProps> { ref={r => treeViewRefs.set(child, r ? r : undefined)} document={pair.layout} dataDoc={pair.data} - containerCollection={containerCollection} + treeViewParent={treeViewParent} prevSibling={docs[i]} // TODO: [AL] add these hierarchyIndex={hierarchyIndex ? [...hierarchyIndex, i + 1] : undefined} @@ -1220,7 +1210,7 @@ export class TreeView extends React.Component<TreeViewProps> { onCheckedClick={onCheckedClick} onChildClick={onChildClick} renderDepth={renderDepth} - removeDoc={StrCast(containerCollection.freezeChildren).includes('remove') ? undefined : remove} + removeDoc={StrCast(treeViewParent.freezeChildren).includes('remove') ? undefined : remove} addDocument={addDocument} styleProvider={styleProvider} panelWidth={rowWidth} diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx index 7f1e15c2f..0dfd119d7 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx @@ -1,6 +1,6 @@ import { action, computed, IReactionDisposer, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; -import { Doc, Field } from '../../../../fields/Doc'; +import { CssSym, Doc, Field } from '../../../../fields/Doc'; import { Id } from '../../../../fields/FieldSymbols'; import { List } from '../../../../fields/List'; import { Cast, NumCast, StrCast } from '../../../../fields/Types'; @@ -35,10 +35,10 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo () => [ this.props.A.props.ScreenToLocalTransform(), Cast(Cast(Cast(this.props.A.rootDoc, Doc, null)?.anchor1, Doc, null)?.annotationOn, Doc, null)?.scrollTop, - Cast(Cast(Cast(this.props.A.rootDoc, Doc, null)?.anchor1, Doc, null)?.annotationOn, Doc, null)?._highlights, + Cast(Cast(Cast(this.props.A.rootDoc, Doc, null)?.anchor1, Doc, null)?.annotationOn, Doc, null)?.[CssSym], this.props.B.props.ScreenToLocalTransform(), Cast(Cast(Cast(this.props.A.rootDoc, Doc, null)?.anchor2, Doc, null)?.annotationOn, Doc, null)?.scrollTop, - Cast(Cast(Cast(this.props.A.rootDoc, Doc, null)?.anchor2, Doc, null)?.annotationOn, Doc, null)?._highlights, + Cast(Cast(Cast(this.props.A.rootDoc, Doc, null)?.anchor2, Doc, null)?.annotationOn, Doc, null)?.[CssSym], ], action(() => { this._start = Date.now(); diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 9d4a788db..ff0d01f29 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -262,7 +262,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection }; selectDocuments = (docs: Doc[]) => { SelectionManager.DeselectAll(); - docs.map(doc => DocumentManager.Instance.getDocumentView(doc, this.props.CollectionView)).map(dv => dv && SelectionManager.SelectView(dv, true)); + docs.map(doc => DocumentManager.Instance.getDocumentView(doc, this.props.DocumentView?.())).map(dv => dv && SelectionManager.SelectView(dv, true)); }; addDocument = (newBox: Doc | Doc[]) => { let retVal = false; @@ -311,7 +311,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection const xfToCollection = options?.docTransform ?? Transform.Identity(); const savedState = { panX: NumCast(this.Document[this.panXFieldKey]), panY: NumCast(this.Document[this.panYFieldKey]), scale: options?.willZoomCentered ? this.Document[this.scaleFieldKey] : undefined }; const cantTransform = this.fitContentsToBox || ((this.rootDoc._isGroup || this.layoutDoc._lockedTransform) && !LightboxView.LightboxDoc); - const { panX, panY, scale } = cantTransform || (!options.willPan && !options.willZoomCentered) ? savedState : this.calculatePanIntoView(anchor, xfToCollection, options?.willZoomCentered ? options?.zoomScale || 0.75 : undefined); + const { panX, panY, scale } = cantTransform || (!options.willPan && !options.willZoomCentered) ? savedState : this.calculatePanIntoView(anchor, xfToCollection, options?.willZoomCentered ? options?.zoomScale ?? 0.75 : undefined); // focus on the document in the collection const didMove = !cantTransform && !anchor.z && (panX !== savedState.panX || panY !== savedState.panY || scale !== savedState.scale); @@ -319,7 +319,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection // glr: freeform transform speed can be set by adjusting presTransition field - needs a way of knowing when presentation is not active... if (didMove) { const focusTime = options?.instant ? 0 : options.zoomTime ?? 500; - options.zoomScale && scale && (this.Document[this.scaleFieldKey] = scale); + (options.zoomScale ?? options.willZoomCentered) && scale && (this.Document[this.scaleFieldKey] = scale); this.setPan(panX, panY, focusTime, true); // docs that are floating in their collection can't be panned to from their collection -- need to propagate the pan to a parent freeform somehow return focusTime; } @@ -349,6 +349,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection zsorted.forEach((doc, index) => (doc.zIndex = doc.isInkMask ? 5000 : index + 1)); const dvals = CollectionFreeFormDocumentView.getValues(refDoc, NumCast(refDoc.activeFrame, 1000)); const dropPos = this.Document._currentFrame !== undefined ? [NumCast(dvals.x), NumCast(dvals.y)] : [NumCast(refDoc.x), NumCast(refDoc.y)]; + for (let i = 0; i < docDragData.droppedDocuments.length; i++) { const d = docDragData.droppedDocuments[i]; const layoutDoc = Doc.Layout(d); @@ -370,6 +371,28 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection (d._raiseWhenDragged === undefined ? DragManager.GetRaiseWhenDragged() : d._raiseWhenDragged) && (d.zIndex = zsorted.length + 1 + i); // bringToFront } + if (this.layoutDoc._autoArrange || de.metaKey) { + const sorted = this.childLayoutPairs.slice().sort((a, b) => NumCast(a.layout.y) - NumCast(b.layout.y)); + sorted.splice( + sorted.findIndex(pair => pair.layout === refDoc), + 1 + ); + if (sorted.length && refDoc && NumCast(sorted[0].layout.y) < NumCast(refDoc.y)) { + const topIndexed = NumCast(refDoc.y) < NumCast(sorted[0].layout.y) + NumCast(sorted[0].layout._height) / 2; + const deltay = sorted.length > 1 ? NumCast(refDoc.y) - (NumCast(sorted[0].layout.y) + (topIndexed ? 0 : NumCast(sorted[0].layout._height))) : 0; + const deltax = sorted.length > 1 ? NumCast(refDoc.x) - NumCast(sorted[0].layout.x) : 0; + + let lastx = NumCast(refDoc.x); + let lasty = NumCast(refDoc.y) + (topIndexed ? 0 : NumCast(refDoc._height)); + runInAction(() => + sorted.slice(1).forEach((pair, i) => { + lastx = pair.layout.x = lastx + deltax; + lasty = (pair.layout.y = lasty + deltay) + (topIndexed ? 0 : NumCast(pair.layout._height)); + }) + ); + } + } + (docDragData.droppedDocuments.length === 1 || de.shiftKey) && this.updateClusterDocs(docDragData.droppedDocuments); return true; } @@ -439,7 +462,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection const ptsParent = e instanceof PointerEvent ? e : e.targetTouches.item(0); if (ptsParent) { const eles = this.childLayoutPairs.map(pair => pair.layout).filter(cd => (this.props.Document._useClusters ? NumCast(cd.cluster) : NumCast(cd.group, -1)) === cluster); - const clusterDocs = eles.map(ele => DocumentManager.Instance.getDocumentView(ele, this.props.CollectionView)!); + const clusterDocs = eles.map(ele => DocumentManager.Instance.getDocumentView(ele, this.props.DocumentView?.())!); const { left, top } = clusterDocs[0].getBounds() || { left: 0, top: 0 }; const de = new DragManager.DocumentDragData(eles, e.ctrlKey || e.altKey ? 'alias' : undefined); de.moveDocument = this.props.moveDocument; @@ -542,7 +565,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection switch (property) { case StyleProp.BackgroundColor: const cluster = NumCast(doc?.cluster); - if (this.Document._useClusters) { + if (this.Document._useClusters && doc?.type !== DocumentType.IMG) { if (this._clusterSets.length <= cluster) { setTimeout(() => doc && this.updateCluster(doc)); } else { @@ -763,7 +786,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection e.stopPropagation(); e.preventDefault(); } else if (Math.abs(e.pageX - this._downX) < 3 && Math.abs(e.pageY - this._downY) < 3) { - if (e.shiftKey) { + if (e.shiftKey && (this.props.renderDepth === 0 || this.isContentActive())) { if (Date.now() - this._lastTap < 300) { // reset zoom of freeform view to 1-to-1 on a shift + double click this.zoomSmoothlyAboutPt(this.getTransform().transformPoint(e.clientX, e.clientY), 1); @@ -847,7 +870,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection const eraserMax = { X: Math.max(lastPoint.X, currPoint.X), Y: Math.max(lastPoint.Y, currPoint.Y) }; return this.childDocs - .map(doc => DocumentManager.Instance.getDocumentView(doc, this.props.CollectionView)) + .map(doc => DocumentManager.Instance.getDocumentView(doc, this.props.DocumentView?.())) .filter(inkView => inkView?.ComponentView instanceof InkingStroke) .map(inkView => ({ inkViewBounds: inkView!.getBounds(), inkStroke: inkView!.ComponentView as InkingStroke, inkView: inkView! })) .filter( @@ -941,7 +964,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection this.childDocs .filter(doc => doc.type === DocumentType.INK && !doc.dontIntersect) .forEach(doc => { - const otherInk = DocumentManager.Instance.getDocumentView(doc, this.props.CollectionView)?.ComponentView as InkingStroke; + const otherInk = DocumentManager.Instance.getDocumentView(doc, this.props.DocumentView?.())?.ComponentView as InkingStroke; const { inkData: otherInkData } = otherInk?.inkScaledData() ?? { inkData: [] }; const otherScreenPts = otherInkData.map(point => otherInk.ptToScreen(point)); const otherCtrlPts = otherScreenPts.map(spt => (ink.ComponentView as InkingStroke).ptFromScreen(spt)); @@ -1096,7 +1119,8 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection @action nudge = (x: number, y: number, nudgeTime: number = 500) => { - if (this.props.ContainingCollectionDoc?._viewType !== CollectionViewType.Freeform || this.props.ContainingCollectionDoc._panX !== undefined) { + const collectionDoc = this.props.docViewPath().lastElement().rootDoc; + if (collectionDoc?._viewType !== CollectionViewType.Freeform || collectionDoc._panX !== undefined) { this.setPan( NumCast(this.layoutDoc[this.panXFieldKey]) + ((this.props.PanelWidth() / 2) * x) / this.zoomScaling(), // nudge x,y as a function of panel dimension and scale NumCast(this.layoutDoc[this.panYFieldKey]) + ((this.props.PanelHeight() / 2) * -y) / this.zoomScaling(), @@ -1157,9 +1181,12 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection const pt2 = xf.transformPoint(NumCast(doc.x) + layoutdoc[WidthSym](), NumCast(doc.y) + layoutdoc[HeightSym]()); const bounds = { left: pt[0], right: pt2[0], top: pt[1], bot: pt2[1], width: pt2[0] - pt[0], height: pt2[1] - pt[1] }; - if (scale) { + if (scale !== undefined) { const maxZoom = 5; // sets the limit for how far we will zoom. this is useful for preventing small text boxes from filling the screen. So probably needs to be more sophisticated to consider more about the target and context - const newScale = Math.min(maxZoom, (1 / (this.nativeDimScaling || 1)) * scale * Math.min(this.props.PanelWidth() / Math.abs(bounds.width), this.props.PanelHeight() / Math.abs(bounds.height))); + const newScale = + scale === 0 + ? NumCast(this.layoutDoc[this.scaleFieldKey]) + : Math.min(maxZoom, (1 / (this.nativeDimScaling || 1)) * scale * Math.min(this.props.PanelWidth() / Math.abs(bounds.width), this.props.PanelHeight() / Math.abs(bounds.height))); return { panX: this.props.isAnnotationOverlay ? bounds.left - (Doc.NativeWidth(this.layoutDoc) / newScale - bounds.width) / 2 : (bounds.left + bounds.right) / 2, panY: this.props.isAnnotationOverlay ? bounds.top - (Doc.NativeHeight(this.layoutDoc) / newScale - bounds.height) / 2 : (bounds.top + bounds.bot) / 2, @@ -1231,12 +1258,11 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection replica={entry.replica} suppressSetHeight={this.layoutEngine ? true : false} renderCutoffProvider={this.renderCutoffProvider} - ContainingCollectionView={this.props.CollectionView} - ContainingCollectionDoc={this.props.Document} CollectionFreeFormView={this} LayoutTemplate={childLayout.z ? undefined : this.props.childLayoutTemplate} LayoutTemplateString={childLayout.z ? undefined : this.props.childLayoutString} rootSelected={childData ? this.rootSelected : returnFalse} + waitForDoubleClickToClick={this.props.waitForDoubleClickToClick} onClick={this.onChildClickHandler} onKey={this.onKeyDown} onDoubleClick={this.onChildDoubleClickHandler} @@ -1408,24 +1434,6 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection } doFreeformLayout(poolData: Map<string, PoolData>) { - if (this.layoutDoc._autoArrange) { - const sorted = this.childLayoutPairs.slice().sort((a, b) => (NumCast(a.layout.y) < NumCast(b.layout.y) ? -1 : 1)); - if (sorted.length > 1) { - const deltay = sorted.length > 1 ? NumCast(sorted[1].layout.y) - (NumCast(sorted[0].layout.y) + NumCast(sorted[0].layout._height)) : 0; - const deltax = sorted.length > 1 ? NumCast(sorted[1].layout.x) - NumCast(sorted[0].layout.x) : 0; - - let lastx = NumCast(sorted[0].layout.x); - let lasty = NumCast(sorted[0].layout.y) + NumCast(sorted[0].layout._height); - setTimeout( - action(() => - sorted.slice(1).forEach((pair, i) => { - lastx = pair.layout.x = lastx + deltax; - lasty = (pair.layout.y = lasty + deltay) + NumCast(pair.layout._height); - }) - ) - ); - } - } this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map((pair, i) => poolData.set(pair.layout[Id], this.getCalculatedPositions({ pair, index: i, collection: this.Document }))); return [] as ViewDefResult[]; } @@ -1711,11 +1719,12 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection }, icon: 'compress-arrows-alt', }); - appearanceItems.push({ - description: 'Toggle auto arrange', - event: () => (this.layoutDoc._autoArrange = !this.layoutDoc._autoArrange), - icon: 'compress-arrows-alt', - }); + !Doc.noviceMode && + appearanceItems.push({ + description: 'Toggle auto arrange', + event: () => (this.layoutDoc._autoArrange = !this.layoutDoc._autoArrange), + icon: 'compress-arrows-alt', + }); if (this.props.setContentView === emptyFunction) { !appearance && ContextMenu.Instance.addItem({ description: 'Appearance...', subitems: appearanceItems, icon: 'eye' }); return; @@ -1728,7 +1737,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection }); appearanceItems.push({ description: `Pin View`, event: () => TabDocView.PinDoc(this.rootDoc, { pinViewport: MarqueeView.CurViewBounds(this.rootDoc, this.props.PanelWidth(), this.props.PanelHeight()) }), icon: 'map-pin' }); !Doc.noviceMode && appearanceItems.push({ description: `update icon`, event: this.updateIcon, icon: 'compress-arrows-alt' }); - appearanceItems.push({ description: 'Ungroup collection', event: this.promoteCollection, icon: 'table' }); + this.props.renderDepth && appearanceItems.push({ description: 'Ungroup collection', event: this.promoteCollection, icon: 'table' }); this.props.Document._isGroup && this.Document.transcription && appearanceItems.push({ description: 'Ink to text', event: () => this.transcribeStrokes(false), icon: 'font' }); @@ -1770,7 +1779,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection const lines = text.split('\n'); const height = 30 + 15 * lines.length; - this.props.ContainingCollectionView?.addDocument(Docs.Create.TextDocument(text, { title: lines[0], x: NumCast(this.layoutDoc.x) + NumCast(this.layoutDoc._width) + 20, y: NumCast(this.layoutDoc.y), _width: 200, _height: height })); + this.addDocument(Docs.Create.TextDocument(text, { title: lines[0], x: NumCast(this.layoutDoc.x) + NumCast(this.layoutDoc._width) + 20, y: NumCast(this.layoutDoc.y), _width: 200, _height: height })); } } }; diff --git a/src/client/views/collections/collectionLinear/CollectionLinearView.tsx b/src/client/views/collections/collectionLinear/CollectionLinearView.tsx index c7d9b6619..97eed7752 100644 --- a/src/client/views/collections/collectionLinear/CollectionLinearView.tsx +++ b/src/client/views/collections/collectionLinear/CollectionLinearView.tsx @@ -209,8 +209,6 @@ export class CollectionLinearView extends CollectionSubView() { docFilters={this.props.docFilters} docRangeFilters={this.props.docRangeFilters} searchFilterDocs={this.props.searchFilterDocs} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} hideResizeHandles={true} /> </div> @@ -251,8 +249,7 @@ export class CollectionLinearView extends CollectionSubView() { self: this.rootDoc, _readOnly_: false, scriptContext: this.props.scriptContext, - thisContainer: this.props.ContainingCollectionDoc, - documentView: this.props.docViewPath().lastElement(), + documentView: this.props.DocumentView?.(), }); this.layoutDoc.linearViewIsExpanded = this.addMenuToggle.current!.checked; })} diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx index b73b1d779..78d3d1b6e 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx +++ b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx @@ -262,8 +262,6 @@ export class CollectionMulticolumnView extends CollectionSubView() { docFilters={this.childDocFilters} docRangeFilters={this.childDocRangeFilters} searchFilterDocs={this.searchFilterDocs} - ContainingCollectionDoc={this.props.CollectionView?.props.Document} - ContainingCollectionView={this.props.CollectionView} dontRegisterView={this.props.dontRegisterView} addDocument={this.props.addDocument} moveDocument={this.props.moveDocument} diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx index 0cca83803..4d61dc272 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx +++ b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx @@ -261,8 +261,6 @@ export class CollectionMultirowView extends CollectionSubView() { docFilters={this.childDocFilters} docRangeFilters={this.childDocRangeFilters} searchFilterDocs={this.searchFilterDocs} - ContainingCollectionDoc={this.props.CollectionView?.props.Document} - ContainingCollectionView={this.props.CollectionView} dontRegisterView={this.props.dontRegisterView} addDocument={this.props.addDocument} moveDocument={this.props.moveDocument} diff --git a/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx b/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx index fd9bcf681..6d5a73e55 100644 --- a/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx +++ b/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx @@ -793,7 +793,7 @@ export class CollectionSchemaView extends CollectionSubView() { fitContentsToBox={returnTrue} dontCenter={'y'} onClickScriptDisable="always" - focus={DocUtils.DefaultFocus} + focus={emptyFunction} renderDepth={this.props.renderDepth + 1} rootSelected={this.rootSelected} PanelWidth={this.previewWidthFunc} @@ -806,8 +806,6 @@ export class CollectionSchemaView extends CollectionSubView() { searchFilterDocs={this.searchFilterDocs} styleProvider={DefaultStyleProvider} docViewPath={returnEmptyDoclist} - ContainingCollectionDoc={this.props.CollectionView?.props.Document} - ContainingCollectionView={this.props.CollectionView} moveDocument={this.props.moveDocument} addDocument={this.addRow} removeDocument={this.props.removeDocument} @@ -849,8 +847,6 @@ class CollectionSchemaViewDocs extends React.Component<CollectionSchemaViewDocsP Document={doc} DataDoc={dataDoc} renderDepth={this.props.schema.props.renderDepth + 1} - ContainingCollectionView={this.props.schema.props.CollectionView} - ContainingCollectionDoc={this.props.schema.Document} PanelWidth={this.tableWidthFunc} PanelHeight={this.rowHeightFunc} styleProvider={DefaultStyleProvider} diff --git a/src/client/views/collections/collectionSchema/SchemaRowBox.tsx b/src/client/views/collections/collectionSchema/SchemaRowBox.tsx index 2defaae00..856537927 100644 --- a/src/client/views/collections/collectionSchema/SchemaRowBox.tsx +++ b/src/client/views/collections/collectionSchema/SchemaRowBox.tsx @@ -29,7 +29,7 @@ export class SchemaRowBox extends ViewBoxBaseComponent<FieldViewProps>() { } @computed get schemaDoc() { - return this.props.ContainingCollectionDoc!; + return this.props.DocumentView?.().props.docViewPath().lastElement()?.rootDoc; } @computed get rowIndex() { diff --git a/src/client/views/collections/collectionSchema/SchemaTableCell.tsx b/src/client/views/collections/collectionSchema/SchemaTableCell.tsx index 13e45963e..5f8ffe8b0 100644 --- a/src/client/views/collections/collectionSchema/SchemaTableCell.tsx +++ b/src/client/views/collections/collectionSchema/SchemaTableCell.tsx @@ -28,8 +28,6 @@ export class SchemaTableCell extends React.Component<SchemaTableCellProps> { searchFilterDocs: returnEmptyDoclist, styleProvider: DefaultStyleProvider, docViewPath: returnEmptyDoclist, - ContainingCollectionView: undefined, - ContainingCollectionDoc: undefined, fieldKey: this.props.fieldKey, rootSelected: returnFalse, isSelected: returnFalse, diff --git a/src/client/views/linking/LinkMenuItem.tsx b/src/client/views/linking/LinkMenuItem.tsx index 29e7cd3ad..19d6c2ae2 100644 --- a/src/client/views/linking/LinkMenuItem.tsx +++ b/src/client/views/linking/LinkMenuItem.tsx @@ -44,25 +44,10 @@ export async function StartLinkTargetsDrag(dragEle: HTMLElement, docView: Docume } const dragData = new DragManager.DocumentDragData(moddrag.length ? moddrag : draggedDocs); - dragData.moveDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (doc: Doc | Doc[]) => boolean): boolean => { - docView.props.removeDocument?.(doc); - addDocument(doc); - return true; - }; - const containingView = docView.props.ContainingCollectionView; - const finishDrag = (e: DragManager.DragCompleteEvent) => - e.docDragData && - (e.docDragData.droppedDocuments = dragData.draggedDocuments.reduce((droppedDocs, d) => { - const dvs = DocumentManager.Instance.getDocumentViews(d).filter(dv => dv.props.ContainingCollectionView === containingView); - if (dvs.length) { - dvs.forEach(dv => droppedDocs.push(dv.props.Document)); - } else { - droppedDocs.push(Doc.MakeAlias(d)); - } - return droppedDocs; - }, [] as Doc[])); + dragData.canEmbed = true; + dragData.dropAction = 'alias'; - DragManager.StartDrag([dragEle], dragData, downX, downY, undefined, finishDrag); + DragManager.StartDocumentDrag([dragEle], dragData, downX, downY, undefined); } } diff --git a/src/client/views/linking/LinkPopup.tsx b/src/client/views/linking/LinkPopup.tsx index 7bdace2b6..5f2d4a7b6 100644 --- a/src/client/views/linking/LinkPopup.tsx +++ b/src/client/views/linking/LinkPopup.tsx @@ -85,15 +85,13 @@ export class LinkPopup extends React.Component<LinkPopupProps> { PanelWidth={this.getPWidth} PanelHeight={this.getPHeight} renderDepth={0} - focus={DocUtils.DefaultFocus} + focus={emptyFunction} docViewPath={returnEmptyDoclist} whenChildContentsActiveChanged={emptyFunction} bringToFront={emptyFunction} docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} /> </div> </div> diff --git a/src/client/views/nodes/AudioBox.tsx b/src/client/views/nodes/AudioBox.tsx index a6acf882c..68fb19208 100644 --- a/src/client/views/nodes/AudioBox.tsx +++ b/src/client/views/nodes/AudioBox.tsx @@ -663,7 +663,6 @@ export class AudioBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp startTag={'_timecodeToShow' /* audioStart */} endTag={'_timecodeToHide' /* audioEnd */} bringToFront={emptyFunction} - CollectionView={undefined} playFrom={this.playFrom} setTime={this.setPlayheadTime} playing={this.playing} diff --git a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx index 9bdb2cee7..24b9f3b25 100644 --- a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx +++ b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx @@ -160,8 +160,9 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF } @action public float = () => { - const { Document: topDoc, ContainingCollectionView: container } = this.props; - const screenXf = container?.screenToLocalTransform(); + const topDoc = this.rootDoc; + const containerDocView = this.props.docViewPath().lastElement(); + const screenXf = containerDocView?.screenToLocalTransform(); if (screenXf) { SelectionManager.DeselectAll(); if (topDoc.z) { @@ -178,7 +179,7 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF topDoc.x = fpt[0]; topDoc.y = fpt[1]; } - setTimeout(() => SelectionManager.SelectView(DocumentManager.Instance.getDocumentView(topDoc, container)!, false), 0); + setTimeout(() => SelectionManager.SelectView(DocumentManager.Instance.getDocumentView(topDoc, containerDocView), false), 0); } }; diff --git a/src/client/views/nodes/DataVizBox/components/Chart.scss b/src/client/views/nodes/DataVizBox/components/Chart.scss index f1d09d2e7..d4f7bfb32 100644 --- a/src/client/views/nodes/DataVizBox/components/Chart.scss +++ b/src/client/views/nodes/DataVizBox/components/Chart.scss @@ -1,40 +1,41 @@ -.tooltip { - // make the height width bigger - width: fit-content; - height: fit-content; -} - -.hoverHighlight-selected, -.selected { - // change the color of the circle element to be red - fill: transparent; - outline: red solid 2px; - border-radius: 100%; - position: absolute; - transform-box: fill-box; - transform-origin: center; -} -.hoverHighlight { - fill: transparent; - outline: black solid 1px; - border-radius: 100%; -} -.hoverHighlight-selected { - fill: transparent; - scale: 1; - outline: black solid 1px; - border-radius: 100%; -} -.datapoint { - fill: black; -} -.brushed { - // change the color of the circle element to be red - fill: red; -} .chart-container { display: flex; flex-direction: column; align-items: center; cursor: default; + + .tooltip { + // make the height width bigger + width: fit-content; + height: fit-content; + } + + .hoverHighlight-selected, + .selected { + // change the color of the circle element to be red + fill: transparent; + outline: red solid 2px; + border-radius: 100%; + position: absolute; + transform-box: fill-box; + transform-origin: center; + } + .hoverHighlight { + fill: transparent; + outline: black solid 1px; + border-radius: 100%; + } + .hoverHighlight-selected { + fill: transparent; + scale: 1; + outline: black solid 1px; + border-radius: 100%; + } + .datapoint { + fill: black; + } + .brushed { + // change the color of the circle element to be red + fill: red; + } } diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx index 9e56de8c2..76a5ce7b3 100644 --- a/src/client/views/nodes/DocumentContentsView.tsx +++ b/src/client/views/nodes/DocumentContentsView.tsx @@ -139,7 +139,6 @@ export class DocumentContentsView extends React.Component< return proto instanceof Promise ? undefined : proto; } get layoutDoc() { - const params = StrCast(this.props.Document.PARAMS); // bcz: replaced this with below : is it correct? change was made to accommodate passing fieldKey's from a layout script // const template: Doc = this.props.LayoutTemplate?.() || Doc.Layout(this.props.Document, this.props.layoutKey ? Cast(this.props.Document[this.props.layoutKey], Doc, null) : undefined); const template: Doc = @@ -147,7 +146,7 @@ export class DocumentContentsView extends React.Component< (this.props.LayoutTemplateString && this.props.Document) || (this.props.layoutKey && StrCast(this.props.Document[this.props.layoutKey]) && this.props.Document) || Doc.Layout(this.props.Document, this.props.layoutKey ? Cast(this.props.Document[this.props.layoutKey], Doc, null) : undefined); - return Doc.expandTemplateLayout(template, this.props.Document, params ? '(' + params + ')' : this.props.layoutKey); + return Doc.expandTemplateLayout(template, this.props.Document); } CreateBindings(onClick: Opt<ScriptField>, onInput: Opt<ScriptField>): JsxBindings { @@ -155,7 +154,6 @@ export class DocumentContentsView extends React.Component< // these are the properties in DocumentViewProps that need to be removed to pass on only DocumentSharedViewProps to the FieldViews 'hideResizeHandles', 'hideTitle', - 'treeViewDoc', 'contentPointerEvents', 'radialMenu', 'LayoutTemplateString', diff --git a/src/client/views/nodes/DocumentLinksButton.tsx b/src/client/views/nodes/DocumentLinksButton.tsx index df3299eef..47705d53d 100644 --- a/src/client/views/nodes/DocumentLinksButton.tsx +++ b/src/client/views/nodes/DocumentLinksButton.tsx @@ -142,7 +142,7 @@ export class DocumentLinksButton extends React.Component<DocumentLinksButtonProp } else if (startLink !== endLink) { endLink = endLinkView?.docView?._componentView?.getAnchor?.(true, pinProps) || endLink; startLink = DocumentLinksButton.StartLinkView?.docView?._componentView?.getAnchor?.(true) || startLink; - const linkDoc = DocUtils.MakeLink(startLink, endLink, { linkRelationship: DocumentLinksButton.AnnotationId ? 'hypothes.is annotation' : undefined }, undefined, true); + const linkDoc = DocUtils.MakeLink(startLink, endLink, { linkRelationship: DocumentLinksButton.AnnotationId ? 'hypothes.is annotation' : undefined }); LinkManager.currentLink = linkDoc; diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index e24bf35f5..dc508d95f 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -22,7 +22,7 @@ import { DictationManager } from '../../util/DictationManager'; import { DocumentManager } from '../../util/DocumentManager'; import { DragManager, dropActionType } from '../../util/DragManager'; import { InteractionUtils } from '../../util/InteractionUtils'; -import { LinkFollower } from '../../util/LinkFollower'; +import { FollowLinkScript } from '../../util/LinkFollower'; import { LinkManager } from '../../util/LinkManager'; import { ScriptingGlobals } from '../../util/ScriptingGlobals'; import { SelectionManager } from '../../util/SelectionManager'; @@ -30,7 +30,6 @@ import { SharingManager } from '../../util/SharingManager'; import { SnappingManager } from '../../util/SnappingManager'; import { Transform } from '../../util/Transform'; import { undoBatch, UndoManager } from '../../util/UndoManager'; -import { CollectionView } from '../collections/CollectionView'; import { ContextMenu } from '../ContextMenu'; import { ContextMenuProps } from '../ContextMenuItem'; import { DocComponent } from '../DocComponent'; @@ -69,6 +68,7 @@ export enum OpenWhere { close = 'close', fullScreen = 'fullScreen', toggle = 'toggle', + toggleRight = 'toggle:right', replace = 'replace', replaceRight = 'replace:right', replaceLeft = 'replace:left', @@ -87,7 +87,7 @@ export enum OpenWhereMod { export interface DocFocusOptions { willPan?: boolean; // determines whether to pan to target document - willZoomCentered?: boolean; // determines whether to zoom in on target document + willZoomCentered?: boolean; // determines whether to zoom in on target document. if zoomScale is 0, this just centers the document zoomScale?: number; // percent of containing frame to zoom into document zoomTime?: number; didMove?: boolean; // whether a document was changed during the showDocument process @@ -97,7 +97,7 @@ export interface DocFocusOptions { effect?: Doc; // animation effect for focus noSelect?: boolean; // whether target should be selected after focusing playAudio?: boolean; // whether to play audio annotation on focus - openLocation?: string; // where to open a missing document + openLocation?: OpenWhere; // where to open a missing document zoomTextSelections?: boolean; // whether to display a zoomed overlay of anchor text selections toggleTarget?: boolean; // whether to toggle target on and off anchorDoc?: Doc; // doc containing anchor info to apply at end of focus to target doc @@ -148,8 +148,6 @@ export interface DocumentViewSharedProps { DataDoc?: Doc; contentBounds?: () => undefined | { x: number; y: number; r: number; b: number }; fitContentsToBox?: () => boolean; // used by freeformview to fit its contents to its panel. corresponds to _fitContentsToBox property on a Document - ContainingCollectionView: Opt<CollectionView>; - ContainingCollectionDoc: Opt<Doc>; suppressSetHeight?: boolean; thumbShown?: () => boolean; setContentView?: (view: DocComponentView) => any; @@ -172,13 +170,14 @@ export interface DocumentViewSharedProps { rootSelected: (outsideReaction?: boolean) => boolean; // whether the root of a template has been selected addDocTab: (doc: Doc, where: OpenWhere) => boolean; filterAddDocument?: (doc: Doc[]) => boolean; // allows a document that renders a Collection view to filter or modify any documents added to the collection (see PresBox for an example) - addDocument?: (doc: Doc | Doc[]) => boolean; - removeDocument?: (doc: Doc | Doc[]) => boolean; - moveDocument?: (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (document: Doc | Doc[]) => boolean) => boolean; + addDocument?: (doc: Doc | Doc[], annotationKey?: string) => boolean; + removeDocument?: (doc: Doc | Doc[], annotationKey?: string) => boolean; + moveDocument?: (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (document: Doc | Doc[], annotationKey?: string) => boolean) => boolean; pinToPres: (document: Doc, pinProps: PinProps) => void; ScreenToLocalTransform: () => Transform; bringToFront: (doc: Doc, sendToBack?: boolean) => void; canEmbedOnDrag?: boolean; + treeViewDoc?: Doc; xPadding?: number; yPadding?: number; dropAction?: dropActionType; @@ -210,7 +209,6 @@ export interface DocumentViewProps extends DocumentViewSharedProps { hideOpenButton?: boolean; hideDeleteButton?: boolean; hideLinkAnchors?: boolean; - treeViewDoc?: Doc; isDocumentActive?: () => boolean | undefined; // whether a document should handle pointer events isContentActive: () => boolean | undefined; // whether document contents should handle pointer events contentPointerEvents?: string; // pointer events allowed for content of a document view. eg. set to "none" in menuSidebar for sharedDocs so that you can select a document, but not interact with its contents @@ -429,7 +427,6 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps this: this.layoutDoc, self: this.rootDoc, scriptContext: this.props.scriptContext, - thisContainer: this.props.ContainingCollectionDoc, documentView: this.props.DocumentView(), clientX, clientY, altKey, shiftKey, ctrlKey, value: undefined, @@ -456,7 +453,6 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps self: this.rootDoc, _readOnly_: false, scriptContext: this.props.scriptContext, - thisContainer: this.props.ContainingCollectionDoc, documentView: this.props.DocumentView(), clientX, clientY, @@ -469,11 +465,6 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps ? this.props.select(false) : ''; clickFunc = () => (this.props.Document.dontUndo ? func() : UndoManager.RunInBatch(func, 'on click')); - } else if (!this.disableClickScriptFunc && this.allLinks.length && this.Document.isLinkButton && !e.shiftKey && !e.ctrlKey) { - clickFunc = () => { - SelectionManager.DeselectAll(); - LinkFollower.FollowLink(undefined, this.Document, e.altKey); - }; } else { // onDragStart implies a button doc that we don't want to select when clicking. RootDocument & isTemplateForField implies we're clicking on part of a template instance and we want to select the whole template, not the part if ((this.layoutDoc.onDragStart || this.props.Document.rootDocument) && !(e.ctrlKey || e.button > 0)) { @@ -482,7 +473,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps preventDefault = false; } - this._singleClickFunc = clickFunc ?? (() => (this._componentView?.select ?? this.props.select)(e.ctrlKey || e.metaKey, e.shiftKey)); + this._singleClickFunc = clickFunc ?? (() => this._componentView?.select?.(e.ctrlKey || e.metaKey, e.shiftKey) ?? this.props.select(e.ctrlKey || e.metaKey || e.shiftKey)); const waitFordblclick = this.props.waitForDoubleClickToClick?.() ?? this.Document.waitForDoubleClickToClick; if ((clickFunc && waitFordblclick !== 'never') || waitFordblclick === 'always') { this._doubleClickTimeout && clearTimeout(this._doubleClickTimeout); @@ -563,48 +554,24 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps @undoBatch @action toggleFollowLink = (zoom?: boolean, setTargetToggle?: boolean): void => { - this.Document.ignoreClick = false; - if (setTargetToggle) { - this.Document.followLinkToggle = !this.Document.followLinkToggle; - this.Document._isLinkButton = this.Document.followLinkToggle || this.Document._isLinkButton; - } else { - this.Document._isLinkButton = !this.Document._isLinkButton; - } - if (this.Document._isLinkButton && !this.onClickHandler) { - zoom !== undefined && (this.Document.followLinkZoom = zoom); - } else if (this.Document._isLinkButton && this.onClickHandler) { - this.Document._isLinkButton = false; - this.dataDoc.onClick = this.Document.onClick = this.layoutDoc.onClick = undefined; - } - }; - @undoBatch - @action - toggleTargetOnClick = (): void => { - this.Document.ignoreClick = false; - this.Document._isLinkButton = true; - this.Document.followLinkToggle = true; - }; - @undoBatch - @action - followLinkOnClick = (location: Opt<string>, zoom: boolean): void => { - this.Document.ignoreClick = false; - this.Document._isLinkButton = true; - this.Document.followLinkToggle = false; - this.Document.followLinkZoom = zoom; - this.Document.followLinkLocation = location; + const hadOnClick = this.rootDoc.onClick; + this.noOnClick(); + this.Document.onClick = hadOnClick ? undefined : FollowLinkScript(); + this.Document.waitForDoubleClickToClick = hadOnClick ? undefined : 'never'; }; @undoBatch @action - selectOnClick = (): void => { + followLinkOnClick = (): void => { this.Document.ignoreClick = false; - this.Document._isLinkButton = false; + this.Document.onClick = FollowLinkScript(); this.Document.followLinkToggle = false; - this.Document.onClick = this.layoutDoc.onClick = undefined; + this.Document.followLinkZoom = false; + this.Document.followLinkLocation = undefined; }; @undoBatch noOnClick = (): void => { this.Document.ignoreClick = false; - this.Document._isLinkButton = false; + this.Document.onClick = Doc.GetProto(this.Document).onClick = undefined; }; @undoBatch deleteClicked = () => this.props.removeDocument?.(this.props.Document); @@ -634,7 +601,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps } if (de.complete.annoDragData || this.rootDoc !== linkdrag.linkSourceDoc.context) { const dropDoc = de.complete.annoDragData?.dropDocument ?? this._componentView?.getAnchor?.(true) ?? this.rootDoc; - de.complete.linkDocument = DocUtils.MakeLink(linkdrag.linkSourceDoc, dropDoc, {}, undefined, undefined, [de.x, de.y - 50]); + de.complete.linkDocument = DocUtils.MakeLink(linkdrag.linkSourceDoc, dropDoc, {}, undefined, [de.x, de.y - 50]); } } } @@ -652,8 +619,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps ); } this.Document.followLinkLocation = OpenWhere.lightbox; - this.Document.followLinkZoom = true; - this.Document._isLinkButton = true; + this.Document.onClick = FollowLinkScript(); }; importDocument = () => { @@ -727,7 +693,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps !Doc.noviceMode && templateDoc && appearanceItems.push({ description: 'Open Template ', event: () => this.props.addDocTab(templateDoc, OpenWhere.addRight), icon: 'eye' }); !appearance && appearanceItems.length && cm.addItem({ description: 'UI Controls...', subitems: appearanceItems, icon: 'compass' }); - if (!Doc.IsSystem(this.rootDoc) && this.rootDoc._viewType !== CollectionViewType.Docking && this.props.ContainingCollectionDoc?._viewType !== CollectionViewType.Tree) { + if (!Doc.IsSystem(this.rootDoc) && this.rootDoc.type !== DocumentType.PRES && ![CollectionViewType.Docking, CollectionViewType.Tree].includes(this.rootDoc._viewType as any)) { const existingOnClick = cm.findByDescription('OnClick...'); const onClicks: ContextMenuProps[] = existingOnClick && 'subitems' in existingOnClick ? existingOnClick.subitems : []; @@ -744,29 +710,20 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps !zorders && cm.addItem({ description: 'ZOrder...', noexpand: true, subitems: zorderItems, icon: 'compass' }); } - !Doc.noviceMode && onClicks.push({ description: 'Enter Portal', event: this.makeIntoPortal, icon: 'window-restore' }); + onClicks.push({ description: 'Enter Portal', event: this.makeIntoPortal, icon: 'window-restore' }); !Doc.noviceMode && onClicks.push({ description: 'Toggle Detail', event: this.setToggleDetail, icon: 'concierge-bell' }); - this.props.CollectionFreeFormDocumentView && - onClicks.push({ - description: (this.Document.followLinkZoom ? "Don't" : '') + ' zoom following link', - event: () => (this.Document.followLinkZoom = !this.Document.followLinkZoom), - icon: this.Document.ignoreClick ? 'unlock' : 'lock', - }); if (!this.Document.annotationOn) { const options = cm.findByDescription('Options...'); const optionItems: ContextMenuProps[] = options && 'subitems' in options ? options.subitems : []; !options && cm.addItem({ description: 'Options...', subitems: optionItems, icon: 'compass' }); - onClicks.push({ description: this.Document.ignoreClick ? 'Select' : 'Do Nothing', event: () => (this.Document.ignoreClick = !this.Document.ignoreClick), icon: this.Document.ignoreClick ? 'unlock' : 'lock' }); - onClicks.push({ description: this.Document.isLinkButton || this.onClickHandler ? 'Remove Click Behavior' : 'Follow Link', event: () => this.toggleFollowLink(false, false), icon: 'link' }); - onClicks.push({ description: (this.Document.followLinkToggle ? 'Remove' : 'Make') + ' Target Visibility Toggle', event: () => this.toggleFollowLink(false, true), icon: 'map-pin' }); + onClicks.push({ description: this.onClickHandler ? 'Remove Click Behavior' : 'Follow Link', event: () => this.toggleFollowLink(false, false), icon: 'link' }); onClicks.push({ description: 'Edit onClick Script', event: () => UndoManager.RunInBatch(() => DocUtils.makeCustomViewClicked(this.props.Document, undefined, 'onClick'), 'edit onClick'), icon: 'terminal' }); !existingOnClick && cm.addItem({ description: 'OnClick...', addDivider: true, noexpand: true, subitems: onClicks, icon: 'mouse-pointer' }); } else if (LinkManager.Links(this.Document).length) { - onClicks.push({ description: 'Select on Click', event: () => this.selectOnClick(), icon: 'link' }); - onClicks.push({ description: 'Follow Link on Click', event: () => this.followLinkOnClick(undefined, false), icon: 'link' }); - onClicks.push({ description: 'Toggle Link Target on Click', event: () => this.toggleTargetOnClick(), icon: 'map-pin' }); + onClicks.push({ description: 'Select on Click', event: () => this.noOnClick(), icon: 'link' }); + onClicks.push({ description: 'Follow Link on Click', event: () => this.followLinkOnClick(), icon: 'link' }); !existingOnClick && cm.addItem({ description: 'OnClick...', addDivider: true, subitems: onClicks, icon: 'mouse-pointer' }); } } @@ -1485,7 +1442,7 @@ export class DocumentView extends React.Component<DocumentViewProps> { scaleToScreenSpace = () => (1 / (this.props.NativeDimScaling?.() || 1)) * this.screenToLocalTransform().Scale; docViewPathFunc = () => this.docViewPath; isSelected = (outsideReaction?: boolean) => SelectionManager.IsSelected(this, outsideReaction); - select = (extendSelection: boolean) => SelectionManager.SelectView(this, !SelectionManager.Views().some(v => v.props.Document === this.props.ContainingCollectionDoc) && extendSelection); + select = (extendSelection: boolean) => SelectionManager.SelectView(this, extendSelection); NativeWidth = () => this.effectiveNativeWidth; NativeHeight = () => this.effectiveNativeHeight; PanelWidth = () => this.panelWidth; diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx index 8d3534a5c..86779e0dd 100644 --- a/src/client/views/nodes/FieldView.tsx +++ b/src/client/views/nodes/FieldView.tsx @@ -32,6 +32,7 @@ export interface FieldViewProps extends DocumentViewSharedProps { // See currentUserUtils headerTemplate for examples of creating text boxes from html which set some of these fields // Also, see InkingStroke for examples of creating text boxes from render() methods which set some of these fields backgroundColor?: string; + treeViewDoc?: Doc; color?: string; fontSize?: number; height?: number; diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss index 6359a9491..22dbc1e80 100644 --- a/src/client/views/nodes/ImageBox.scss +++ b/src/client/views/nodes/ImageBox.scss @@ -119,6 +119,14 @@ } } } +.imageBox-alternateDropTarget { + position: absolute; + color: white; + background: black; + right: 0; + bottom: 0; + z-index: 2; +} .imageBox-fader img { position: absolute; diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index f38ebba27..98df777cb 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -1,3 +1,5 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Tooltip } from '@material-ui/core'; import { action, computed, IReactionDisposer, observable, ObservableMap, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import { extname } from 'path'; @@ -8,7 +10,7 @@ import { List } from '../../../fields/List'; import { ObjectField } from '../../../fields/ObjectField'; import { createSchema } from '../../../fields/Schema'; import { ComputedField } from '../../../fields/ScriptField'; -import { BoolCast, Cast, NumCast, StrCast } from '../../../fields/Types'; +import { Cast, NumCast, StrCast } from '../../../fields/Types'; import { ImageField } from '../../../fields/URLField'; import { TraceMobx } from '../../../fields/util'; import { DashColor, emptyFunction, returnEmptyString, returnFalse, returnOne, returnZero, setupMoveUpEvents, Utils } from '../../../Utils'; @@ -19,6 +21,7 @@ import { DocumentType } from '../../documents/DocumentTypes'; import { Networking } from '../../Network'; import { DocumentManager } from '../../util/DocumentManager'; import { DragManager } from '../../util/DragManager'; +import { SnappingManager } from '../../util/SnappingManager'; import { undoBatch } from '../../util/UndoManager'; import { ContextMenu } from '../../views/ContextMenu'; import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView'; @@ -57,6 +60,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp private _dropDisposer?: DragManager.DragDropDisposer; private _disposers: { [name: string]: IReactionDisposer } = {}; private _getAnchor: (savedAnnotations: Opt<ObservableMap<number, HTMLDivElement[]>>, addAsAnnotation: boolean) => Opt<Doc> = () => undefined; + private _overlayIconRef = React.createRef<HTMLDivElement>(); @observable _curSuffix = ''; @observable _uploadIcon = uploadIcons.idle; @@ -131,10 +135,16 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp @action drop = (e: Event, de: DragManager.DropEvent) => { if (de.complete.docDragData) { - if (de.metaKey) { + const targetIsBullseye = (ele: HTMLElement): boolean => { + if (!ele) return false; + if (ele === this._overlayIconRef.current) return true; + return targetIsBullseye(ele.parentElement as HTMLElement); + }; + if (de.metaKey || targetIsBullseye(e.target as HTMLElement)) { de.complete.docDragData.droppedDocuments.forEach( action((drop: Doc) => { Doc.AddDocToList(this.dataDoc, this.fieldKey + '-alternates', drop); + this.rootDoc[this.fieldKey + '-usePath'] = 'alternate:hover'; e.stopPropagation(); }) ); @@ -154,8 +164,6 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp @undoBatch resolution = () => (this.layoutDoc._showFullRes = !this.layoutDoc._showFullRes); - @undoBatch - setUseAlt = () => (this.layoutDoc[this.fieldKey + '-useAlt'] = !this.layoutDoc[this.fieldKey + '-useAlt']); @undoBatch setNativeSize = action(() => { @@ -202,7 +210,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp cropping.y = NumCast(this.rootDoc.y); cropping._width = anchw * (this.props.NativeDimScaling?.() || 1); cropping._height = anchh * (this.props.NativeDimScaling?.() || 1); - cropping.isLinkButton = undefined; + cropping.onClick = undefined; const croppingProto = Doc.GetProto(cropping); croppingProto.annotationOn = undefined; croppingProto.isPrototype = true; @@ -239,7 +247,6 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp funcs.push({ description: 'Rotate Clockwise 90', event: this.rotate, icon: 'redo-alt' }); funcs.push({ description: `Show ${this.layoutDoc._showFullRes ? 'Dynamic Res' : 'Full Res'}`, event: this.resolution, icon: 'expand' }); funcs.push({ description: 'Set Native Pixel Size', event: this.setNativeSize, icon: 'expand-arrows-alt' }); - funcs.push({ description: `${this.layoutDoc[this.fieldKey + '-useAlt'] ? 'Show Alternate' : 'Show Primary'}`, event: this.setUseAlt, icon: 'image' }); funcs.push({ description: 'Copy path', event: () => Utils.CopyText(this.choosePath(field.url)), icon: 'copy' }); if (!Doc.noviceMode) { funcs.push({ description: 'Export to Google Photos', event: () => GooglePhotos.Transactions.UploadImages([this.props.Document]), icon: 'caret-square-right' }); @@ -352,10 +359,45 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp @computed get nativeSize() { TraceMobx(); const nativeWidth = NumCast(this.dataDoc[this.fieldKey + '-nativeWidth'], NumCast(this.layoutDoc[this.fieldKey + '-nativeWidth'], 500)); - const nativeHeight = NumCast(this.dataDoc[this.fieldKey + '-nativeHeight'], NumCast(this.layoutDoc[this.fieldKey + '-nativeHeight'], 1)); + const nativeHeight = NumCast(this.dataDoc[this.fieldKey + '-nativeHeight'], NumCast(this.layoutDoc[this.fieldKey + '-nativeHeight'], 500)); const nativeOrientation = NumCast(this.dataDoc[this.fieldKey + '-nativeOrientation'], 1); return { nativeWidth, nativeHeight, nativeOrientation }; } + @computed get overlayImageIcon() { + const usePath = this.rootDoc[`_${this.fieldKey}-usePath`]; + return ( + <Tooltip + title={ + <div className="dash-tooltip"> + toggle between + <span style={{ color: usePath === undefined ? 'black' : undefined }}> + <em> primary, </em> + </span> + <span style={{ color: usePath === 'alternate' ? 'black' : undefined }}> + <em>alternate, </em> + </span> + and show + <span style={{ color: usePath === 'alternate:hover' ? 'black' : undefined }}> + <em> alternate on hover</em> + </span> + </div> + }> + <div + className="imageBox-alternateDropTarget" + ref={this._overlayIconRef} + onPointerDown={e => setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, e => (this.rootDoc[`_${this.fieldKey}-usePath`] = usePath === undefined ? 'alternate' : usePath === 'alternate' ? 'alternate:hover' : undefined))} + style={{ + display: (SnappingManager.GetIsDragging() && DragManager.DocDragData?.canEmbed) || DocListCast(this.dataDoc[this.fieldKey + '-alternates']).length ? 'block' : 'none', + width: 'min(10%, 25px)', + height: 'min(10%, 25px)', + background: usePath === undefined ? 'white' : usePath === 'alternate' ? 'black' : 'gray', + color: usePath === undefined ? 'black' : 'white', + }}> + <FontAwesomeIcon icon="turn-up" size="lg" /> + </div> + </Tooltip> + ); + } @computed get paths() { const field = Cast(this.dataDoc[this.fieldKey], ImageField, null); // retrieve the primary image URL that is being rendered from the data doc @@ -389,15 +431,14 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp transformOrigin = 'right top'; transform = `translate(-100%, 0%) rotate(${rotation}deg) scale(${aspect})`; } + const usePath = this.rootDoc[`_${this.fieldKey}-usePath`]; return ( <div className="imageBox-cont" onPointerEnter={action(() => (this._isHovering = true))} onPointerLeave={action(() => (this._isHovering = false))} key={this.layoutDoc[Id]} ref={this.createDropTarget} onPointerDown={this.marqueeDown}> <div className="imageBox-fader" style={{ opacity: backAlpha }}> <img key="paths" src={srcpath} style={{ transform, transformOrigin }} draggable={false} width={nativeWidth} /> {fadepath === srcpath ? null : ( - <div - className={`imageBox-fadeBlocker${(this._isHovering && this.layoutDoc[this.fieldKey + '-useAlt'] === undefined) || BoolCast(this.layoutDoc[this.fieldKey + '-useAlt']) ? '-hover' : ''}`} - style={{ transition: StrCast(this.layoutDoc.viewTransition, 'opacity 1000ms') }}> + <div className={`imageBox-fadeBlocker${(this._isHovering && usePath === 'alternate:hover') || usePath === 'alternate' ? '-hover' : ''}`} style={{ transition: StrCast(this.layoutDoc.viewTransition, 'opacity 1000ms') }}> <img className="imageBox-fadeaway" key="fadeaway" src={fadepath} style={{ transform, transformOrigin }} draggable={false} width={nativeWidth} /> </div> )} @@ -405,6 +446,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp {!Doc.noviceMode && this.considerDownloadIcon} {this.considerGooglePhotosLink()} <FaceRectangles document={this.dataDoc} color={'#0000FF'} backgroundColor={'#0000FF'} /> + {this.overlayImageIcon} </div> ); } @@ -478,7 +520,6 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp renderDepth={this.props.renderDepth + 1} fieldKey={this.annotationKey} styleProvider={this.props.styleProvider} - CollectionView={undefined} isAnnotationOverlay={true} annotationLayerHostsContent={true} PanelWidth={this.props.PanelWidth} diff --git a/src/client/views/nodes/KeyValuePair.tsx b/src/client/views/nodes/KeyValuePair.tsx index 94434dce7..7ea6d42ff 100644 --- a/src/client/views/nodes/KeyValuePair.tsx +++ b/src/client/views/nodes/KeyValuePair.tsx @@ -61,8 +61,6 @@ export class KeyValuePair extends React.Component<KeyValuePairProps> { searchFilterDocs: returnEmptyDoclist, styleProvider: DefaultStyleProvider, docViewPath: returnEmptyDoclist, - ContainingCollectionView: undefined, - ContainingCollectionDoc: undefined, fieldKey: this.props.keyName, rootSelected: returnFalse, isSelected: returnFalse, diff --git a/src/client/views/nodes/LinkAnchorBox.tsx b/src/client/views/nodes/LinkAnchorBox.tsx index 3feb95ce9..31f1775e5 100644 --- a/src/client/views/nodes/LinkAnchorBox.tsx +++ b/src/client/views/nodes/LinkAnchorBox.tsx @@ -47,7 +47,7 @@ export class LinkAnchorBox extends ViewBoxBaseComponent<FieldViewProps>() { if (separation > 100) { const dragData = new DragManager.DocumentDragData([this.rootDoc]); dragData.dropAction = 'alias'; - dragData.removeDropProperties = ['anchor1_x', 'anchor1_y', 'anchor2_x', 'anchor2_y', 'isLinkButton']; + dragData.removeDropProperties = ['anchor1_x', 'anchor1_y', 'anchor2_x', 'anchor2_y', 'onClick']; DragManager.StartDocumentDrag([this._ref.current!], dragData, pt[0], pt[1]); return true; } else { diff --git a/src/client/views/nodes/LinkDocPreview.tsx b/src/client/views/nodes/LinkDocPreview.tsx index fcc5b6975..c58b5dd8c 100644 --- a/src/client/views/nodes/LinkDocPreview.tsx +++ b/src/client/views/nodes/LinkDocPreview.tsx @@ -271,14 +271,12 @@ export class LinkDocPreview extends React.Component<LinkDocPreviewProps> { docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionDoc={undefined} - ContainingCollectionView={undefined} renderDepth={0} suppressSetHeight={true} PanelWidth={this.width} PanelHeight={this.height} pointerEvents={returnNone} - focus={DocUtils.DefaultFocus} + focus={emptyFunction} whenChildContentsActiveChanged={returnFalse} ignoreAutoHeight={true} // need to ignore autoHeight otherwise autoHeight text boxes will expand beyond the preview panel size. bringToFront={returnFalse} diff --git a/src/client/views/nodes/MapBox/MapBoxInfoWindow.tsx b/src/client/views/nodes/MapBox/MapBoxInfoWindow.tsx index d65ef9c4c..7b437c7de 100644 --- a/src/client/views/nodes/MapBox/MapBoxInfoWindow.tsx +++ b/src/client/views/nodes/MapBox/MapBoxInfoWindow.tsx @@ -57,7 +57,6 @@ export class MapBoxInfoWindow extends React.Component<MapBoxInfoWindowProps & Vi Document={this.props.place} DataDoc={undefined} fieldKey="data" - CollectionView={undefined} NativeWidth={returnZero} NativeHeight={returnZero} docFilters={returnEmptyFilter} diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx index e38d7e2a8..6aa04e356 100644 --- a/src/client/views/nodes/PDFBox.tsx +++ b/src/client/views/nodes/PDFBox.tsx @@ -119,7 +119,7 @@ export class PDFBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps cropping.y = NumCast(this.rootDoc.y); cropping._width = anchw; cropping._height = anchh; - cropping.isLinkButton = undefined; + cropping.onClick = undefined; const croppingProto = Doc.GetProto(cropping); croppingProto.annotationOn = undefined; croppingProto.isPrototype = true; @@ -547,7 +547,6 @@ export class PDFBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps removeDocument={this.sidebarRemDocument} moveDocument={this.sidebarMoveDocument} addDocument={this.sidebarAddDocument} - CollectionView={undefined} ScreenToLocalTransform={this.sidebarScreenToLocal} renderDepth={this.props.renderDepth + 1} noSidebar={true} @@ -587,6 +586,7 @@ export class PDFBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps }}> <PDFViewer {...this.props} + sidebarAddDoc={this.sidebarAddDocument} rootDoc={this.rootDoc} addDocTab={this.sidebarAddDocTab} layoutDoc={this.layoutDoc} diff --git a/src/client/views/nodes/ScreenshotBox.tsx b/src/client/views/nodes/ScreenshotBox.tsx index db11a7776..aa2b22e28 100644 --- a/src/client/views/nodes/ScreenshotBox.tsx +++ b/src/client/views/nodes/ScreenshotBox.tsx @@ -303,10 +303,8 @@ export class ScreenshotBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatabl removeDocument={returnFalse} moveDocument={returnFalse} addDocument={returnFalse} - CollectionView={undefined} ScreenToLocalTransform={this.props.ScreenToLocalTransform} - renderDepth={this.props.renderDepth + 1} - ContainingCollectionDoc={this.props.ContainingCollectionDoc}> + renderDepth={this.props.renderDepth + 1}> <> {this.threed} {this.content} @@ -330,7 +328,7 @@ export class ScreenshotBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatabl moveDocument={returnFalse} addDocument={returnFalse} renderDepth={this.props.renderDepth + 1} - ContainingCollectionDoc={this.props.ContainingCollectionDoc}></FormattedTextBox> + /> )} </div> </div> diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index e14ad4b05..7a7d4fe37 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -33,6 +33,8 @@ import { FieldView, FieldViewProps } from './FieldView'; import { RecordingBox } from './RecordingBox'; import { PinProps, PresBox } from './trails'; import './VideoBox.scss'; +import { ScriptField } from '../../../fields/ScriptField'; +import { FollowLinkScript } from '../../util/LinkFollower'; const path = require('path'); /** @@ -324,7 +326,7 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp _width: 150, _height: 50, title: (this.layoutDoc._currentTimecode || 0).toString(), - _isLinkButton: true, + onClick: FollowLinkScript(), }); this.props.addDocument?.(b); DocUtils.MakeLink(b, this.rootDoc, { linkRelationship: 'video snapshot' }); @@ -368,7 +370,7 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp _nativeHeight: Doc.NativeHeight(this.layoutDoc), x: NumCast(this.layoutDoc.x) + width, y: NumCast(this.layoutDoc.y), - _isLinkButton: true, + onClick: FollowLinkScript(), _width: 150, _height: (height / width) * 150, title: '--snapshot' + NumCast(this.layoutDoc._currentTimecode) + ' image-', @@ -961,7 +963,6 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp startTag={'_timecodeToShow' /* videoStart */} endTag={'_timecodeToHide' /* videoEnd */} bringToFront={emptyFunction} - CollectionView={undefined} playFrom={this.playFrom} setTime={this.setPlayheadTime} playing={this.playing} @@ -1010,7 +1011,7 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp cropping._height = anchh * (this.props.NativeDimScaling?.() || 1); cropping.timecodeToHide = undefined; cropping.timecodeToShow = undefined; - cropping.isLinkButton = undefined; + cropping.onClick = undefined; const croppingProto = Doc.GetProto(cropping); croppingProto.annotationOn = undefined; croppingProto.isPrototype = true; @@ -1072,7 +1073,6 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp NativeHeight={returnZero} renderDepth={this.props.renderDepth + 1} fieldKey={this.annotationKey} - CollectionView={undefined} isAnnotationOverlay={true} annotationLayerHostsContent={true} PanelWidth={this.panelWidth} diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx index 66d0fd385..e05b48c0b 100644 --- a/src/client/views/nodes/WebBox.tsx +++ b/src/client/views/nodes/WebBox.tsx @@ -28,6 +28,7 @@ import { LightboxView } from '../LightboxView'; import { MarqueeAnnotator } from '../MarqueeAnnotator'; import { AnchorMenu } from '../pdf/AnchorMenu'; import { Annotation } from '../pdf/Annotation'; +import { GPTPopup } from '../pdf/GPTPopup/GPTPopup'; import { SidebarAnnos } from '../SidebarAnnos'; import { StyleProp } from '../StyleProvider'; import { DocFocusOptions, DocumentView, DocumentViewProps, OpenWhere } from './DocumentView'; @@ -366,8 +367,13 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps const scale = (this.props.NativeDimScaling?.() || 1) * mainContBounds.scale; const sel = this._iframe.contentWindow.getSelection(); if (sel) { + this._selectionText = sel.toString(); + AnchorMenu.Instance.setSelectedText(sel.toString()); this._textAnnotationCreator = () => this.createTextAnnotation(sel, !sel.isCollapsed ? sel.getRangeAt(0) : undefined); AnchorMenu.Instance.jumpTo(e.clientX * scale + mainContBounds.translateX, e.clientY * scale + mainContBounds.translateY - NumCast(this.layoutDoc._scrollTop) * scale); + // Changing which document to add the annotation to (the currently selected WebBox) + GPTPopup.Instance.setSidebarId(`${this.props.fieldKey}-${this._urlHash}-sidebar`); + GPTPopup.Instance.addDoc = this.sidebarAddDocument; } } }; @@ -785,6 +791,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps } addDocumentWrapper = (doc: Doc | Doc[], annotationKey?: string) => { + console.log(annotationKey); (doc instanceof Doc ? [doc] : doc).forEach(doc => (doc.webUrl = this._url)); return this.addDocument(doc, annotationKey); }; @@ -915,7 +922,6 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps renderDepth={this.props.renderDepth + 1} isAnnotationOverlay={true} fieldKey={this.annotationKey} - CollectionView={undefined} setPreviewCursor={this.setPreviewCursor} setBrushViewer={this.setBrushViewer} PanelWidth={this.panelWidth} diff --git a/src/client/views/nodes/button/FontIconBox.tsx b/src/client/views/nodes/button/FontIconBox.tsx index 8eacfbc51..1a75a7e76 100644 --- a/src/client/views/nodes/button/FontIconBox.tsx +++ b/src/client/views/nodes/button/FontIconBox.tsx @@ -568,12 +568,12 @@ ScriptingGlobals.add(function setBackgroundColor(color?: string, checkResult?: b if (selectedViews.length) { if (checkResult) { const selView = selectedViews.lastElement(); - const layoutFrameNumber = Cast(selView.props.ContainingCollectionDoc?._currentFrame, 'number'); // frame number that container is at which determines layout frame values + const layoutFrameNumber = Cast(selView.props.docViewPath().lastElement()?.rootDoc?._currentFrame, 'number'); // frame number that container is at which determines layout frame values const contentFrameNumber = Cast(selView.rootDoc?._currentFrame, 'number', layoutFrameNumber ?? null); // frame number that content is at which determines what content is displayed return CollectionFreeFormDocumentView.getStringValues(selView?.rootDoc, contentFrameNumber).backgroundColor ?? 'transparent'; } selectedViews.forEach(dv => { - const layoutFrameNumber = Cast(dv.props.ContainingCollectionDoc?._currentFrame, 'number'); // frame number that container is at which determines layout frame values + const layoutFrameNumber = Cast(dv.props.docViewPath().lastElement()?.rootDoc?._currentFrame, 'number'); // frame number that container is at which determines layout frame values const contentFrameNumber = Cast(dv.rootDoc?._currentFrame, 'number', layoutFrameNumber ?? null); // frame number that content is at which determines what content is displayed if (contentFrameNumber !== undefined) { CollectionFreeFormDocumentView.setStringValues(contentFrameNumber, dv.rootDoc, { backgroundColor: color }); diff --git a/src/client/views/nodes/formattedText/DashDocView.tsx b/src/client/views/nodes/formattedText/DashDocView.tsx index 61345f891..b31fc01ff 100644 --- a/src/client/views/nodes/formattedText/DashDocView.tsx +++ b/src/client/views/nodes/formattedText/DashDocView.tsx @@ -9,10 +9,9 @@ import { DocServer } from '../../../DocServer'; import { Docs, DocUtils } from '../../../documents/Documents'; import { ColorScheme } from '../../../util/SettingsManager'; import { Transform } from '../../../util/Transform'; -import { DocumentView } from '../DocumentView'; +import { DocFocusOptions, DocumentView } from '../DocumentView'; import { FormattedTextBox } from './FormattedTextBox'; import React = require('react'); -import { SelectionManager } from '../../../util/SelectionManager'; export class DashDocView { dom: HTMLSpanElement; // container for label and value @@ -77,7 +76,7 @@ export class DashDocViewInternal extends React.Component<IDashDocViewInternal> { updateDoc = action((dashDoc: Doc) => { this._dashDoc = dashDoc; - this._finalLayout = this.props.docId ? dashDoc : Doc.expandTemplateLayout(Doc.Layout(dashDoc), dashDoc, this.props.fieldKey); + this._finalLayout = this.props.docId ? dashDoc : Doc.expandTemplateLayout(Doc.Layout(dashDoc), dashDoc); if (this._finalLayout) { if (!Doc.AreProtosEqual(this._finalLayout, dashDoc)) { @@ -151,7 +150,7 @@ export class DashDocViewInternal extends React.Component<IDashDocViewInternal> { const { scale, translateX, translateY } = Utils.GetScreenTransform(this._spanRef.current); return new Transform(-translateX, -translateY, 1).scale(1 / scale); }; - outerFocus = (target: Doc) => this._textBox.props.focus(this._textBox.props.Document, {}); // ideally, this would scroll to show the focus target + outerFocus = (target: Doc, options: DocFocusOptions) => this._textBox.focus(target, options); // ideally, this would scroll to show the focus target onKeyDown = (e: any) => { e.stopPropagation(); @@ -212,8 +211,6 @@ export class DashDocViewInternal extends React.Component<IDashDocViewInternal> { docFilters={this.props.tbox?.props.docFilters} docRangeFilters={this.props.tbox?.props.docRangeFilters} searchFilterDocs={this.props.tbox?.props.searchFilterDocs} - ContainingCollectionView={this._textBox.props.ContainingCollectionView} - ContainingCollectionDoc={this._textBox.props.ContainingCollectionDoc} /> </div> ); diff --git a/src/client/views/nodes/formattedText/DashFieldView.tsx b/src/client/views/nodes/formattedText/DashFieldView.tsx index 21ccf3bc7..72e8aedac 100644 --- a/src/client/views/nodes/formattedText/DashFieldView.tsx +++ b/src/client/views/nodes/formattedText/DashFieldView.tsx @@ -101,11 +101,7 @@ export class DashFieldViewInternal extends React.Component<IDashFieldViewInterna this._textBoxDoc = this.props.tbox.props.Document; if (this.props.docId) { - DocServer.GetRefField(this.props.docId).then( - action(async dashDoc => { - dashDoc instanceof Doc && (this._dashDoc = dashDoc); - }) - ); + DocServer.GetRefField(this.props.docId).then(action(dashDoc => dashDoc instanceof Doc && (this._dashDoc = dashDoc))); } else { this._dashDoc = this.props.tbox.rootDoc; } @@ -116,7 +112,7 @@ export class DashFieldViewInternal extends React.Component<IDashFieldViewInterna public static multiValueDelimeter = ';'; public static fieldContent(textBoxDoc: Doc, dashDoc: Doc, fieldKey: string) { - const dashVal = dashDoc[fieldKey] ?? dashDoc[DataSym][fieldKey] ?? (fieldKey === 'PARAMS' ? textBoxDoc[fieldKey] : ''); + const dashVal = dashDoc[fieldKey] ?? dashDoc[DataSym][fieldKey] ?? ''; const fval = dashVal instanceof List ? dashVal.join(DashFieldViewInternal.multiValueDelimeter) : StrCast(dashVal).startsWith(':=') || dashVal === '' ? Doc.Layout(textBoxDoc)[fieldKey] : dashVal; return { boolVal: Cast(fval, 'boolean', null), strVal: Field.toString(fval as Field) || '' }; } @@ -223,7 +219,7 @@ export class DashFieldViewInternal extends React.Component<IDashFieldViewInterna Doc.SetInPlace(this._dashDoc!, this._fieldKey, Number(newText), true); } else { const splits = newText.split(DashFieldViewInternal.multiValueDelimeter); - if (this._fieldKey !== 'PARAMS' || !this._textBoxDoc[this._fieldKey] || this._dashDoc?.PARAMS) { + if (!this._textBoxDoc[this._fieldKey]) { const strVal = splits.length > 1 ? new List<string>(splits) : newText; if (this._fieldKey.startsWith('_')) Doc.Layout(this._textBoxDoc)[this._fieldKey] = strVal; Doc.SetInPlace(this._dashDoc!, this._fieldKey, strVal, true); @@ -235,10 +231,7 @@ export class DashFieldViewInternal extends React.Component<IDashFieldViewInterna }; createPivotForField = (e: React.MouseEvent) => { - let container = this.props.tbox.props.ContainingCollectionView; - while (container?.props.Document.isTemplateForField || container?.props.Document.isTemplateDoc) { - container = container.props.ContainingCollectionView; - } + let container = this.props.tbox.props.DocumentView?.().props.docViewPath().lastElement(); if (container) { const alias = Doc.MakeAlias(container.props.Document); alias._viewType = CollectionViewType.Time; diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.scss b/src/client/views/nodes/formattedText/FormattedTextBox.scss index cbe0a465d..fd7fbb333 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.scss +++ b/src/client/views/nodes/formattedText/FormattedTextBox.scss @@ -149,6 +149,10 @@ audiotag:hover { } } +.gpt-typing-wrapper { + padding: 10px; +} + // .menuicon { // display: inline-block; // border-right: 1px solid rgba(0, 0, 0, 0.2); diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 361e000f9..bbe38cf99 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -1,8 +1,9 @@ import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { isEqual } from 'lodash'; -import { action, computed, IReactionDisposer, observable, reaction, runInAction } from 'mobx'; +import { action, computed, IReactionDisposer, observable, ObservableSet, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; +import { Configuration, OpenAIApi } from 'openai'; import { baseKeymap, selectAll } from 'prosemirror-commands'; import { history } from 'prosemirror-history'; import { inputRules } from 'prosemirror-inputrules'; @@ -11,7 +12,7 @@ import { Fragment, Mark, Node, Slice } from 'prosemirror-model'; import { EditorState, NodeSelection, Plugin, TextSelection, Transaction } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { DateField } from '../../../../fields/DateField'; -import { AclAdmin, AclAugment, AclEdit, AclSelfEdit, Doc, DocListCast, Field, ForceServerWrite, HeightSym, Opt, UpdatingFromServer, WidthSym } from '../../../../fields/Doc'; +import { AclAdmin, AclAugment, AclEdit, AclSelfEdit, CssSym, Doc, DocListCast, Field, ForceServerWrite, HeightSym, Opt, UpdatingFromServer, WidthSym } from '../../../../fields/Doc'; import { Id } from '../../../../fields/FieldSymbols'; import { InkTool } from '../../../../fields/InkField'; import { PrefetchProxy } from '../../../../fields/Proxy'; @@ -22,13 +23,16 @@ import { BoolCast, Cast, DocCast, FieldValue, NumCast, ScriptCast, StrCast } fro import { GetEffectiveAcl, TraceMobx } from '../../../../fields/util'; import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, numberRange, returnFalse, returnZero, setupMoveUpEvents, smoothScroll, unimplementedFunction, Utils } from '../../../../Utils'; import { GoogleApiClientUtils, Pulls, Pushes } from '../../../apis/google_docs/GoogleApiClientUtils'; +import { gptAPICall, GPTCallType, gptImageCall } from '../../../apis/gpt/GPT'; import { DocServer } from '../../../DocServer'; import { Docs, DocUtils } from '../../../documents/Documents'; import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes'; +import { Networking } from '../../../Network'; import { DictationManager } from '../../../util/DictationManager'; import { DocumentManager } from '../../../util/DocumentManager'; import { DragManager } from '../../../util/DragManager'; import { MakeTemplate } from '../../../util/DropConverter'; +import { IsFollowLinkScript } from '../../../util/LinkFollower'; import { LinkManager } from '../../../util/LinkManager'; import { SelectionManager } from '../../../util/SelectionManager'; import { SnappingManager } from '../../../util/SnappingManager'; @@ -65,9 +69,7 @@ import { SummaryView } from './SummaryView'; import applyDevTools = require('prosemirror-dev-tools'); import React = require('react'); const translateGoogleApi = require('translate-google-api'); - export const GoogleRef = 'googleDocId'; - type PullHandler = (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => void; @observer @@ -78,7 +80,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps public static blankState = () => EditorState.create(FormattedTextBox.Instance.config); public static Instance: FormattedTextBox; public static LiveTextUndo: UndoManager.Batch | undefined; - static _globalHighlights: string[] = ['Audio Tags', 'Text from Others', 'Todo Items', 'Important Items', 'Disagree Items', 'Ignore Items']; + static _globalHighlightsCache: string = ''; + static _globalHighlights = new ObservableSet<string>(['Audio Tags', 'Text from Others', 'Todo Items', 'Important Items', 'Disagree Items', 'Ignore Items']); static _highlightStyleSheet: any = addStyleSheet(); static _bulletStyleSheet: any = addStyleSheet(); static _userStyleSheet: any = addStyleSheet(); @@ -170,6 +173,10 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps }; } + // State for GPT + @observable + private gptRes: string = ''; + public static PasteOnLoad: ClipboardEvent | undefined; public static SelectOnLoad = ''; public static DontSelectInitialText = false; // whether initial text should be selected or not @@ -192,7 +199,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps constructor(props: any) { super(props); FormattedTextBox.Instance = this; - this.updateHighlights(); this._recordingStart = Date.now(); } @@ -540,7 +546,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps // embed document when dragg marked as embed } else if (de.embedKey) { const target = dragData.droppedDocuments[0]; - target._fitContentsToBox = true; const node = schema.nodes.dashDoc.create({ width: target[WidthSym](), height: target[HeightSym](), @@ -551,6 +556,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps if (!['alias', 'copy'].includes((dragData.dropAction ?? '') as any)) { dragData.removeDocument?.(dragData.draggedDocuments[0]); } + target._fitContentsToBox = true; + target.context = this.rootDoc; const view = this._editorView!; view.dispatch(view.state.tr.insert(view.posAtCoords({ left: de.x, top: de.y })!.pos, node)); e.stopPropagation(); @@ -603,45 +610,46 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps return ret; } - updateHighlights = () => { - const highlights = FormattedTextBox._globalHighlights; + updateHighlights = (highlights: string[]) => { + if (Array.from(highlights).join('') === FormattedTextBox._globalHighlightsCache) return; + setTimeout(() => (FormattedTextBox._globalHighlightsCache = Array.from(highlights).join(''))); clearStyleSheetRules(FormattedTextBox._userStyleSheet); - if (highlights.indexOf('Audio Tags') === -1) { + if (highlights.includes('Audio Tags')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'audiotag', { display: 'none' }, ''); } - if (highlights.indexOf('Text from Others') !== -1) { + if (highlights.includes('Text from Others')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UM-remote', { background: 'yellow' }); } - if (highlights.indexOf('My Text') !== -1) { + if (highlights.includes('My Text')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UM-' + Doc.CurrentUserEmail.replace('.', '').replace('@', ''), { background: 'moccasin' }); } - if (highlights.indexOf('Todo Items') !== -1) { + if (highlights.includes('Todo Items')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UT-todo', { outline: 'black solid 1px' }); } - if (highlights.indexOf('Important Items') !== -1) { + if (highlights.includes('Important Items')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UT-important', { 'font-size': 'larger' }); } - if (highlights.indexOf('Bold Text') !== -1) { - addStyleSheetRule(FormattedTextBox._userStyleSheet, '.formattedTextBox-inner-selected .ProseMirror strong > span', { 'font-size': 'large' }, ''); - addStyleSheetRule(FormattedTextBox._userStyleSheet, '.formattedTextBox-inner-selected .ProseMirror :not(strong > span)', { 'font-size': '0px' }, ''); + if (highlights.includes('Bold Text')) { + addStyleSheetRule(FormattedTextBox._userStyleSheet, '.formattedTextBox-inner .ProseMirror strong > span', { 'font-size': 'large' }, ''); + addStyleSheetRule(FormattedTextBox._userStyleSheet, '.formattedTextBox-inner .ProseMirror :not(strong > span)', { 'font-size': '0px' }, ''); } - if (highlights.indexOf('Disagree Items') !== -1) { + if (highlights.includes('Disagree Items')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UT-disagree', { 'text-decoration': 'line-through' }); } - if (highlights.indexOf('Ignore Items') !== -1) { + if (highlights.includes('Ignore Items')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UT-ignore', { 'font-size': '1' }); } - if (highlights.indexOf('By Recent Minute') !== -1) { + if (highlights.includes('By Recent Minute')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UM-' + Doc.CurrentUserEmail.replace('.', '').replace('@', ''), { opacity: '0.1' }); const min = Math.round(Date.now() / 1000 / 60); numberRange(10).map(i => addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UM-min-' + (min - i), { opacity: ((10 - i - 1) / 10).toString() })); - setTimeout(this.updateHighlights); } - if (highlights.indexOf('By Recent Hour') !== -1) { + if (highlights.includes('By Recent Hour')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UM-' + Doc.CurrentUserEmail.replace('.', '').replace('@', ''), { opacity: '0.1' }); const hr = Math.round(Date.now() / 1000 / 60 / 60); numberRange(10).map(i => addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UM-hr-' + (hr - i), { opacity: ((10 - i - 1) / 10).toString() })); } + this.layoutDoc[CssSym] = this.layoutDoc[CssSym] + 1; // css changes happen outside of react/mobx. so we need to set a flag that will notify anyone intereted in layout changes triggered by css changes (eg., CollectionLinkView) }; @observable _showSidebar = false; @@ -779,18 +787,16 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps const expertHighlighting = [...noviceHighlighting, 'Important Items', 'Ignore Items', 'Disagree Items', 'By Recent Minute', 'By Recent Hour']; (Doc.noviceMode ? noviceHighlighting : expertHighlighting).forEach(option => highlighting.push({ - description: (FormattedTextBox._globalHighlights.indexOf(option) === -1 ? 'Highlight ' : 'Unhighlight ') + option, - event: () => { + description: (!FormattedTextBox._globalHighlights.has(option) ? 'Highlight ' : 'Unhighlight ') + option, + event: action(() => { e.stopPropagation(); - if (FormattedTextBox._globalHighlights.indexOf(option) === -1) { - FormattedTextBox._globalHighlights.push(option); + if (!FormattedTextBox._globalHighlights.has(option)) { + FormattedTextBox._globalHighlights.add(option); } else { - FormattedTextBox._globalHighlights.splice(FormattedTextBox._globalHighlights.indexOf(option), 1); + FormattedTextBox._globalHighlights.delete(option); } - runInAction(() => (this.layoutDoc._highlights = FormattedTextBox._globalHighlights.join(''))); - this.updateHighlights(); - }, - icon: FormattedTextBox._globalHighlights.indexOf(option) === -1 ? 'highlighter' : 'remove-format', + }), + icon: !FormattedTextBox._globalHighlights.has(option) ? 'highlighter' : 'remove-format', }) ); @@ -844,12 +850,67 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps const options = cm.findByDescription('Options...'); const optionItems = options && 'subitems' in options ? options.subitems : []; + optionItems.push({ description: `Generate Dall-E Image`, event: () => this.generateImage(), icon: 'star' }); + optionItems.push({ description: `Ask GPT-3`, event: () => this.askGPT(), icon: 'lightbulb' }); optionItems.push({ description: !this.Document._singleLine ? 'Make Single Line' : 'Make Multi Line', event: () => (this.layoutDoc._singleLine = !this.layoutDoc._singleLine), icon: !this.Document._singleLine ? 'grip-lines' : 'bars' }); optionItems.push({ description: `${this.Document._autoHeight ? 'Lock' : 'Auto'} Height`, event: () => (this.layoutDoc._autoHeight = !this.layoutDoc._autoHeight), icon: this.Document._autoHeight ? 'lock' : 'unlock' }); !options && cm.addItem({ description: 'Options...', subitems: optionItems, icon: 'eye' }); this._downX = this._downY = Number.NaN; }; + animateRes = (resIndex: number) => { + if (resIndex < this.gptRes.length) { + this.dataDoc.text = (this.dataDoc.text as RichTextField)?.Text + this.gptRes[resIndex]; + setTimeout(() => { + this.animateRes(resIndex + 1); + }, 20); + } + }; + + askGPT = action(async () => { + try { + let res = await gptAPICall((this.dataDoc.text as RichTextField)?.Text, GPTCallType.COMPLETION); + if (res) { + this.gptRes = res; + this.animateRes(0); + } + } catch (err) { + console.log(err); + this.dataDoc.text = (this.dataDoc.text as RichTextField)?.Text + 'Something went wrong'; + } + }); + + generateImage = async () => { + console.log('Generate image from text: ', (this.dataDoc.text as RichTextField)?.Text); + try { + let image_url = await gptImageCall((this.dataDoc.text as RichTextField)?.Text); + if (image_url) { + const [result] = await Networking.PostToServer('/uploadRemoteImage', { sources: [image_url] }); + const source = Utils.prepend(result.accessPaths.agnostic.client); + const newDoc = Docs.Create.ImageDocument(source, { + x: NumCast(this.rootDoc.x) + NumCast(this.layoutDoc._width) + 10, + y: NumCast(this.rootDoc.y), + _height: 200, + _width: 200, + 'data-nativeWidth': result.nativeWidth, + 'data-nativeHeight': result.nativeHeight, + }); + if (DocListCast(Doc.MyOverlayDocs?.data).includes(this.rootDoc)) { + newDoc.overlayX = this.rootDoc.x; + newDoc.overlayY = NumCast(this.rootDoc.y) + NumCast(this.rootDoc._height); + Doc.AddDocToList(Doc.MyOverlayDocs, undefined, newDoc); + } else { + this.props.addDocument?.(newDoc); + } + // Create link between prompt and image + DocUtils.MakeLink(this.rootDoc, newDoc, { linkRelationship: 'Image Prompt' }); + } + } catch (err) { + console.log(err); + return ''; + } + }; + breakupDictation = () => { if (this._editorView && this._recording) { this.stopDictation(true); @@ -879,7 +940,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps if (this._editorView && this._recordingStart) { if (this._break) { const textanchorFunc = () => { - const tanch = Docs.Create.TextanchorDocument({ title: 'dictation anchor' }); + const tanch = Docs.Create.TextanchorDocument({ title: 'dictation anchor', unrendered: true }); return this.addDocument(tanch) ? tanch : undefined; }; const link = DocUtils.MakeLinkToActiveAudio(textanchorFunc, false).lastElement(); @@ -955,7 +1016,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps let hadStart = start !== 0; frag.forEach((node, index) => { const examinedNode = findAnchorNode(node, editor); - if (examinedNode?.node && (examinedNode.node.textContent || examinedNode.node.type === this._editorView?.state.schema.nodes.audiotag)) { + if (examinedNode?.node && (examinedNode.node.textContent || examinedNode.node.type === this._editorView?.state.schema.nodes.dashDoc || examinedNode.node.type === this._editorView?.state.schema.nodes.audiotag)) { nodes.push(examinedNode.node); !hadStart && (start = index + examinedNode.start); hadStart = true; @@ -970,9 +1031,15 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps } return undefined; } + if (node.type === this._editorView?.state.schema.nodes.dashDoc) { + if (node.attrs.docId === textAnchorId) { + return { node, start: 0 }; + } + return undefined; + } if (!node.isText) { const content = findAnchorFrag(node.content, editor); - return { node: node.copy(content.frag), start: content.start }; + if (content.frag.childCount) return { node: content.frag.childCount ? content.frag.child(0) : node, start: content.start }; } const marks = [...node.marks]; const linkIndex = marks.findIndex(mark => mark.type === editor.state.schema.marks.linkAnchor); @@ -986,7 +1053,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps const ret = findAnchorFrag(editor.state.doc.content, editor); const content = (ret.frag as any)?.content; - if ((ret.frag.size > 2 || (content?.length && content[0].type === this._editorView.state.schema.nodes.audiotag)) && ret.start >= 0) { + if ((ret.frag.size || (content?.length && content[0].type === this._editorView.state.schema.nodes.dashDoc) || (content?.length && content[0].type === this._editorView.state.schema.nodes.audiotag)) && ret.start >= 0) { !options.instant && (this._focusSpeed = focusSpeed); let selection = TextSelection.near(editor.state.doc.resolve(ret.start)); // default to near the start if (ret.frag.firstChild) { @@ -997,6 +1064,9 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps addStyleSheetRule(FormattedTextBox._highlightStyleSheet, `${escAnchorId}`, { background: 'yellow', transform: 'scale(3)', 'transform-origin': 'left bottom' }); setTimeout(() => (this._focusSpeed = undefined), this._focusSpeed); setTimeout(() => clearStyleSheetRules(FormattedTextBox._highlightStyleSheet), Math.max(this._focusSpeed || 0, 3000)); + return focusSpeed; + } else { + return this.props.focus(this.rootDoc, options); } } }; @@ -1020,6 +1090,11 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps () => this.autoHeight, autoHeight => autoHeight && this.tryUpdateScrollHeight() ); + this._disposers.highlights = reaction( + () => Array.from(FormattedTextBox._globalHighlights).slice(), + highlights => this.updateHighlights(highlights), + { fireImmediately: true } + ); this._disposers.width = reaction( () => this.props.PanelWidth(), width => this.tryUpdateScrollHeight() @@ -1104,7 +1179,9 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps this._disposers.selected = reaction( () => this.props.isSelected(), action(selected => { - this.layoutDoc._highlights = selected ? FormattedTextBox._globalHighlights.join('') : ''; + if (FormattedTextBox._globalHighlights.has('Bold Text')) { + this.layoutDoc[CssSym] = this.layoutDoc[CssSym] + 1; // css change happens outside of mobx/react, so this will notify anyone interested in the layout that it has changed + } if (RichTextMenu.Instance?.view === this._editorView && !selected) { RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined); } @@ -1112,6 +1189,11 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps RichTextMenu.Instance?.updateMenu(this._editorView, undefined, this.props); this.autoLink(); } + // Accessing editor and text doc for gpt assisted text edits + if (this._editorView && selected) { + AnchorMenu.Instance?.setEditorView(this._editorView); + AnchorMenu.Instance?.setTextDoc(this.dataDoc); + } }), { fireImmediately: true } ); @@ -1420,7 +1502,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps } componentWillUnmount() { - FormattedTextBox.Focused === this && (FormattedTextBox.Focused = undefined); Object.values(this._disposers).forEach(disposer => disposer?.()); this.endUndoTypingBatch(); this.unhighlightSearchTerms(); @@ -1521,11 +1602,9 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps @action onFocused = (e: React.FocusEvent): void => { //applyDevTools.applyDevTools(this._editorView); - FormattedTextBox.Focused = this; this.ProseRef?.children[0] === e.nativeEvent.target && this._editorView && RichTextMenu.Instance?.updateMenu(this._editorView, undefined, this.props); this.startUndoTypingBatch(); }; - @observable public static Focused: FormattedTextBox | undefined; onClick = (e: React.MouseEvent): void => { if (!this.props.isContentActive()) return; @@ -1622,7 +1701,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps tr && this._editorView.dispatch(tr); } } - FormattedTextBox.Focused === this && (FormattedTextBox.Focused = undefined); if (RichTextMenu.Instance?.view === this._editorView && !this.props.isSelected(true)) { RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined); } @@ -1833,7 +1911,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps removeDocument={this.sidebarRemDocument} moveDocument={this.sidebarMoveDocument} addDocument={this.sidebarAddDocument} - CollectionView={undefined} ScreenToLocalTransform={this.sidebarScreenToLocal} renderDepth={this.props.renderDepth + 1} setHeight={this.setSidebarHeight} @@ -1933,7 +2010,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps paddingRight: StrCast(this.layoutDoc._textBoxPaddingX, `${paddingX - selPad}px`), paddingTop: StrCast(this.layoutDoc._textBoxPaddingY, `${paddingY - selPad}px`), paddingBottom: StrCast(this.layoutDoc._textBoxPaddingY, `${paddingY - selPad}px`), - pointerEvents: !active && !SnappingManager.GetIsDragging() ? (this.layoutDoc.isLinkButton ? 'none' : undefined) : undefined, + pointerEvents: !active && !SnappingManager.GetIsDragging() ? (IsFollowLinkScript(this.layoutDoc.onClick) ? 'none' : undefined) : undefined, }} /> </div> diff --git a/src/client/views/nodes/trails/PresBox.tsx b/src/client/views/nodes/trails/PresBox.tsx index 3589a9065..f8c47aafe 100644 --- a/src/client/views/nodes/trails/PresBox.tsx +++ b/src/client/views/nodes/trails/PresBox.tsx @@ -527,7 +527,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { bestTarget._dataTransition = `all ${transTime}ms`; const fkey = Doc.LayoutFieldKey(bestTarget); Doc.GetProto(bestTarget)[fkey] = activeItem.presData instanceof ObjectField ? activeItem.presData[Copy]() : activeItem.presData; - bestTarget[fkey + '-useAlt'] = activeItem.presUseAlt; + bestTarget[fkey + '-usePath'] = activeItem.presUsePath; setTimeout(() => (bestTarget._dataTransition = undefined), transTime + 10); } if ((pinDataTypes?.textview && activeItem.presData !== undefined) || (!pinDataTypes && activeItem.presData !== undefined)) { @@ -613,7 +613,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { pinProps?.activeFrame !== undefined; const fkey = Doc.LayoutFieldKey(targetDoc); if (pinProps.pinData.dataview) { - pinDoc.presUseAlt = targetDoc[fkey + '-useAlt']; + pinDoc.presUsePath = targetDoc[fkey + '-usePath']; pinDoc.presData = targetDoc[fkey] instanceof ObjectField ? (targetDoc[fkey] as ObjectField)[Copy]() : targetDoc.data; } if (pinProps.pinData.dataannos) { @@ -2457,7 +2457,6 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { {mode !== CollectionViewType.Invalid ? ( <CollectionView {...this.props} - ContainingCollectionDoc={this.props.Document} PanelWidth={this.props.PanelWidth} PanelHeight={this.panelHeight} childIgnoreNativeSize={true} diff --git a/src/client/views/nodes/trails/PresElementBox.tsx b/src/client/views/nodes/trails/PresElementBox.tsx index 9a74b5dba..502eef373 100644 --- a/src/client/views/nodes/trails/PresElementBox.tsx +++ b/src/client/views/nodes/trails/PresElementBox.tsx @@ -39,7 +39,7 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps>() { // Idea: this boolean will determine whether to automatically show the video when this preselement is selected. // @observable static showVideo: boolean = false; @computed get indexInPres() { - return DocListCast(this.presBox[StrCast(this.presBox.presFieldKey, 'data')]).indexOf(this.rootDoc); + return DocListCast(this.presBox?.[StrCast(this.presBox.presFieldKey, 'data')]).indexOf(this.rootDoc); } // the index field is where this document is in the presBox display list (since this value is different for each presentation element, the value can't be stored on the layout template which is used by all display elements) @computed get expandViewHeight() { return 100; @@ -51,11 +51,10 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps>() { return this.presBoxView?.selectedArray; } @computed get presBoxView() { - const vpath = this.props.docViewPath(); - return vpath.length > 1 ? (vpath[vpath.length - 2].ComponentView as PresBox) : undefined; + return this.props.DocumentView?.()?.props.docViewPath().lastElement()?.ComponentView as PresBox; } @computed get presBox() { - return this.props.ContainingCollectionDoc!; + return this.props.DocumentView?.().props.docViewPath().lastElement()?.rootDoc; } @computed get targetDoc() { return Cast(this.rootDoc.presentationTargetDoc, Doc, null) || this.rootDoc; @@ -97,7 +96,7 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps>() { return !this.rootDoc.presExpandInlineButton || !this.targetDoc ? null : ( <div className="presItem-embedded" style={{ height: this.embedHeight(), width: '50%' }}> <DocumentView - Document={this.rootDoc} + Document={PresBox.targetRenderedDoc(this.rootDoc)} DataDoc={undefined} //this.targetDoc[DataSym] !== this.targetDoc && this.targetDoc[DataSym]} PanelWidth={this.embedWidth} PanelHeight={this.embedHeight} @@ -110,14 +109,12 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps>() { docFilters={this.props.docFilters} docRangeFilters={this.props.docRangeFilters} searchFilterDocs={this.props.searchFilterDocs} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} rootSelected={returnTrue} addDocument={returnFalse} removeDocument={returnFalse} fitContentsToBox={returnTrue} moveDocument={this.props.moveDocument!} - focus={DocUtils.DefaultFocus} + focus={emptyFunction} whenChildContentsActiveChanged={returnFalse} addDocTab={returnFalse} pinToPres={returnFalse} @@ -195,7 +192,7 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps>() { const dragData = new DragManager.DocumentDragData(this.presBoxView?.sortArray() ?? []); if (!dragData.draggedDocuments.length) dragData.draggedDocuments.push(this.rootDoc); dragData.dropAction = 'move'; - dragData.treeViewDoc = this.presBox._viewType === CollectionViewType.Tree ? this.props.ContainingCollectionDoc : undefined; // this.props.DocumentView?.()?.props.treeViewDoc; + dragData.treeViewDoc = this.presBox?._viewType === CollectionViewType.Tree ? this.presBox : undefined; // this.props.DocumentView?.()?.props.treeViewDoc; dragData.moveDocument = this.props.moveDocument; const dragItem: HTMLElement[] = []; if (dragArray.length === 1) { @@ -269,7 +266,7 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps>() { @undoBatch removeItem = action((e: React.MouseEvent) => { e.stopPropagation(); - if (this.indexInPres < (this.presBoxView?.itemIndex || 0)) { + if (this.presBox && this.indexInPres < (this.presBoxView?.itemIndex || 0)) { this.presBox.itemIndex = (this.presBoxView?.itemIndex || 0) - 1; } this.props.removeDocument?.(this.rootDoc); @@ -406,15 +403,15 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps>() { @computed get toolbarWidth(): number { const presBoxDocView = DocumentManager.Instance.getDocumentView(this.presBox); - let width: number = NumCast(this.presBox._width); + let width: number = NumCast(this.presBox?._width); if (presBoxDocView) width = presBoxDocView.props.PanelWidth(); if (width === 0) width = 300; return width; } @computed get presButtons() { - const presBox: Doc = this.presBox; //presBox - const presBoxColor: string = StrCast(presBox._backgroundColor); + const presBox = this.presBox; //presBox + const presBoxColor: string = StrCast(presBox?._backgroundColor); const presColorBool: boolean = presBoxColor ? presBoxColor !== Colors.WHITE && presBoxColor !== 'transparent' : false; const targetDoc: Doc = this.targetDoc; const activeItem: Doc = this.rootDoc; @@ -494,10 +491,10 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps>() { @computed get mainItem() { const isSelected: boolean = this.selectedArray?.has(this.rootDoc) ? true : false; - const isCurrent: boolean = this.presBox._itemIndex === this.indexInPres; + const isCurrent: boolean = this.presBox?._itemIndex === this.indexInPres; const miniView: boolean = this.toolbarWidth <= 110; - const presBox: Doc = this.presBox; //presBox - const presBoxColor: string = StrCast(presBox._backgroundColor); + const presBox = this.presBox; //presBox + const presBoxColor: string = StrCast(presBox?._backgroundColor); const presColorBool: boolean = presBoxColor ? presBoxColor !== Colors.WHITE && presBoxColor !== 'transparent' : false; const activeItem: Doc = this.rootDoc; diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx index 7392d2706..d6dddf71a 100644 --- a/src/client/views/pdf/AnchorMenu.tsx +++ b/src/client/views/pdf/AnchorMenu.tsx @@ -10,8 +10,11 @@ import { SelectionManager } from '../../util/SelectionManager'; import { AntimodeMenu, AntimodeMenuProps } from '../AntimodeMenu'; import { LinkPopup } from '../linking/LinkPopup'; import { ButtonDropdown } from '../nodes/formattedText/RichTextMenu'; -import './AnchorMenu.scss'; +import { gptAPICall, GPTCallType } from '../../apis/gpt/GPT'; +import { GPTPopup, GPTPopupMode } from './GPTPopup/GPTPopup'; import { LightboxView } from '../LightboxView'; +import { EditorView } from 'prosemirror-view'; +import './AnchorMenu.scss'; @observer export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { @@ -43,6 +46,56 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { @observable public Status: 'marquee' | 'annotation' | '' = ''; + // GPT additions + @observable private GPTpopupText: string = ''; + @observable private loadingGPT: boolean = false; + @observable private showGPTPopup: boolean = false; + @observable private GPTMode: GPTPopupMode = GPTPopupMode.SUMMARY; + @observable private selectedText: string = ''; + @observable private editorView?: EditorView; + @observable private textDoc?: Doc; + @observable private highlightRange: number[] | undefined; + private selectionRange: number[] | undefined; + + @action + setGPTPopupVis = (vis: boolean) => { + this.showGPTPopup = vis; + }; + @action + setGPTMode = (mode: GPTPopupMode) => { + this.GPTMode = mode; + }; + + @action + setGPTPopupText = (txt: string) => { + this.GPTpopupText = txt; + }; + + @action + setLoading = (loading: boolean) => { + this.loadingGPT = loading; + }; + + @action + setHighlightRange(r: number[] | undefined) { + this.highlightRange = r; + } + + @action + public setSelectedText = (txt: string) => { + this.selectedText = txt; + }; + + @action + public setEditorView = (editor: EditorView) => { + this.editorView = editor; + }; + + @action + public setTextDoc = (textDoc: Doc) => { + this.textDoc = textDoc; + }; + public onMakeAnchor: () => Opt<Doc> = () => undefined; // Method to get anchor from text search public OnCrop: (e: PointerEvent) => void = unimplementedFunction; @@ -76,18 +129,94 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { componentDidMount() { this._disposer2 = reaction( () => this._opacity, - opacity => !opacity && (this._showLinkPopup = false), + opacity => { + if (!opacity) { + this._showLinkPopup = false; + this.setGPTPopupVis(false); + this.setGPTPopupText(''); + } + }, { fireImmediately: true } ); this._disposer = reaction( () => SelectionManager.Views().slice(), selected => { this._showLinkPopup = false; + this.setGPTPopupVis(false); + this.setGPTPopupText(''); AnchorMenu.Instance.fadeOut(true); } ); } + /** + * Invokes the API with the selected text and stores it in the summarized text. + * @param e pointer down event + */ + gptSummarize = async (e: React.PointerEvent) => { + this.setHighlightRange(undefined); + this.setGPTPopupVis(true); + this.setGPTMode(GPTPopupMode.SUMMARY); + this.setLoading(true); + + try { + const res = await gptAPICall(this.selectedText, GPTCallType.SUMMARY); + if (res) { + this.setGPTPopupText(res); + } else { + this.setGPTPopupText('Something went wrong.'); + } + } catch (err) { + console.error(err); + } + + this.setLoading(false); + }; + + /** + * Makes a GPT call to edit selected text. + * @returns nothing + */ + gptEdit = async () => { + if (!this.editorView) return; + this.setHighlightRange(undefined); + const state = this.editorView.state; + const sel = state.selection; + const fullText = state.doc.textBetween(0, this.editorView.state.doc.content.size, ' \n'); + const selectedText = state.doc.textBetween(sel.from, sel.to); + + this.setGPTPopupVis(true); + this.setGPTMode(GPTPopupMode.EDIT); + this.setLoading(true); + + try { + let res = await gptAPICall(selectedText, GPTCallType.EDIT); + // let res = await this.mockGPTCall(); + if (!res) return; + res = res.trim(); + const resultText = fullText.slice(0, sel.from - 1) + res + fullText.slice(sel.to - 1); + + if (res) { + this.setGPTPopupText(resultText); + this.setHighlightRange([sel.from - 1, sel.from - 1 + res.length]); + } else { + this.setGPTPopupText('Something went wrong.'); + } + } catch (err) { + console.error(err); + } + + this.setLoading(false); + }; + + /** + * Replaces text suggestions from GPT. + */ + replaceText = (replacement: string) => { + if (!this.editorView || !this.textDoc) return; + this.textDoc.text = replacement; + }; + pointerDown = (e: React.PointerEvent) => { setupMoveUpEvents( this, @@ -180,6 +309,31 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { this.highlightColor = Utils.colorString(col); }; + /** + * Returns whether the selected text can be summarized. The goal is to have + * all selected text available to summarize but its only supported for pdf and web ATM. + * @returns Whether the GPT icon for summarization should appear + */ + canSummarize = (): boolean => { + const docs = SelectionManager.Docs(); + if (docs.length > 0) { + return docs.some(doc => doc.type === 'pdf' || doc.type === 'web'); + } + return false; + }; + + /** + * Returns whether the selected text can be edited. + * @returns Whether the GPT icon for summarization should appear + */ + canEdit = (): boolean => { + const docs = SelectionManager.Docs(); + if (docs.length > 0) { + return docs.some(doc => doc.type === 'rtf'); + } + return false; + }; + render() { const buttons = this.Status === 'marquee' ? ( @@ -190,6 +344,25 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { <FontAwesomeIcon icon="comment-alt" size="lg" /> </button> </Tooltip> + {/* GPT Summarize icon only shows up when text is highlighted, not on marquee selection*/} + {AnchorMenu.Instance.StartCropDrag === unimplementedFunction && this.canSummarize() && ( + <Tooltip key="gpt" title={<div className="dash-tooltip">Summarize with AI</div>}> + <button className="antimodeMenu-button annotate" onPointerDown={this.gptSummarize} style={{ cursor: 'grab' }}> + <FontAwesomeIcon icon="comment-dots" size="lg" /> + </button> + </Tooltip> + )} + <GPTPopup + key="gptpopup" + visible={this.showGPTPopup} + text={this.GPTpopupText} + highlightRange={this.highlightRange} + loading={this.loadingGPT} + callSummaryApi={this.gptSummarize} + callEditApi={this.gptEdit} + replaceText={this.replaceText} + mode={this.GPTMode} + /> {AnchorMenu.Instance.OnAudio === unimplementedFunction ? null : ( <Tooltip key="annoaudiotate" title={<div className="dash-tooltip">Click to Record Annotation</div>}> <button className="antimodeMenu-button annotate" onPointerDown={this.audioDown} style={{ cursor: 'grab' }}> @@ -197,6 +370,13 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { </button> </Tooltip> )} + {this.canEdit() && ( + <Tooltip key="gpttextedit" title={<div className="dash-tooltip">AI edit suggestions</div>}> + <button className="antimodeMenu-button annotate" onPointerDown={this.gptEdit} style={{ cursor: 'grab' }}> + <FontAwesomeIcon icon="pencil-alt" size="lg" /> + </button> + </Tooltip> + )} <Tooltip key="link" title={<div className="dash-tooltip">Find document to link to selected text</div>}> <button className="antimodeMenu-button link" onPointerDown={this.toggleLinkPopup}> <FontAwesomeIcon style={{ position: 'absolute', transform: 'scale(1.5)' }} icon={'search'} size="lg" /> diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.scss b/src/client/views/pdf/GPTPopup/GPTPopup.scss new file mode 100644 index 000000000..44413ede7 --- /dev/null +++ b/src/client/views/pdf/GPTPopup/GPTPopup.scss @@ -0,0 +1,132 @@ +$textgrey: #707070; +$lighttextgrey: #a3a3a3; +$greyborder: #d3d3d3; +$lightgrey: #ececec; +$button: #5b97ff; +$highlightedText: #82e0ff; + +.summary-box { + display: flex; + flex-direction: column; + justify-content: space-between; + background-color: #ffffff; + box-shadow: 0 2px 5px #7474748d; + color: $textgrey; + position: fixed; + bottom: 10px; + right: 10px; + width: 250px; + min-height: 200px; + border-radius: 15px; + padding: 15px; + padding-bottom: 0; + z-index: 999; + + .summary-heading { + display: flex; + align-items: center; + border-bottom: 1px solid $greyborder; + padding-bottom: 5px; + + .summary-text { + font-size: 12px; + font-weight: 500; + } + } + + label { + color: $textgrey; + font-size: 12px; + font-weight: 400; + letter-spacing: 1px; + margin: 0; + padding-right: 5px; + } + + a { + cursor: pointer; + } + + .content-wrapper { + padding-top: 10px; + min-height: 50px; + max-height: 150px; + overflow-y: auto; + } + + .btns-wrapper { + height: 50px; + display: flex; + justify-content: space-between; + align-items: center; + + .summarizing { + display: flex; + align-items: center; + } + } + + button { + font-size: 9px; + padding: 10px; + color: #ffffff; + background-color: $button; + border-radius: 5px; + } + + .text-btn { + &:hover { + background-color: $button; + } + } + + .btn-secondary { + font-size: 8px; + padding: 10px 5px; + background-color: $lightgrey; + color: $textgrey; + &:hover { + background-color: $lightgrey; + } + } + + .icon-btn { + background-color: #ffffff; + padding: 10px; + border-radius: 50%; + color: $button; + border: 1px solid $button; + } + + .ai-warning { + padding: 10px 0; + font-size: 10px; + color: $lighttextgrey; + border-top: 1px solid $greyborder; + } + + .highlighted-text { + background-color: $highlightedText; + } +} + +// Typist CSS +.Typist .Cursor { + display: inline-block; +} +.Typist .Cursor--blinking { + opacity: 1; + animation: blink 1s linear infinite; +} + +@keyframes blink { + 0% { + opacity: 1; + } + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } +} diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.tsx b/src/client/views/pdf/GPTPopup/GPTPopup.tsx new file mode 100644 index 000000000..cc0072a94 --- /dev/null +++ b/src/client/views/pdf/GPTPopup/GPTPopup.tsx @@ -0,0 +1,188 @@ +import React = require('react'); +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { action, observable } from 'mobx'; +import { observer } from 'mobx-react'; +import ReactLoading from 'react-loading'; +import Typist from 'react-typist'; +import { Doc } from '../../../../fields/Doc'; +import { Docs } from '../../../documents/Documents'; +import './GPTPopup.scss'; + +export enum GPTPopupMode { + SUMMARY, + EDIT, +} + +interface GPTPopupProps { + visible: boolean; + text: string; + loading: boolean; + mode: GPTPopupMode; + callSummaryApi: (e: React.PointerEvent) => Promise<void>; + callEditApi: (e: React.PointerEvent) => Promise<void>; + replaceText: (replacement: string) => void; + highlightRange?: number[]; +} + +@observer +export class GPTPopup extends React.Component<GPTPopupProps> { + static Instance: GPTPopup; + + @observable + private done: boolean = false; + @observable + private sidebarId: string = ''; + + @action + public setDone = (done: boolean) => { + this.done = done; + }; + @action + public setSidebarId = (id: string) => { + this.sidebarId = id; + }; + + public addDoc: (doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean = () => false; + + /** + * Transfers the summarization text to a sidebar annotation text document. + */ + private transferToText = () => { + const newDoc = Docs.Create.TextDocument(this.props.text.trim(), { + _width: 200, + _height: 50, + _fitWidth: true, + _autoHeight: true, + }); + this.addDoc(newDoc, this.sidebarId); + }; + + constructor(props: GPTPopupProps) { + super(props); + GPTPopup.Instance = this; + } + + componentDidUpdate = () => { + if (this.props.loading) { + this.setDone(false); + } + }; + + summaryBox = () => ( + <> + <div> + {this.heading('SUMMARY')} + <div className="content-wrapper"> + {!this.props.loading && + (!this.done ? ( + <Typist + key={this.props.text} + avgTypingDelay={15} + cursor={{ hideWhenDone: true }} + onTypingDone={() => { + setTimeout(() => { + this.setDone(true); + }, 500); + }}> + {this.props.text} + </Typist> + ) : ( + this.props.text + ))} + </div> + </div> + {!this.props.loading && ( + <div className="btns-wrapper"> + {this.done ? ( + <> + <button className="icon-btn" onPointerDown={e => this.props.callSummaryApi(e)}> + <FontAwesomeIcon icon="redo-alt" size="lg" /> + </button> + <button + className="text-btn" + onClick={e => { + this.transferToText(); + }}> + Transfer to Text + </button> + </> + ) : ( + <div className="summarizing"> + <span>Summarizing</span> + <ReactLoading type="bubbles" color="#bcbcbc" width={20} height={20} /> + <button + className="btn-secondary" + onClick={e => { + this.setDone(true); + }}> + Stop Animation + </button> + </div> + )} + </div> + )} + </> + ); + + editBox = () => { + const hr = this.props.highlightRange; + return ( + <> + <div> + {this.heading('TEXT EDIT SUGGESTIONS')} + <div className="content-wrapper"> + {hr && ( + <div> + {this.props.text.slice(0, hr[0])} <span className="highlighted-text">{this.props.text.slice(hr[0], hr[1])}</span> {this.props.text.slice(hr[1])} + </div> + )} + </div> + </div> + {hr && !this.props.loading && ( + <> + <div className="btns-wrapper"> + <> + <button className="icon-btn" onPointerDown={e => this.props.callEditApi(e)}> + <FontAwesomeIcon icon="redo-alt" size="lg" /> + </button> + <button + className="text-btn" + onClick={e => { + this.props.replaceText(this.props.text); + }}> + Replace Text + </button> + </> + </div> + {this.aiWarning()} + </> + )} + </> + ); + }; + + aiWarning = () => + this.done ? ( + <div className="ai-warning"> + <FontAwesomeIcon icon="exclamation-circle" size="sm" style={{ paddingRight: '5px' }} /> + AI generated responses can contain inaccurate or misleading content. + </div> + ) : ( + <></> + ); + + heading = (headingText: string) => ( + <div className="summary-heading"> + <label className="summary-text">{headingText}</label> + {this.props.loading && <ReactLoading type="spin" color="#bcbcbc" width={14} height={14} />} + </div> + ); + + render() { + return ( + <div className="summary-box" style={{ display: this.props.visible ? 'flex' : 'none' }}> + {this.props.mode === GPTPopupMode.SUMMARY ? this.summaryBox() : this.editBox()} + </div> + ); + } +} diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx index 6ab541207..68241e61f 100644 --- a/src/client/views/pdf/PDFViewer.tsx +++ b/src/client/views/pdf/PDFViewer.tsx @@ -24,6 +24,7 @@ import { AnchorMenu } from './AnchorMenu'; import { Annotation } from './Annotation'; import './PDFViewer.scss'; import React = require('react'); +import { GPTPopup } from './GPTPopup/GPTPopup'; const PDFJSViewer = require('pdfjs-dist/web/pdf_viewer'); const pdfjsLib = require('pdfjs-dist'); const _global = (window /* browser */ || global) /* node */ as any; @@ -40,9 +41,10 @@ interface IViewerProps extends FieldViewProps { fieldKey: string; pdf: Pdfjs.PDFDocumentProxy; url: string; + sidebarAddDoc: (doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean; loaded?: (nw: number, nh: number, np: number) => void; setPdfViewer: (view: PDFViewer) => void; - anchorMenuClick?: () => undefined | ((anchor: Doc) => void); + anchorMenuClick?: () => undefined | ((anchor: Doc, summarize?: boolean) => void); crop: (region: Doc | undefined, addCrop?: boolean) => Doc | undefined; } @@ -409,10 +411,18 @@ export class PDFViewer extends React.Component<IViewerProps> { document.removeEventListener('pointerup', this.onSelectEnd); const sel = window.getSelection(); + if (sel) { + AnchorMenu.Instance.setSelectedText(sel.toString()); + } + if (sel?.type === 'Range') { this.createTextAnnotation(sel, sel.getRangeAt(0)); AnchorMenu.Instance.jumpTo(e.clientX, e.clientY); } + + // Changing which document to add the annotation to (the currently selected PDF) + GPTPopup.Instance.setSidebarId('data-sidebar'); + GPTPopup.Instance.addDoc = this.props.sidebarAddDoc; }; @action @@ -422,7 +432,7 @@ export class PDFViewer extends React.Component<IViewerProps> { const clientRects = selRange.getClientRects(); for (let i = 0; i < clientRects.length; i++) { const rect = clientRects.item(i); - if (rect && rect.width !== this._mainCont.current.clientWidth && rect.width) { + if (rect?.width && rect.width < this._mainCont.current.clientWidth / this.props.ScreenToLocalTransform().Scale) { const scaleX = this._mainCont.current.offsetWidth / boundingRect.width; const pdfScale = NumCast(this.props.layoutDoc._viewScale, 1); const annoBox = document.createElement('div'); @@ -530,7 +540,6 @@ export class PDFViewer extends React.Component<IViewerProps> { renderDepth={this.props.renderDepth + 1} isAnnotationOverlay={true} fieldKey={this.props.fieldKey + '-annotations'} - CollectionView={undefined} getScrollHeight={this.getScrollHeight} setPreviewCursor={this.setPreviewCursor} setBrushViewer={this.setBrushViewer} diff --git a/src/client/views/topbar/TopBar.tsx b/src/client/views/topbar/TopBar.tsx index f2e9be61d..d63c25dbe 100644 --- a/src/client/views/topbar/TopBar.tsx +++ b/src/client/views/topbar/TopBar.tsx @@ -46,7 +46,7 @@ export class TopBar extends React.Component { return ( <div className="topbar-left"> {Doc.ActiveDashboard ? ( - <IconButton onClick={this.navigateToHome} icon={<FontAwesomeIcon icon="home" />} isCircle={true} hoverStyle="gray" color={this.textColor} /> + <IconButton onClick={this.navigateToHome} icon={<FontAwesomeIcon icon="home" />} color={this.textColor} /> ) : ( <div className="logo-container"> <img className="logo" src="/assets/medium-blue-light-blue-circle.png" alt="dash logo"></img> @@ -55,18 +55,7 @@ export class TopBar extends React.Component { </div> )} {Doc.ActiveDashboard && ( - <Button - text="Explore" - tooltip="Browsing mode for directly navigating to documents" - fontSize={FontSize.SECONDARY} - isActive={MainView.Instance._exploreMode} - size={Size.SMALL} - color={this.textColor} - borderRadius={5} - hoverStyle="gray" - iconPosition="right" - onClick={action(() => (MainView.Instance._exploreMode = !MainView.Instance._exploreMode))} - /> + <Button text="Explore" tooltip="Browsing mode for directly navigating to documents" size={Size.SMALL} color={this.textColor} onClick={action(() => (MainView.Instance._exploreMode = !MainView.Instance._exploreMode))} /> )} </div> ); @@ -94,13 +83,8 @@ export class TopBar extends React.Component { <Button text={StrCast(Doc.ActiveDashboard.title)} tooltip="Browsing mode for directly navigating to documents" - fontSize={FontSize.SECONDARY} size={Size.SMALL} color={'white'} - type="outline" - backgroundColor={Colors.MEDIUM_BLUE} - borderRadius={5} - hoverStyle="none" onClick={(e: React.MouseEvent) => { const dashView = Doc.ActiveDashboard && DocumentManager.Instance.getDocumentView(Doc.ActiveDashboard); ContextMenu.Instance.addItem({ description: 'Open Dashboard View', event: this.navigateToHome, icon: 'edit' }); @@ -121,21 +105,13 @@ export class TopBar extends React.Component { onClick={() => { SharingManager.Instance.open(undefined, Doc.ActiveDashboard); }} - type="outline" - fontSize={FontSize.SECONDARY} size={Size.SMALL} - borderRadius={5} - hoverStyle="gray" /> {!Doc.noviceMode && ( <IconButton - fontSize={FontSize.SECONDARY} - isCircle={true} tooltip="Work on a copy of the dashboard layout" size={Size.SMALL} color={this.textColor} - borderRadius={Borders.STANDARD} - hoverStyle="gray" onClick={async () => { const batch = UndoManager.StartBatch('snapshot'); await DashboardView.snapshotDashboard(); @@ -156,26 +132,9 @@ export class TopBar extends React.Component { @computed get topbarRight() { return ( <div className="topbar-right"> - <IconButton size={Size.SMALL} isCircle={true} color={Colors.LIGHT_GRAY} backgroundColor={Colors.DARK_GRAY} hoverStyle="gray" onClick={() => ReportManager.Instance.open()} icon={<FaBug />} /> - <IconButton - size={Size.SMALL} - isCircle={true} - color={Colors.LIGHT_GRAY} - backgroundColor={Colors.DARK_GRAY} - hoverStyle="gray" - onClick={() => window.open('https://brown-dash.github.io/Dash-Documentation/', '_blank')} - icon={<FontAwesomeIcon icon="question-circle" />} - /> - <IconButton - size={Size.SMALL} - isCircle={true} - color={Colors.LIGHT_GRAY} - backgroundColor={Colors.DARK_GRAY} - hoverStyle="gray" - isActive={SettingsManager.Instance.isOpen} - onClick={() => SettingsManager.Instance.open()} - icon={<FontAwesomeIcon icon="cog" />} - /> + <IconButton size={Size.SMALL} color={Colors.LIGHT_GRAY} onClick={() => ReportManager.Instance.open()} icon={<FaBug />} /> + <IconButton size={Size.SMALL} color={Colors.LIGHT_GRAY} onClick={() => window.open('https://brown-dash.github.io/Dash-Documentation/', '_blank')} icon={<FontAwesomeIcon icon="question-circle" />} /> + <IconButton size={Size.SMALL} color={Colors.LIGHT_GRAY} onClick={() => SettingsManager.Instance.open()} icon={<FontAwesomeIcon icon="cog" />} /> {/* <Button text={'Logout'} borderRadius={5} hoverStyle={'gray'} backgroundColor={Colors.DARK_GRAY} color={this.textColor} fontSize={FontSize.SECONDARY} onClick={() => window.location.assign(Utils.prepend('/logout'))} /> */} </div> ); diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts index 1db56cbdd..b0033b977 100644 --- a/src/fields/Doc.ts +++ b/src/fields/Doc.ts @@ -96,6 +96,7 @@ export const HighlightSym = Symbol('Highlight'); export const DataSym = Symbol('Data'); export const LayoutSym = Symbol('Layout'); export const FieldsSym = Symbol('Fields'); +export const CssSym = Symbol('Css'); export const AclSym = Symbol('Acl'); export const DirectLinksSym = Symbol('DirectLinks'); export const AclUnset = Symbol('AclUnset'); @@ -341,6 +342,7 @@ export class Doc extends RefField { @observable private ___fieldKeys: any = {}; /// all of the raw acl's that have been set on this document. Use GetEffectiveAcl to determine the actual ACL of the doc for editing @observable public [AclSym]: { [key: string]: symbol } = {}; + @observable public [CssSym]: number = 0; // incrementer denoting a change to CSS layout @observable public [DirectLinksSym]: Set<Doc> = new Set(); @observable public [AnimationSym]: Opt<Doc>; @observable public [HighlightSym]: boolean = false; @@ -698,12 +700,12 @@ export namespace Doc { // this lists out all the tag ids that can be in a RichTextField that might contain document ids. // if a document is cloned, we need to make sure to clone all of these referenced documents as well; export const DocsInTextFieldIds = ['audioId', 'textId', 'anchorId', 'docId']; - export async function makeClone(doc: Doc, cloneMap: Map<string, Doc>, linkMap: Map<string, Doc>, rtfs: { copy: Doc; key: string; field: RichTextField }[], exclusions: string[], cloneLinks: boolean): Promise<Doc> { + export async function makeClone(doc: Doc, cloneMap: Map<string, Doc>, linkMap: Map<string, Doc>, rtfs: { copy: Doc; key: string; field: RichTextField }[], exclusions: string[], pruneDocs: Doc[], cloneLinks: boolean): Promise<Doc> { if (Doc.IsBaseProto(doc)) return doc; if (cloneMap.get(doc[Id])) return cloneMap.get(doc[Id])!; const copy = new Doc(undefined, true); cloneMap.set(doc[Id], copy); - const filter = [...exclusions, ...Cast(doc.cloneFieldFilter, listSpec('string'), [])]; + const filter = [...exclusions, ...StrListCast(doc.cloneFieldFilter)]; await Promise.all( Object.keys(doc).map(async key => { if (filter.includes(key)) return; @@ -714,7 +716,7 @@ export namespace Doc { const list = await Cast(doc[key], listSpec(Doc)); const docs = list && (await DocListCastAsync(list))?.filter(d => d instanceof Doc); if (docs !== undefined && docs.length) { - const clones = await Promise.all(docs.map(async d => Doc.makeClone(d, cloneMap, linkMap, rtfs, exclusions, cloneLinks))); + const clones = await Promise.all(docs.map(async d => Doc.makeClone(d, cloneMap, linkMap, rtfs, exclusions, pruneDocs, cloneLinks))); assignKey(new List<Doc>(clones)); } else { assignKey(ObjectField.MakeCopy(field)); @@ -723,15 +725,13 @@ export namespace Doc { const docidsearch = new RegExp('(' + DocsInTextFieldIds.map(exp => '(' + exp + ')').join('|') + ')":"([a-z-A-Z0-9_]*)"', 'g'); const rawdocids = field.Data.match(docidsearch); const docids = rawdocids?.map((str: string) => - DocsInTextFieldIds.reduce((output, exp) => { - return output.replace(new RegExp(`${exp}":`, 'g'), ''); - }, str) + DocsInTextFieldIds.reduce((output, exp) => output.replace(new RegExp(`${exp}":`, 'g'), ''), str) .replace(/"/g, '') .trim() ); const results = docids && (await DocServer.GetRefFields(docids)); const docs = results && Array.from(Object.keys(results)).map(key => DocCast(results[key])); - docs && docs.map(doc => Doc.makeClone(doc, cloneMap, linkMap, rtfs, exclusions, cloneLinks)); + docs?.map(doc => doc && Doc.makeClone(doc, cloneMap, linkMap, rtfs, exclusions, pruneDocs, cloneLinks)); rtfs.push({ copy, key, field }); } } @@ -739,16 +739,17 @@ export namespace Doc { }; const docAtKey = doc[key]; if (docAtKey instanceof Doc) { - if (!Doc.IsSystem(docAtKey) && (key.startsWith('layout') || key === 'annotationOn' || key === 'proto' || ((key === 'anchor1' || key === 'anchor2') && doc.author === Doc.CurrentUserEmail))) { - assignKey(await Doc.makeClone(docAtKey, cloneMap, linkMap, rtfs, exclusions, cloneLinks)); + if (pruneDocs.includes(docAtKey)) { + // prune doc and do nothing + } else if (!Doc.IsSystem(docAtKey) && (key.startsWith('layout') || ['context', 'annotationOn', 'proto'].includes(key) || ((key === 'anchor1' || key === 'anchor2') && doc.author === Doc.CurrentUserEmail))) { + assignKey(await Doc.makeClone(docAtKey, cloneMap, linkMap, rtfs, exclusions, pruneDocs, cloneLinks)); } else { assignKey(docAtKey); } } else if (field instanceof RefField) { assignKey(field); } else if (cfield instanceof ComputedField) { - assignKey(cfield[Copy]()); - // ComputedField.MakeFunction(cfield.script.originalScript)); + assignKey(cfield[Copy]()); // ComputedField.MakeFunction(cfield.script.originalScript)); } else if (field instanceof ObjectField) { await copyObjectField(field); } else if (field instanceof Promise) { @@ -763,7 +764,7 @@ export namespace Doc { cloneLinks || ((cloneMap.has(DocCast(link.anchor1)?.[Id]) || cloneMap.has(DocCast(DocCast(link.anchor1)?.annotationOn)?.[Id])) && (cloneMap.has(DocCast(link.anchor2)?.[Id]) || cloneMap.has(DocCast(DocCast(link.anchor2)?.annotationOn)?.[Id]))) ) { - linkMap.set(link[Id], await Doc.makeClone(link, cloneMap, linkMap, rtfs, exclusions, cloneLinks)); + linkMap.set(link[Id], await Doc.makeClone(link, cloneMap, linkMap, rtfs, exclusions, pruneDocs, cloneLinks)); } }); Doc.SetInPlace(copy, 'title', 'CLONE: ' + doc.title, true); @@ -799,11 +800,9 @@ export namespace Doc { export async function MakeClone(doc: Doc, cloneLinks = true, cloneMap: Map<string, Doc> = new Map()) { const linkMap = new Map<string, Doc>(); const rtfMap: { copy: Doc; key: string; field: RichTextField }[] = []; - const copy = await Doc.makeClone(doc, cloneMap, linkMap, rtfMap, ['cloneOf'], cloneLinks); + const copy = await Doc.makeClone(doc, cloneMap, linkMap, rtfMap, ['cloneOf'], doc.context ? [DocCast(doc.context)] : [], cloneLinks); const repaired = new Set<Doc>(); const linkedDocs = Array.from(linkMap.values()); - const clonedDocs = [...Array.from(cloneMap.values()), ...linkedDocs]; - clonedDocs.map(clone => Doc.repairClone(clone, cloneMap, repaired)); linkedDocs.map((link: Doc) => LinkManager.Instance.addLink(link, true)); rtfMap.map(({ copy, key, field }) => { const replacer = (match: any, attr: string, id: string, offset: any, string: any) => { @@ -814,104 +813,79 @@ export namespace Doc { const mapped = cloneMap.get(id); return href + (mapped ? mapped[Id] : id); }; - const regex = `(${Doc.localServerPath()})([^"]*)`; - const re = new RegExp(regex, 'g'); + const re = new RegExp(`(${Doc.localServerPath()})([^"]*)`, 'g'); const docidsearch = new RegExp('(' + DocsInTextFieldIds.map(exp => `"${exp}":`).join('|') + ')"([^"]+)"', 'g'); copy[key] = new RichTextField(field.Data.replace(docidsearch, replacer).replace(re, replacer2), field.Text); }); + const clonedDocs = [...Array.from(cloneMap.values()), ...linkedDocs]; + clonedDocs.map(clone => Doc.repairClone(clone, cloneMap, repaired)); return { clone: copy, map: cloneMap, linkMap }; } - export async function Zip(doc: Doc) { - // const a = document.createElement("a"); - // const url = Utils.prepend(`/downloadId/${this.props.Document[Id]}`); - // a.href = url; - // a.download = `DocExport-${this.props.Document[Id]}.zip`; - // a.click(); + export async function Zip(doc: Doc, zipFilename = 'dashExport.zip') { const { clone, map, linkMap } = await Doc.MakeClone(doc); - clone.LINKS = new List<Doc>(Array.from(linkMap.values())); const proms = [] as string[]; function replacer(key: any, value: any) { if (key && ['branchOf', 'cloneOf', 'cursors'].includes(key)) return undefined; else if (value instanceof Doc) { if (key !== 'field' && Number.isNaN(Number(key))) { - const __fields = value[FieldsSym](); - return { id: value[Id], __type: 'Doc', fields: __fields }; - } else { - return { fieldId: value[Id], __type: 'proxy' }; + return { id: value[Id], __type: 'Doc', fields: value[FieldsSym]() }; } - } else if (value instanceof ScriptField) return { script: value.script, __type: 'script' }; - else if (value instanceof RichTextField) return { Data: value.Data, Text: value.Text, __type: 'RichTextField' }; - else if (value instanceof ImageField) { + return { fieldId: value[Id], __type: 'proxy' }; + } else if (value instanceof ImageField) { const extension = value.url.href.replace(/.*\./, ''); proms.push(value.url.href.replace('.' + extension, '_o.' + extension)); return { url: value.url.href, __type: 'image' }; } else if (value instanceof PdfField) { proms.push(value.url.href); return { url: value.url.href, __type: 'pdf' }; - } else if (value instanceof AudioField) return { url: value.url.href, __type: 'audio' }; - else if (value instanceof VideoField) return { url: value.url.href, __type: 'video' }; + } else if (value instanceof AudioField) { + proms.push(value.url.href); + return { url: value.url.href, __type: 'audio' }; + } else if (value instanceof VideoField) { + proms.push(value.url.href); + return { url: value.url.href, __type: 'video' }; + } else if (value instanceof ScriptField) return { script: value.script, __type: 'script' }; + else if (value instanceof RichTextField) return { Data: value.Data, Text: value.Text, __type: 'RichTextField' }; else if (value instanceof WebField) return { url: value.url.href, __type: 'web' }; else if (value instanceof MapField) return { url: value.url.href, __type: 'map' }; else if (value instanceof DateField) return { date: value.toString(), __type: 'date' }; else if (value instanceof ProxyField) return { fieldId: value.fieldId, __type: 'proxy' }; else if (value instanceof Array && key !== 'fields') return { fields: value, __type: 'list' }; else if (value instanceof ComputedField) return { script: value.script, __type: 'computed' }; - else return value; + return value; } const docs: { [id: string]: any } = {}; + const links: { [id: string]: any } = {}; Array.from(map.entries()).forEach(f => (docs[f[0]] = f[1])); - const docString = JSON.stringify({ id: clone[Id], docs }, decycle(replacer)); - - let generateZIP = (proms: string[]) => { - var zip = new JSZip(); - var count = 0; - var zipFilename = 'dashExport.zip'; - - proms - .filter(url => url.startsWith(window.location.origin)) - .forEach((url, i) => { - var filename = proms[i].replace(window.location.origin + '/', '').replace(/\//g, '%%%'); - // loading a file and add it in a zip file - JSZipUtils.getBinaryContent(url, function (err: any, data: any) { - if (err) { - throw err; // or handle the error - } - zip.file(filename, data, { binary: true }); - count++; - if (count == proms.length) { - zip.file('doc.json', docString); - zip.generateAsync({ type: 'blob' }).then(function (content) { - saveAs(content, zipFilename); - }); - } - }); - }); - }; - generateZIP(proms); - const zip = new JSZip(); - - zip.file('doc.json', docString); - - // // Generate a directory within the Zip file structure - // var img = zip.folder("images"); - - // // Add a file to the directory, in this case an image with data URI as contents - // img.file("smile.gif", imgData, {base64: true}); + Array.from(linkMap.entries()).forEach(l => (links[l[0]] = l[1])); + const jsonDocs = JSON.stringify({ id: clone[Id], docs, links }, decycle(replacer)); - // Generate the zip file asynchronously - zip.generateAsync({ type: 'blob' }).then((content: any) => { - // Force down of the Zip file - saveAs(content, doc.title + '.zip'); // glr: Possibly change the name of the document to match the title? - }); - } - // - // Determines whether the layout needs to be expanded (as a template). - // template expansion is rquired when the layout is a template doc/field and there's a datadoc which isn't equal to the layout template - // - export function WillExpandTemplateLayout(layoutDoc: Doc, dataDoc?: Doc) { - return (layoutDoc.isTemplateForField || layoutDoc.isTemplateDoc) && dataDoc && layoutDoc !== dataDoc; + const zip = new JSZip(); + var count = 0; + proms + .filter(url => url.startsWith(window.location.origin)) + .forEach((url, i) => { + // loading a file and add it in a zip file + JSZipUtils.getBinaryContent(url, (err: any, data: any) => { + if (err) throw err; // or handle the error + // // Generate a directory within the Zip file structure + // const assets = zip.folder("assets"); + // assets.file(filename, data, {binary: true}); + const assetPathOnServer = proms[i].replace(window.location.origin + '/', '').replace(/\//g, '%%%'); + zip.file(assetPathOnServer, data, { binary: true }); + if (++count == proms.length) { + zip.file('docs.json', jsonDocs); + zip.generateAsync({ type: 'blob' }).then(content => saveAs(content, zipFilename)); + // const a = document.createElement("a"); + // const url = Utils.prepend(`/downloadId/${this.props.Document[Id]}`); + // a.href = url; + // a.download = `DocExport-${this.props.Document[Id]}.zip`; + // a.click(); + } + }); + }); } const _pendingMap: Map<string, boolean> = new Map(); @@ -919,44 +893,34 @@ export namespace Doc { // Returns an expanded template layout for a target data document if there is a template relationship // between the two. If so, the layoutDoc is expanded into a new document that inherits the properties // of the original layout while allowing for individual layout properties to be overridden in the expanded layout. - // templateArgs should be equivalent to the layout key that generates the template since that's where the template parameters are stored in ()'s at the end of the key. - // NOTE: the template will have references to "@params" -- the template arguments will be assigned to the '@params' field - // so that when the @params key is accessed, it will be rewritten as the key that is stored in the 'params' field and - // the derefence will then occur on the rootDocument (the original document). - // in the future, field references could be written as @<someparam> and then arguments would be passed in the layout key as: - // layout_mytemplate(somparam=somearg). - // then any references to @someparam would be rewritten as accesses to 'somearg' on the rootDocument - export function expandTemplateLayout(templateLayoutDoc: Doc, targetDoc?: Doc, templateArgs?: string) { - const args = templateArgs?.match(/\(([a-zA-Z0-9._\-]*)\)/)?.[1].replace('()', '') || StrCast(templateLayoutDoc.PARAMS); - if ((!args && !WillExpandTemplateLayout(templateLayoutDoc, targetDoc)) || !targetDoc) return templateLayoutDoc; - - const templateField = StrCast(templateLayoutDoc.isTemplateForField); // the field that the template renders + export function expandTemplateLayout(templateLayoutDoc: Doc, targetDoc?: Doc) { + // nothing to do if the layout isn't a template or we don't have a target that's different than the template + if (!targetDoc || templateLayoutDoc === targetDoc || (!templateLayoutDoc.isTemplateForField && !templateLayoutDoc.isTemplateDoc)) { + return templateLayoutDoc; + } + + const templateField = StrCast(templateLayoutDoc.isTemplateForField, Doc.LayoutFieldKey(templateLayoutDoc)); // the field that the template renders // First it checks if an expanded layout already exists -- if so it will be stored on the dataDoc // using the template layout doc's id as the field key. // If it doesn't find the expanded layout, then it makes a delegate of the template layout and // saves it on the data doc indexed by the template layout's id. // - const params = args.split('=').length > 1 ? args.split('=')[0] : 'PARAMS'; - const layoutFielddKey = Doc.LayoutFieldKey(templateLayoutDoc); - const expandedLayoutFieldKey = (templateField || layoutFielddKey) + '-layout[' + templateLayoutDoc[Id] + (args ? `(${args})` : '') + ']'; + const expandedLayoutFieldKey = templateField + '-layout[' + templateLayoutDoc[Id] + ']'; let expandedTemplateLayout = targetDoc?.[expandedLayoutFieldKey]; if (templateLayoutDoc.resolvedDataDoc instanceof Promise) { expandedTemplateLayout = undefined; _pendingMap.set(targetDoc[Id] + expandedLayoutFieldKey, true); - } else if (expandedTemplateLayout === undefined && !_pendingMap.get(targetDoc[Id] + expandedLayoutFieldKey + args)) { - if (templateLayoutDoc.resolvedDataDoc === (targetDoc.rootDocument || Doc.GetProto(targetDoc)) && templateLayoutDoc.PARAMS === StrCast(targetDoc.PARAMS)) { + } else if (expandedTemplateLayout === undefined && !_pendingMap.get(targetDoc[Id] + expandedLayoutFieldKey)) { + if (templateLayoutDoc.resolvedDataDoc === (targetDoc.rootDocument || Doc.GetProto(targetDoc))) { expandedTemplateLayout = templateLayoutDoc; // reuse an existing template layout if its for the same document with the same params } else { templateLayoutDoc.resolvedDataDoc && (templateLayoutDoc = Cast(templateLayoutDoc.proto, Doc, null) || templateLayoutDoc); // if the template has already been applied (ie, a nested template), then use the template's prototype if (!targetDoc[expandedLayoutFieldKey]) { - _pendingMap.set(targetDoc[Id] + expandedLayoutFieldKey + args, true); + _pendingMap.set(targetDoc[Id] + expandedLayoutFieldKey, true); setTimeout( action(() => { const newLayoutDoc = Doc.MakeDelegate(templateLayoutDoc, undefined, '[' + templateLayoutDoc.title + ']'); - // the template's arguments are stored in params which is derefenced to find - // the actual field key where the parameterized template data is stored. - newLayoutDoc[params] = args !== '...' ? args : ''; // ... signifies the layout has sub template(s) -- so we have to expand the layout for them so that they can get the correct 'rootDocument' field, but we don't need to reassign their params. it would be better if the 'rootDocument' field could be passed dynamically to avoid have to create instances newLayoutDoc.rootDocument = targetDoc; const dataDoc = Doc.GetProto(targetDoc); newLayoutDoc.resolvedDataDoc = dataDoc; @@ -965,7 +929,7 @@ export namespace Doc { } targetDoc[expandedLayoutFieldKey] = newLayoutDoc; - _pendingMap.delete(targetDoc[Id] + expandedLayoutFieldKey + args); + _pendingMap.delete(targetDoc[Id] + expandedLayoutFieldKey); }) ); } @@ -978,11 +942,11 @@ export namespace Doc { // otherwise, it just returns the childDoc export function GetLayoutDataDocPair(containerDoc: Doc, containerDataDoc: Opt<Doc>, childDoc: Doc) { if (!childDoc || childDoc instanceof Promise || !Doc.GetProto(childDoc)) { - console.log('No, no, no!'); + console.log('Warning: GetLayoutDataDocPair childDoc not defined'); return { layout: childDoc, data: childDoc }; } - const resolvedDataDoc = Doc.AreProtosEqual(containerDataDoc, containerDoc) || (!childDoc.isTemplateDoc && !childDoc.isTemplateForField && !childDoc.PARAMS) ? undefined : containerDataDoc; - return { layout: Doc.expandTemplateLayout(childDoc, resolvedDataDoc, '(' + StrCast(containerDoc.PARAMS) + ')'), data: resolvedDataDoc }; + const resolvedDataDoc = Doc.AreProtosEqual(containerDataDoc, containerDoc) || (!childDoc.isTemplateDoc && !childDoc.isTemplateForField) ? undefined : containerDataDoc; + return { layout: Doc.expandTemplateLayout(childDoc, resolvedDataDoc), data: resolvedDataDoc }; } export function Overwrite(doc: Doc, overwrite: Doc, copyProto: boolean = false): Doc { @@ -1568,20 +1532,26 @@ export namespace Doc { } } - export async function importDocument(file: File) { + /// + // imports a previously exported zip file which contains a set of documents and their assets (eg, images, videos) + // the 'remap' parameter determines whether the ids of the documents loaded should be kept as they were, or remapped to new ids + // If they are not remapped, loading the file will overwrite any existing documents with those ids + // + export async function importDocument(file: File, remap = false) { const upload = Utils.prepend('/uploadDoc'); const formData = new FormData(); if (file) { formData.append('file', file); - formData.append('remap', 'true'); + formData.append('remap', remap.toString()); const response = await fetch(upload, { method: 'POST', body: formData }); const json = await response.json(); - console.log(json); if (json !== 'error') { - await DocServer.GetRefFields(json.docids as string[]); + const docs = await DocServer.GetRefFields(json.docids as string[]); const doc = DocCast(await DocServer.GetRefField(json.id)); - (await DocListCastAsync(doc?.LINKS))?.forEach(link => LinkManager.Instance.addLink(link)); - doc.LINKS = undefined; + const links = await DocServer.GetRefFields(json.linkids as string[]); + Array.from(Object.keys(links)) + .map(key => links[key]) + .forEach(link => link instanceof Doc && LinkManager.Instance.addLink(link)); return doc; } } diff --git a/src/fields/documentSchemas.ts b/src/fields/documentSchemas.ts index 5b489a96c..b7fd06973 100644 --- a/src/fields/documentSchemas.ts +++ b/src/fields/documentSchemas.ts @@ -89,7 +89,6 @@ export const documentSchema = createSchema({ hideAllLinks: 'boolean', // whether all individual blue anchor dots should be hidden linkDisplay: 'boolean', // whether a link connection should be shown between link anchor endpoints. isLightbox: 'boolean', // whether the marked object will display addDocTab() calls that target "lightbox" destinations - isLinkButton: 'boolean', // whether document functions as a link follow button to follow the first link on the document when clicked layers: listSpec('string'), // which layers the document is part of _lockedPosition: 'boolean', // whether the document can be moved (dragged) _lockedTransform: 'boolean', // whether a freeformview can pan/zoom diff --git a/src/mobile/AudioUpload.tsx b/src/mobile/AudioUpload.tsx index 64baa351c..a0b437354 100644 --- a/src/mobile/AudioUpload.tsx +++ b/src/mobile/AudioUpload.tsx @@ -13,13 +13,18 @@ import { listSpec } from '../fields/Schema'; import { Cast, FieldValue } from '../fields/Types'; import { nullAudio } from '../fields/URLField'; import { emptyFunction, returnEmptyDoclist, returnEmptyFilter, returnFalse, returnTrue, emptyPath } from '../Utils'; -import "./ImageUpload.scss"; +import './ImageUpload.scss'; import { MobileInterface } from './MobileInterface'; import React = require('react'); @observer export class AudioUpload extends React.Component { - @observable public _audioCol: Doc = FieldValue(Cast(Docs.Create.FreeformDocument([Cast(Docs.Create.AudioDocument(nullAudio, { title: "mobile audio", _width: 500, _height: 100 }), Doc) as Doc], { title: "mobile audio", _width: 300, _height: 300, _fitContentsToBox: true, boxShadow: "0 0" }), Doc)) as Doc; + @observable public _audioCol: Doc = FieldValue( + Cast( + Docs.Create.FreeformDocument([Cast(Docs.Create.AudioDocument(nullAudio, { title: 'mobile audio', _width: 500, _height: 100 }), Doc) as Doc], { title: 'mobile audio', _width: 300, _height: 300, _fitContentsToBox: true, boxShadow: '0 0' }), + Doc + ) + ) as Doc; /** * Handles the onclick functionality for the 'Restart' button @@ -28,25 +33,36 @@ export class AudioUpload extends React.Component { @action clearUpload = () => { for (let i = 1; i < 8; i++) { - this.setOpacity(i, "0.2"); + this.setOpacity(i, '0.2'); } - this._audioCol = FieldValue(Cast( - Docs.Create.FreeformDocument( - [Cast(Docs.Create.AudioDocument(nullAudio, { - title: "mobile audio", - _width: 500, - _height: 100 - }), Doc) as Doc], { title: "mobile audio", _width: 300, _height: 300, _fitContentsToBox: true, boxShadow: "0 0" }), Doc)) as Doc; - } + this._audioCol = FieldValue( + Cast( + Docs.Create.FreeformDocument( + [ + Cast( + Docs.Create.AudioDocument(nullAudio, { + title: 'mobile audio', + _width: 500, + _height: 100, + }), + Doc + ) as Doc, + ], + { title: 'mobile audio', _width: 300, _height: 300, _fitContentsToBox: true, boxShadow: '0 0' } + ), + Doc + ) + ) as Doc; + }; - /** + /** * Handles the onClick of the 'Close' button * Reset upload interface and toggle audio */ closeUpload = () => { this.clearUpload(); MobileInterface.Instance.toggleAudio(); - } + }; /** * Handles the on click of the 'Upload' button. @@ -57,14 +73,14 @@ export class AudioUpload extends React.Component { const audioDoc = this._audioCol; const data = Cast(audioRightSidebar.data, listSpec(Doc)); for (let i = 1; i < 8; i++) { - setTimeout(() => this.setOpacity(i, "1"), i * 200); + setTimeout(() => this.setOpacity(i, '1'), i * 200); } if (data) { data.push(audioDoc); } // Resets uploader after 3 seconds setTimeout(this.clearUpload, 3000); - } + }; // Returns the upload audio menu private get uploadInterface() { @@ -72,11 +88,13 @@ export class AudioUpload extends React.Component { <> <ContextMenu /> <DictationOverlay /> - <div style={{ display: "none" }}><RichTextMenu key="rich" /></div> + <div style={{ display: 'none' }}> + <RichTextMenu key="rich" /> + </div> <div className="closeUpload" onClick={() => this.closeUpload()}> - <FontAwesomeIcon icon="window-close" size={"lg"} /> + <FontAwesomeIcon icon="window-close" size={'lg'} /> </div> - <FontAwesomeIcon icon="microphone" size="lg" style={{ fontSize: "130" }} /> + <FontAwesomeIcon icon="microphone" size="lg" style={{ fontSize: '130' }} /> <div className="audioUpload_cont"> <DocumentView Document={this._audioCol} @@ -96,12 +114,10 @@ export class AudioUpload extends React.Component { isDocumentActive={returnTrue} isContentActive={emptyFunction} focus={emptyFunction} - styleProvider={() => "rgba(0,0,0,0)"} + styleProvider={() => 'rgba(0,0,0,0)'} docViewPath={returnEmptyDoclist} whenChildContentsActiveChanged={emptyFunction} bringToFront={emptyFunction} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} /> </div> <div className="restart_label" onClick={this.clearUpload}> @@ -125,28 +141,16 @@ export class AudioUpload extends React.Component { // Handles the setting of the loading bar setOpacity = (index: number, opacity: string) => { - const slab = document.getElementById("slab0" + index); + const slab = document.getElementById('slab0' + index); if (slab) { slab.style.opacity = opacity; } - } + }; @observable private dialogueBoxOpacity = 1; @observable private overlayOpacity = 0.4; render() { - return ( - <MainViewModal - contents={this.uploadInterface} - isDisplayed={true} - interactive={true} - dialogueBoxDisplayedOpacity={this.dialogueBoxOpacity} - overlayDisplayedOpacity={this.overlayOpacity} - closeOnExternalClick={this.closeUpload} - /> - ); + return <MainViewModal contents={this.uploadInterface} isDisplayed={true} interactive={true} dialogueBoxDisplayedOpacity={this.dialogueBoxOpacity} overlayDisplayedOpacity={this.overlayOpacity} closeOnExternalClick={this.closeUpload} />; } - } - - diff --git a/src/mobile/MobileInterface.tsx b/src/mobile/MobileInterface.tsx index 2ae597b0b..a543c2f70 100644 --- a/src/mobile/MobileInterface.tsx +++ b/src/mobile/MobileInterface.tsx @@ -397,7 +397,7 @@ export class MobileInterface extends React.Component { renderDepth={0} isDocumentActive={returnTrue} isContentActive={emptyFunction} - focus={DocUtils.DefaultFocus} + focus={emptyFunction} styleProvider={this.whitebackground} docViewPath={returnEmptyDoclist} whenChildContentsActiveChanged={emptyFunction} @@ -405,8 +405,6 @@ export class MobileInterface extends React.Component { docFilters={returnEmptyFilter} docRangeFilters={returnEmptyFilter} searchFilterDocs={returnEmptyDoclist} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} /> </div> ); diff --git a/src/server/ApiManagers/UploadManager.ts b/src/server/ApiManagers/UploadManager.ts index 6e28268a9..74c06b4a6 100644 --- a/src/server/ApiManagers/UploadManager.ts +++ b/src/server/ApiManagers/UploadManager.ts @@ -180,13 +180,9 @@ export default class UploadManager extends ApiManager { const ids: { [id: string]: string } = {}; let remap = true; const getId = (id: string): string => { - if (!remap) return id; - if (id.endsWith('Proto')) return id; - if (id in ids) { - return ids[id]; - } else { - return (ids[id] = v4()); - } + if (!remap || id.endsWith('Proto')) return id; + if (id in ids) return ids[id]; + return (ids[id] = v4()); }; const mapFn = (doc: any) => { if (doc.id) { @@ -229,6 +225,7 @@ export default class UploadManager extends ApiManager { remap = fields.remap !== 'false'; let id: string = ''; let docids: string[] = []; + let linkids: string[] = []; try { for (const name in files) { const f = files[name]; @@ -255,27 +252,22 @@ export default class UploadManager extends ApiManager { console.log(e); } }); - const json = zip.getEntry('doc.json'); + const json = zip.getEntry('docs.json'); try { const data = JSON.parse(json.getData().toString('utf8'), retrocycle()); - const datadocs = data.docs; + const { docs, links } = data; id = getId(data.id); - const docs = Object.keys(datadocs).map(key => datadocs[key]); - docs.forEach(mapFn); - docids = docs.map(doc => doc.id); + const rdocs = Object.keys(docs).map(key => docs[key]); + const ldocs = Object.keys(links).map(key => links[key]); + [...rdocs, ...ldocs].forEach(mapFn); + docids = rdocs.map(doc => doc.id); + linkids = ldocs.map(link => link.id); await Promise.all( - docs.map( - (doc: any) => + [...rdocs, ...ldocs].map( + doc => new Promise<void>(res => { - Database.Instance.replace( - doc.id, - doc, - (err, r) => { - err && console.log(err); - res(); - }, - true - ); + // overwrite mongo doc with json doc contents + Database.Instance.replace(doc.id, doc, (err, r) => res(err && console.log(err)), true); }) ) ); @@ -285,7 +277,7 @@ export default class UploadManager extends ApiManager { unlink(path_2, () => {}); } SolrManager.update(); - res.send(JSON.stringify({ id, docids } || 'error')); + res.send(JSON.stringify({ id, docids, linkids } || 'error')); } catch (e) { console.log(e); } diff --git a/src/server/ApiManagers/UserManager.ts b/src/server/ApiManagers/UserManager.ts index 53e55c1c3..c3dadd821 100644 --- a/src/server/ApiManagers/UserManager.ts +++ b/src/server/ApiManagers/UserManager.ts @@ -5,6 +5,7 @@ import { msToTime } from '../ActionUtilities'; import * as bcrypt from 'bcrypt-nodejs'; import { Opt } from '../../fields/Doc'; import { WebSocket } from '../websocket'; +import { DashStats } from '../DashStats'; export const timeMap: { [id: string]: number } = {}; interface ActivityUnit { diff --git a/src/server/DashStats.ts b/src/server/DashStats.ts new file mode 100644 index 000000000..8d341db63 --- /dev/null +++ b/src/server/DashStats.ts @@ -0,0 +1,293 @@ +import { cyan, magenta } from 'colors'; +import { Response } from 'express'; +import SocketIO from 'socket.io'; +import { timeMap } from './ApiManagers/UserManager'; +import { WebSocket } from './websocket'; +const fs = require('fs'); + +/** + * DashStats focuses on tracking user data for each session. + * + * This includes time connected, number of operations, and + * the rate of their operations + */ +export namespace DashStats { + export const SAMPLING_INTERVAL = 1000; // in milliseconds (ms) - Time interval to update the frontend. + export const RATE_INTERVAL = 10; // in seconds (s) - Used to calculate rate + + const statsCSVFilename = './src/server/stats/userLoginStats.csv'; + const columns = ['USERNAME', 'ACTION', 'TIME']; + + /** + * UserStats holds the stats associated with a particular user. + */ + interface UserStats { + socketId: string; + username: string; + time: string; + operations: number; + rate: number; + } + + /** + * UserLastOperations is the queue object for each user + * storing their past operations. + */ + interface UserLastOperations { + sampleOperations: number; // stores how many operations total are in this rate section (10 sec, for example) + lastSampleOperations: number; // stores how many total operations were recorded at the last sample + previousOperationsQueue: number[]; // stores the operations to calculate rate. + } + + /** + * StatsDataBundle represents an object that will be sent to the frontend view + * on each websocket update. + */ + interface StatsDataBundle { + connectedUsers: UserStats[]; + } + + /** + * CSVStore represents how objects will be stored in the CSV + */ + interface CSVStore { + USERNAME: string; + ACTION: string; + TIME: string; + } + + /** + * ServerTraffic describes the current traffic going to the backend. + */ + enum ServerTraffic { + NOT_BUSY, + BUSY, + VERY_BUSY + } + + // These values can be changed after further testing how many + // users correspond to each traffic level in Dash. + const BUSY_SERVER_BOUND = 2; + const VERY_BUSY_SERVER_BOUND = 3; + + const serverTrafficMessages = [ + "Not Busy", + "Busy", + "Very Busy" + ] + + // lastUserOperations maps each username to a UserLastOperations + // structure + export const lastUserOperations = new Map<string, UserLastOperations>(); + + /** + * handleStats is called when the /stats route is called, providing a JSON + * object with relevant stats. In this case, we return the number of + * current connections and + * @param res Response object from Express + */ + export function handleStats(res: Response) { + let current = getCurrentStats(); + const results: CSVStore[] = []; + res.json({ + currentConnections: current.length, + socketMap: current, + }); + } + + /** + * getUpdatedStatesBundle() sends an updated copy of the current stats to the + * frontend /statsview route via websockets. + * + * @returns a StatsDataBundle that is sent to the frontend view on each websocket update + */ + export function getUpdatedStatsBundle(): StatsDataBundle { + let current = getCurrentStats(); + + return { + connectedUsers: current, + } + } + + /** + * handleStatsView() is called when the /statsview route is called. This + * will use pug to render a frontend view of the current stats + * + * @param res + */ + export function handleStatsView(res: Response) { + let current = getCurrentStats(); + + let connectedUsers = current.map((socketPair) => { + return socketPair.time + " - " + socketPair.username + " Operations: " + socketPair.operations; + }) + + let serverTraffic = ServerTraffic.NOT_BUSY; + if(current.length < BUSY_SERVER_BOUND) { + serverTraffic = ServerTraffic.NOT_BUSY; + } else if(current.length >= BUSY_SERVER_BOUND && current.length < VERY_BUSY_SERVER_BOUND) { + serverTraffic = ServerTraffic.BUSY; + } else { + serverTraffic = ServerTraffic.VERY_BUSY; + } + + res.render("stats.pug", { + title: "Dash Stats", + numConnections: connectedUsers.length, + serverTraffic: serverTraffic, + serverTrafficMessage : serverTrafficMessages[serverTraffic], + connectedUsers: connectedUsers + }); + } + + /** + * logUserLogin() writes a login event to the CSV file. + * + * @param username the username in the format of "username@domain.com logged in" + * @param socket the websocket associated with the current connection + */ + export function logUserLogin(username: string | undefined, socket: SocketIO.Socket) { + if (!(username === undefined)) { + let currentDate = new Date(); + console.log(magenta(`User ${username.split(' ')[0]} logged in at: ${currentDate.toISOString()}`)); + + let toWrite: CSVStore = { + USERNAME : username, + ACTION : "loggedIn", + TIME : currentDate.toISOString() + } + + let statsFile = fs.createWriteStream(statsCSVFilename, { flags: "a"}); + statsFile.write(convertToCSV(toWrite)); + statsFile.end(); + console.log(cyan(convertToCSV(toWrite))); + } + } + + /** + * logUserLogout() writes a logout event to the CSV file. + * + * @param username the username in the format of "username@domain.com logged in" + * @param socket the websocket associated with the current connection. + */ + export function logUserLogout(username: string | undefined, socket: SocketIO.Socket) { + if (!(username === undefined)) { + let currentDate = new Date(); + + let statsFile = fs.createWriteStream(statsCSVFilename, { flags: "a"}); + let toWrite: CSVStore = { + USERNAME : username, + ACTION : "loggedOut", + TIME : currentDate.toISOString() + } + statsFile.write(convertToCSV(toWrite)); + statsFile.end(); + } + } + + /** + * getLastOperationsOrDefault() is a helper method that will attempt + * to query the lastUserOperations map for a specified username. If the + * username is not in the map, an empty UserLastOperations object is returned. + * @param username + * @returns the user's UserLastOperations structure or an empty + * UserLastOperations object (All values set to 0) if the username is not found. + */ + function getLastOperationsOrDefault(username: string): UserLastOperations { + if(lastUserOperations.get(username) === undefined) { + let initializeOperationsQueue = []; + for(let i = 0; i < RATE_INTERVAL; i++) { + initializeOperationsQueue.push(0); + } + return { + sampleOperations: 0, + lastSampleOperations: 0, + previousOperationsQueue: initializeOperationsQueue + } + } + return lastUserOperations.get(username)!; + } + + /** + * updateLastOperations updates a specific user's UserLastOperations information + * for the current sampling cycle. The method removes old/outdated counts for + * operations from the queue and adds new data for the current sampling + * cycle to the queue, updating the total count as it goes. + * @param lastOperationData the old UserLastOperations data that must be updated + * @param currentOperations the total number of operations measured for this sampling cycle. + * @returns the udpated UserLastOperations structure. + */ + function updateLastOperations(lastOperationData: UserLastOperations, currentOperations: number): UserLastOperations { + // create a copy of the UserLastOperations to modify + let newLastOperationData: UserLastOperations = { + sampleOperations: lastOperationData.sampleOperations, + lastSampleOperations: lastOperationData.lastSampleOperations, + previousOperationsQueue: lastOperationData.previousOperationsQueue.slice() + } + + let newSampleOperations = newLastOperationData.sampleOperations; + newSampleOperations -= newLastOperationData.previousOperationsQueue.shift()!; // removes and returns the first element of the queue + let operationsThisCycle = currentOperations - lastOperationData.lastSampleOperations; + newSampleOperations += operationsThisCycle; // add the operations this cycle to find out what our count for the interval should be (e.g operations in the last 10 seconds) + + // update values for the copy object + newLastOperationData.sampleOperations = newSampleOperations; + + newLastOperationData.previousOperationsQueue.push(operationsThisCycle); + newLastOperationData.lastSampleOperations = currentOperations; + + return newLastOperationData; + } + + /** + * getUserOperationsOrDefault() is a helper method to get the user's total + * operations for the CURRENT sampling interval. The method will return 0 + * if the username is not in the userOperations map. + * @param username the username to search the map for + * @returns the total number of operations recorded up to this sampling cycle. + */ + function getUserOperationsOrDefault(username: string): number { + return WebSocket.userOperations.get(username) === undefined ? 0 : WebSocket.userOperations.get(username)! + } + + /** + * getCurrentStats() calculates the total stats for this cycle. In this case, + * getCurrentStats() returns an Array of UserStats[] objects describing + * the stats for each user + * @returns an array of UserStats storing data for each user at the current moment. + */ + function getCurrentStats(): UserStats[] { + let socketPairs: UserStats[] = []; + for (let [key, value] of WebSocket.socketMap) { + let username = value.split(' ')[0]; + let connectionTime = new Date(timeMap[username]); + + let connectionTimeString = connectionTime.toLocaleDateString() + " " + connectionTime.toLocaleTimeString(); + + if (!key.disconnected) { + let lastRecordedOperations = getLastOperationsOrDefault(username); + let currentUserOperationCount = getUserOperationsOrDefault(username); + + socketPairs.push({ + socketId: key.id, + username: username, + time: connectionTimeString.includes("Invalid Date") ? "" : connectionTimeString, + operations : WebSocket.userOperations.get(username) ? WebSocket.userOperations.get(username)! : 0, + rate: lastRecordedOperations.sampleOperations + }); + lastUserOperations.set(username, updateLastOperations(lastRecordedOperations,currentUserOperationCount)); + } + } + return socketPairs; + } + + /** + * convertToCSV() is a helper method that stringifies a CSVStore object + * that can be written to the CSV file later. + * @param dataObject the object to stringify + * @returns the object as a string. + */ + function convertToCSV(dataObject: CSVStore): string { + return `${dataObject.USERNAME},${dataObject.ACTION},${dataObject.TIME}\n`; + } +} diff --git a/src/server/Message.ts b/src/server/Message.ts index d87ae5027..8f0af08bc 100644 --- a/src/server/Message.ts +++ b/src/server/Message.ts @@ -94,4 +94,6 @@ export namespace MessageStore { export const YoutubeApiQuery = new Message<YoutubeQueryInput>("Youtube Api Query"); export const DeleteField = new Message<string>("Delete field"); export const DeleteFields = new Message<string[]>("Delete fields"); + + export const UpdateStats = new Message<string>("updatestats"); } diff --git a/src/server/index.ts b/src/server/index.ts index 6562860fe..8b2e18847 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -19,6 +19,7 @@ import { GoogleCredentialsLoader, SSL } from './apis/google/CredentialsLoader'; import { GoogleApiServerUtils } from './apis/google/GoogleApiServerUtils'; import { DashSessionAgent } from './DashSession/DashSessionAgent'; import { AppliedSessionAgent } from './DashSession/Session/agents/applied_session_agent'; +import { DashStats } from './DashStats'; import { DashUploadUtils } from './DashUploadUtils'; import { Database } from './database'; import { Logger } from './ProcessFactory'; @@ -98,6 +99,18 @@ function routeSetter({ isRelease, addSupervisedRoute, logRegistrationOutcome }: addSupervisedRoute({ method: Method.GET, + subscription: '/stats', + secureHandler: ({ res }) => DashStats.handleStats(res), + }); + + addSupervisedRoute({ + method: Method.GET, + subscription: '/statsview', + secureHandler: ({ res }) => DashStats.handleStatsView(res), + }); + + addSupervisedRoute({ + method: Method.GET, subscription: '/resolvedPorts', secureHandler: ({ res }) => res.send(resolvedPorts), publicHandler: ({ res }) => res.send(resolvedPorts), diff --git a/src/server/websocket.ts b/src/server/websocket.ts index 68b003496..a11d20cfa 100644 --- a/src/server/websocket.ts +++ b/src/server/websocket.ts @@ -12,6 +12,7 @@ import { GoogleCredentialsLoader, SSL } from './apis/google/CredentialsLoader'; import YoutubeApi from './apis/youtube/youtubeApiSample'; import { initializeGuest } from './authentication/DashUserModel'; import { Client } from './Client'; +import { DashStats } from './DashStats'; import { Database } from './database'; import { DocumentsCollection } from './IDatabase'; import { Diff, GestureContent, MessageStore, MobileDocumentUploadContent, MobileInkOverlayContent, Transferable, Types, UpdateMobileInkOverlayPositionContent, YoutubeQueryInput, YoutubeQueryTypes } from './Message'; @@ -20,8 +21,9 @@ import { resolvedPorts } from './server_Initialization'; export namespace WebSocket { export let _socket: Socket; - const clients: { [key: string]: Client } = {}; + export const clients: { [key: string]: Client } = {}; export const socketMap = new Map<SocketIO.Socket, string>(); + export const userOperations = new Map<string, number>(); export let disconnect: Function; export async function initialize(isRelease: boolean, app: express.Express) { @@ -49,6 +51,8 @@ export namespace WebSocket { next(); }); + socket.emit(MessageStore.UpdateStats.Message, DashStats.getUpdatedStatsBundle()) + // convenience function to log server messages on the client function log(message?: any, ...optionalParams: any[]) { socket.emit('log', ['Message from server:', message, ...optionalParams]); @@ -97,6 +101,17 @@ export namespace WebSocket { console.log('received bye'); }); + socket.on('disconnect', function () { + let currentUser = socketMap.get(socket); + if (!(currentUser === undefined)) { + let currentUsername = currentUser.split(' ')[0] + DashStats.logUserLogout(currentUsername, socket); + delete timeMap[currentUsername] + } + }); + + + Utils.Emit(socket, MessageStore.Foo, 'handshooken'); Utils.AddServerHandler(socket, MessageStore.Bar, guid => barReceived(socket, guid)); @@ -130,6 +145,12 @@ export namespace WebSocket { socket.disconnect(true); }; }); + + setInterval(function() { + // Utils.Emit(socket, MessageStore.UpdateStats, DashStats.getUpdatedStatsBundle()); + + io.emit(MessageStore.UpdateStats.Message, DashStats.getUpdatedStatsBundle()) + }, DashStats.SAMPLING_INTERVAL); } function processGesturePoints(socket: Socket, content: GestureContent) { @@ -172,11 +193,16 @@ export namespace WebSocket { } function barReceived(socket: SocketIO.Socket, userEmail: string) { - clients[userEmail] = new Client(userEmail.toString()); + clients[userEmail] = new Client(userEmail.toString()); const currentdate = new Date(); const datetime = currentdate.getDate() + '/' + (currentdate.getMonth() + 1) + '/' + currentdate.getFullYear() + ' @ ' + currentdate.getHours() + ':' + currentdate.getMinutes() + ':' + currentdate.getSeconds(); console.log(blue(`user ${userEmail} has connected to the web socket at: ${datetime}`)); + printActiveUsers(); + + timeMap[userEmail] = Date.now(); socketMap.set(socket, userEmail + ' at ' + datetime); + userOperations.set(userEmail, 0); + DashStats.logUserLogin(userEmail, socket); } function getField([id, callback]: [string, (result?: Transferable) => void]) { @@ -300,7 +326,8 @@ export namespace WebSocket { const remListItems = diff.diff.$set[updatefield].fields; const curList = (curListItems as any)?.fields?.[updatefield.replace('fields.', '')]?.fields.filter((f: any) => f !== null) || []; diff.diff.$set[updatefield].fields = curList?.filter( - (curItem: any) => !remListItems.some((remItem: any) => (remItem.fieldId ? remItem.fieldId === curItem.fieldId : remItem.heading ? remItem.heading === curItem.heading : remItem === curItem)) + (curItem: any) => !remListItems.some((remItem: any) => (remItem.fieldId ? remItem.fieldId === curItem.fieldId : + remItem.heading ? remItem.heading === curItem.heading : remItem === curItem)) ); const sendBack = diff.diff.length !== diff.diff.$set[updatefield].fields.length; delete diff.diff.length; @@ -346,6 +373,9 @@ export namespace WebSocket { var CurUser: string | undefined = undefined; function UpdateField(socket: Socket, diff: Diff) { + let currentUsername = socketMap.get(socket)!.split(' ')[0]; + userOperations.set(currentUsername, userOperations.get(currentUsername) !== undefined ? userOperations.get(currentUsername)! + 1 : 0); + if (CurUser !== socketMap.get(socket)) { CurUser = socketMap.get(socket); console.log('Switch User: ' + CurUser); |