diff options
author | Michael Foiani <sotech117@michaels-mbp-5.devices.brown.edu> | 2022-08-10 13:45:19 -0400 |
---|---|---|
committer | Michael Foiani <sotech117@michaels-mbp-5.devices.brown.edu> | 2022-08-10 13:45:19 -0400 |
commit | f573b7a8e0764f6fdfec637810f6e5b699c33053 (patch) | |
tree | 3a66fa34db31e83c95cc72cf13515f6c018b3a88 | |
parent | 57a2713e1219b58e7ad08b697a23ea3f9b962a84 (diff) | |
parent | a63f017c213563728f45f2caa7415843f50f3559 (diff) |
Merge branch 'master' into report-bug-mfoiani
casual merge with master to keep local branch updated
52 files changed, 3000 insertions, 1065 deletions
diff --git a/.gitignore b/.gitignore index 7a3c82ee6..7d3a4d214 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ src/server/session_manager/logs/**/*.log debug.log .vscodeignore Dockerfile +.vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json index a7c30ca1e..ce9f50f67 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,9 +12,7 @@ "breakOnLoad": true, "url": "http://localhost:1050/login", "webRoot": "${workspaceFolder}", - "runtimeArgs": [ - "--experimental-modules" - ] + "runtimeArgs": ["--experimental-modules"] }, { "type": "chrome", @@ -25,18 +23,21 @@ "url": "http://localhost:1050/login", "webRoot": "${workspaceFolder}", "runtimeExecutable": "/usr/bin/chromium", - "runtimeArgs": [ - "--experimental-modules" - ] + "runtimeArgs": ["--experimental-modules"] }, { "type": "firefox", "request": "launch", "name": "Launch Firefox against localhost", - //"sourceMaps": "client", "reAttach": true, "url": "http://localhost:1050/login", "webRoot": "${workspaceFolder}", + "pathMappings": [ + { + "url": "webpack://dash/src", + "path": "${workspaceFolder}/src" + } + ] }, { "type": "chrome", @@ -45,8 +46,7 @@ "sourceMaps": true, "breakOnLoad": true, "url": "https://browndash.com/login", - //"url": "http://dash-web.eastus2.cloudapp.azure.com:1050/login", - "webRoot": "${workspaceFolder}", + "webRoot": "${workspaceFolder}" }, { "type": "node", @@ -62,13 +62,7 @@ "request": "launch", "name": "Current TS File", "runtimeExecutable": "npx", - "runtimeArgs": [ - "ts-node-dev", - "--nolazy", - "--inspect", - "--", - "${relativeFile}" - ], + "runtimeArgs": ["ts-node-dev", "--nolazy", "--inspect", "--", "${relativeFile}"], "port": 9229 }, { @@ -76,14 +70,7 @@ "request": "launch", "name": "Mocha Tests", "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", - "args": [ - "-r", - "ts-node/register", - "--timeout", - "999999", - "--colors", - "${workspaceFolder}/test/**/*.ts" - ], + "args": ["-r", "ts-node/register", "--timeout", "999999", "--colors", "${workspaceFolder}/test/**/*.ts"], "console": "integratedTerminal", "internalConsoleOptions": "openOnSessionStart", "protocol": "inspector" @@ -93,17 +80,10 @@ "request": "launch", "name": "Mocha Current File", "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", - "args": [ - "-r", - "ts-node/register", - "--timeout", - "999999", - "--colors", - "${file}" - ], + "args": ["-r", "ts-node/register", "--timeout", "999999", "--colors", "${file}"], "console": "integratedTerminal", "internalConsoleOptions": "openOnSessionStart", "protocol": "inspector" - }, + } ] -}
\ No newline at end of file +} diff --git a/src/client/documents/DocumentTypes.ts b/src/client/documents/DocumentTypes.ts index 4e98a90a3..9dfadf778 100644 --- a/src/client/documents/DocumentTypes.ts +++ b/src/client/documents/DocumentTypes.ts @@ -63,4 +63,5 @@ export enum CollectionViewType { Grid = 'grid', Pile = 'pileup', StackedTimeline = 'stacked timeline', + NoteTaking = "notetaking" } diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 64d26e425..c7ea04839 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -1035,6 +1035,17 @@ export namespace Docs { return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { ...options, _viewType: CollectionViewType.Stacking }, id, undefined, protoId); } + export function NoteTakingDocument(documents: Array<Doc>, options: DocumentOptions, id?: string, protoId?: string) { + return InstanceFromProto( + Prototypes.get(DocumentType.COL), + new List(documents), + { columnHeaders: new List<SchemaHeaderField>([new SchemaHeaderField('Untitled')]), ...options, _viewType: CollectionViewType.NoteTaking }, + id, + undefined, + protoId + ); + } + export function MulticolumnDocument(documents: Array<Doc>, options: DocumentOptions) { return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { ...options, _viewType: CollectionViewType.Multicolumn }); } @@ -1276,10 +1287,11 @@ export namespace DocUtils { export let ActiveRecordings: { props: FieldViewProps; getAnchor: () => Doc }[] = []; - export function MakeLinkToActiveAudio(getSourceDoc: () => Doc, broadcastEvent = true) { + export function MakeLinkToActiveAudio(getSourceDoc: () => Doc | undefined, broadcastEvent = true) { broadcastEvent && runInAction(() => (DocumentManager.Instance.RecordingEvent = DocumentManager.Instance.RecordingEvent + 1)); return DocUtils.ActiveRecordings.map(audio => { - const link = DocUtils.MakeLink({ doc: getSourceDoc() }, { doc: audio.getAnchor() || audio.props.Document }, 'recording annotation:linked recording', 'recording timeline'); + const sourceDoc = getSourceDoc(); + const link = sourceDoc && DocUtils.MakeLink({ doc: sourceDoc }, { doc: audio.getAnchor() || audio.props.Document }, 'recording annotation:linked recording', 'recording timeline'); link && (link.followLinkLocation = 'add:right'); return link; }); @@ -1485,7 +1497,7 @@ export namespace DocUtils { return ctor ? ctor(path, options) : undefined; } - export function addDocumentCreatorMenuItems(docTextAdder: (d: Doc) => void, docAdder: (d: Doc) => void, x: number, y: number, simpleMenu: boolean = false): void { + export function addDocumentCreatorMenuItems(docTextAdder: (d: Doc) => void, docAdder: (d: Doc) => void, x: number, y: number, simpleMenu: boolean = false, pivotField?: string, pivotValue?: string): void { !simpleMenu && ContextMenu.Instance.addItem({ description: 'Quick Notes', @@ -1501,6 +1513,9 @@ export namespace DocUtils { }); textDoc.layoutKey = 'layout_' + note.title; textDoc[textDoc.layoutKey] = note; + if (pivotField) { + textDoc[pivotField] = pivotValue; + } docTextAdder(textDoc); }), icon: StrCast(note.icon) as IconProp, @@ -1521,6 +1536,9 @@ export namespace DocUtils { newDoc.y = y; EquationBox.SelectOnLoad = newDoc[Id]; if (newDoc.type === DocumentType.RTF) FormattedTextBox.SelectOnLoad = newDoc[Id]; + if (pivotField) { + newDoc[pivotField] = pivotValue; + } docAdder?.(newDoc); } }), diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts index f55ed6e72..d19874720 100644 --- a/src/client/util/CurrentUserUtils.ts +++ b/src/client/util/CurrentUserUtils.ts @@ -107,7 +107,7 @@ export class CurrentUserUtils { const reqdClickList = reqdTempOpts.map(opts => { const allOpts = {...reqdClickOpts, ...opts.opts}; const clickDoc = tempClicks ? DocListCast(tempClicks.data).find(doc => doc.title === opts.opts.title): undefined; - return DocUtils.AssignOpts(clickDoc, allOpts) ?? Docs.Create.ScriptingDocument(ScriptField.MakeScript(opts.script,allOpts)); + return DocUtils.AssignOpts(clickDoc, allOpts) ?? Docs.Create.ScriptingDocument(ScriptField.MakeScript(opts.script, allOpts),allOpts); }); const reqdOpts:DocumentOptions = { title: "child click editors", _height:75, system: true}; @@ -135,7 +135,6 @@ export class CurrentUserUtils { return DocUtils.AssignOpts(tempClicks, reqdOpts, reqdClickList) ?? (doc[field] = Docs.Create.TreeDocument(reqdClickList, reqdOpts)); } - /// Initializes templates that can be applied to notes static setupNoteTemplates(doc: Doc, field="template-notes") { const tempNotes = DocCast(doc[field]); @@ -255,6 +254,7 @@ export class CurrentUserUtils { creator:(opts:DocumentOptions)=> any // how to create the empty thing if it doesn't exist }[] = [ {key: "Note", creator: opts => Docs.Create.TextDocument("", opts), opts: { _width: 200, _autoHeight: true }}, + {key: "Noteboard", creator: opts => Docs.Create.NoteTakingDocument([], opts), opts: { _width: 250, _height: 200 }}, {key: "Collection", creator: opts => Docs.Create.FreeformDocument([], opts), opts: { _width: 150, _height: 100 }}, {key: "Equation", creator: opts => Docs.Create.EquationDocument(opts), opts: { _width: 300, _height: 35, _fitWidth:false, _backgroundGridShow: true, }}, {key: "Webpage", creator: opts => Docs.Create.WebDocument("",opts), opts: { _width: 400, _height: 512, _nativeWidth: 850, useCors: true, }}, @@ -280,6 +280,7 @@ export class CurrentUserUtils { return [ { toolTip: "Tap or drag to create a note", title: "Note", icon: "sticky-note", dragFactory: doc.emptyNote as Doc, }, + { toolTip: "Tap or drag to create a note board", title: "Notes", icon: "folder", dragFactory: doc.emptyNoteboard as Doc, }, { toolTip: "Tap or drag to create a collection", title: "Col", icon: "folder", dragFactory: doc.emptyCollection as Doc, clickFactory: DocCast(doc.emptyTab), scripts: { onClick: 'openOnRight(copyDragFactory(this.clickFactory))', onDragStart: '{ return copyDragFactory(this.dragFactory);}'}, }, { toolTip: "Tap or drag to create an equation", title: "Math", icon: "calculator", dragFactory: doc.emptyEquation as Doc, }, { toolTip: "Tap or drag to create a webpage", title: "Web", icon: "globe-asia", dragFactory: doc.emptyWebpage as Doc, }, @@ -632,6 +633,7 @@ export class CurrentUserUtils { { title: "Center", toolTip: "Center align", btnType: ButtonType.ToggleButton, icon: "align-center", scripts: {onClick:'{ return setAlignment("center", _readOnly_);}'} }, { title: "Right", toolTip: "Right align", btnType: ButtonType.ToggleButton, icon: "align-right", scripts: {onClick:'{ return setAlignment("right", _readOnly_);}'} }, { title: "NoLink", toolTip: "Auto Link", btnType: ButtonType.ToggleButton, icon: "link", scripts: {onClick:'{ return toggleNoAutoLinkAnchor(_readOnly_);}'}}, + { title: "Dictate",toolTip: "Dictate", btnType: ButtonType.ToggleButton, icon: "microphone", scripts: {onClick:'{ return toggleDictation(_readOnly_);}'}}, ]; } @@ -669,7 +671,7 @@ export class CurrentUserUtils { CollectionViewType.Stacking, CollectionViewType.Masonry, CollectionViewType.Multicolumn, CollectionViewType.Multirow, CollectionViewType.Time, CollectionViewType.Carousel, CollectionViewType.Carousel3D, CollectionViewType.Linear, CollectionViewType.Map, - CollectionViewType.Grid]), + CollectionViewType.Grid, CollectionViewType.NoteTaking]), title: "Perspective", toolTip: "View", btnType: ButtonType.DropdownList, ignoreClick: true, width: 100, scripts: { script: 'setView(value, _readOnly_)'}}, { title: "Back", icon: "chevron-left", toolTip: "Prev Animation Frame", btnType: ButtonType.ClickButton, funcs: {hidden: '!SelectionManager_selectedDocType(undefined, "freeform") || IsNoviceMode()'}, width: 20, scripts: { onClick: 'prevKeyFrame(_readOnly_)'}}, { title: "Fwd", icon: "chevron-right", toolTip: "Next Animation Frame", btnType: ButtonType.ClickButton, funcs: {hidden: '!SelectionManager_selectedDocType(undefined, "freeform") || IsNoviceMode()'}, width: 20, scripts: { onClick: 'nextKeyFrame(_readOnly_)'}}, diff --git a/src/client/util/DictationManager.ts b/src/client/util/DictationManager.ts index a6dcda4bc..0a61f3478 100644 --- a/src/client/util/DictationManager.ts +++ b/src/client/util/DictationManager.ts @@ -1,37 +1,35 @@ -import * as interpreter from "words-to-numbers"; +import * as interpreter from 'words-to-numbers'; // @ts-ignore bcz: how are you supposed to include these definitions since dom-speech-recognition isn't a module? -import type { } from "@types/dom-speech-recognition"; -import { Doc, Opt } from "../../fields/Doc"; -import { List } from "../../fields/List"; -import { RichTextField } from "../../fields/RichTextField"; -import { listSpec } from "../../fields/Schema"; -import { Cast, CastCtor } from "../../fields/Types"; -import { AudioField, ImageField } from "../../fields/URLField"; -import { Utils } from "../../Utils"; -import { Docs } from "../documents/Documents"; -import { DocumentType } from "../documents/DocumentTypes"; -import { DictationOverlay } from "../views/DictationOverlay"; -import { DocumentView } from "../views/nodes/DocumentView"; -import { SelectionManager } from "./SelectionManager"; -import { UndoManager } from "./UndoManager"; - +import type {} from '@types/dom-speech-recognition'; +import { Doc, Opt } from '../../fields/Doc'; +import { List } from '../../fields/List'; +import { RichTextField } from '../../fields/RichTextField'; +import { listSpec } from '../../fields/Schema'; +import { Cast, CastCtor } from '../../fields/Types'; +import { AudioField, ImageField } from '../../fields/URLField'; +import { Utils } from '../../Utils'; +import { Docs } from '../documents/Documents'; +import { DocumentType } from '../documents/DocumentTypes'; +import { DictationOverlay } from '../views/DictationOverlay'; +import { DocumentView } from '../views/nodes/DocumentView'; +import { SelectionManager } from './SelectionManager'; +import { UndoManager } from './UndoManager'; /** * This namespace provides a singleton instance of a manager that * handles the listening and text-conversion of user speech. - * + * * The basic manager functionality can be attained by the DictationManager.Controls namespace, which provide * a simple recording operation that returns the interpreted text as a string. - * + * * Additionally, however, the DictationManager also exposes the ability to execute voice commands within Dash. * It stores a default library of registered commands that can be triggered by listen()'ing for a phrase and then * passing the results into the execute() function. - * + * * In addition to compile-time default commands, you can invoke DictationManager.Commands.Register(Independent|Dependent) * to add new commands as classes or components are constructed. */ export namespace DictationManager { - /** * Some type maneuvering to access Webkit's built-in * speech recognizer. @@ -42,27 +40,26 @@ export namespace DictationManager { } } const { webkitSpeechRecognition }: CORE.IWindow = window as any as CORE.IWindow; - export const placeholder = "Listening..."; + export const placeholder = 'Listening...'; export namespace Controls { - - export const Infringed = "unable to process: dictation manager still involved in previous session"; + export const Infringed = 'unable to process: dictation manager still involved in previous session'; const browser = (() => { const identifier = navigator.userAgent.toLowerCase(); - if (identifier.indexOf("safari") >= 0) { - return "Safari"; + if (identifier.indexOf('safari') >= 0) { + return 'Safari'; } - if (identifier.indexOf("chrome") >= 0) { - return "Chrome"; + if (identifier.indexOf('chrome') >= 0) { + return 'Chrome'; } - if (identifier.indexOf("firefox") >= 0) { - return "Firefox"; + if (identifier.indexOf('firefox') >= 0) { + return 'Firefox'; } - return "Unidentified Browser"; + return 'Unidentified Browser'; })(); const unsupported = `listening is not supported in ${browser}`; - const intraSession = ". "; - const interSession = " ... "; + const intraSession = '. '; + const interSession = ' ... '; export let isListening = false; let isManuallyStopped = false; @@ -74,7 +71,7 @@ export namespace DictationManager { export type InterimResultHandler = (results: string) => any; export type ContinuityArgs = { indefinite: boolean } | false; - export type DelimiterArgs = { inter: string, intra: string }; + export type DelimiterArgs = { inter: string; intra: string }; export type ListeningUIStatus = { interim: boolean } | false; export interface ListeningOptions { @@ -105,21 +102,21 @@ export namespace DictationManager { try { results = await (pendingListen = listenImpl(options)); pendingListen = undefined; - // if (results) { - // Utils.CopyText(results); - // if (overlay) { - // DictationOverlay.Instance.isListening = false; - // const execute = options?.tryExecute; - // DictationOverlay.Instance.dictatedPhrase = execute ? results.toLowerCase() : results; - // DictationOverlay.Instance.dictationSuccess = execute ? await DictationManager.Commands.execute(results) : true; - // } - // options?.tryExecute && await DictationManager.Commands.execute(results); - // } + if (results) { + Utils.CopyText(results); + if (overlay) { + DictationOverlay.Instance.isListening = false; + const execute = options?.tryExecute; + DictationOverlay.Instance.dictatedPhrase = execute ? results.toLowerCase() : results; + DictationOverlay.Instance.dictationSuccess = execute ? await DictationManager.Commands.execute(results) : true; + } + options?.tryExecute && (await DictationManager.Commands.execute(results)); + } } catch (e: any) { console.log(e); if (overlay) { DictationOverlay.Instance.isListening = false; - DictationOverlay.Instance.dictatedPhrase = results = `dictation error: ${"error" in e ? e.error : "unknown error"}`; + DictationOverlay.Instance.dictatedPhrase = results = `dictation error: ${'error' in e ? e.error : 'unknown error'}`; DictationOverlay.Instance.dictationSuccess = false; } } finally { @@ -131,7 +128,7 @@ export namespace DictationManager { const listenImpl = (options?: Partial<ListeningOptions>) => { if (!recognizer) { - console.log("DictationManager:" + unsupported); + console.log('DictationManager:' + unsupported); return unsupported; } if (isListening) { @@ -146,16 +143,17 @@ export namespace DictationManager { const intra = options?.delimiters?.intra; const inter = options?.delimiters?.inter; - recognizer.onstart = () => console.log("initiating speech recognition session..."); + recognizer.onstart = () => console.log('initiating speech recognition session...'); recognizer.interimResults = handler !== undefined; recognizer.continuous = continuous === undefined ? false : continuous !== false; - recognizer.lang = language === undefined ? "en-US" : language; + recognizer.lang = language === undefined ? 'en-US' : language; recognizer.start(); return new Promise<string>((resolve, reject) => { - recognizer.onerror = (e: any) => { // e is SpeechRecognitionError but where is that defined? - if (!(indefinite && e.error === "no-speech")) { + recognizer.onerror = (e: any) => { + // e is SpeechRecognitionError but where is that defined? + if (!(indefinite && e.error === 'no-speech')) { recognizer.stop(); resolve(e); //reject(e); @@ -165,7 +163,7 @@ export namespace DictationManager { recognizer.onresult = (e: SpeechRecognitionEvent) => { current = synthesize(e, intra); let matchedTerminator: string | undefined; - if (options?.terminators && (matchedTerminator = options.terminators.find(end => current ? current.trim().toLowerCase().endsWith(end.toLowerCase()) : false))) { + if (options?.terminators && (matchedTerminator = options.terminators.find(end => (current ? current.trim().toLowerCase().endsWith(end.toLowerCase()) : false)))) { current = matchedTerminator; recognizer.abort(); return complete(); @@ -191,7 +189,7 @@ export namespace DictationManager { current && sessionResults.push(current); sessionResults.length && resolve(sessionResults.join(inter || interSession)); } else { - resolve(current || ""); + resolve(current || ''); } current = undefined; sessionResults = []; @@ -201,7 +199,6 @@ export namespace DictationManager { recognizer.onerror = null; recognizer.onend = null; }; - }); }; @@ -222,171 +219,173 @@ export namespace DictationManager { } return transcripts.join(delimiter || intraSession); }; - } - // export namespace Commands { - - // export const dictationFadeDuration = 2000; - - // export type IndependentAction = (target: DocumentView) => any | Promise<any>; - // export type IndependentEntry = { action: IndependentAction, restrictTo?: DocumentType[] }; - - // export type DependentAction = (target: DocumentView, matches: RegExpExecArray) => any | Promise<any>; - // export type DependentEntry = { expression: RegExp, action: DependentAction, restrictTo?: DocumentType[] }; - - // export const RegisterIndependent = (key: string, value: IndependentEntry) => Independent.set(key, value); - // export const RegisterDependent = (entry: DependentEntry) => Dependent.push(entry); - - // export const execute = async (phrase: string) => { - // return UndoManager.RunInBatch(async () => { - // const targets = SelectionManager.Views(); - // if (!targets || !targets.length) { - // return; - // } - - // phrase = phrase.toLowerCase(); - // const entry = Independent.get(phrase); - - // if (entry) { - // let success = false; - // const restrictTo = entry.restrictTo; - // for (const target of targets) { - // if (!restrictTo || validate(target, restrictTo)) { - // await entry.action(target); - // success = true; - // } - // } - // return success; - // } - - // for (const entry of Dependent) { - // const regex = entry.expression; - // const matches = regex.exec(phrase); - // regex.lastIndex = 0; - // if (matches !== null) { - // let success = false; - // const restrictTo = entry.restrictTo; - // for (const target of targets) { - // if (!restrictTo || validate(target, restrictTo)) { - // await entry.action(target, matches); - // success = true; - // } - // } - // return success; - // } - // } - - // return false; - // }, "Execute Command"); - // }; - - // const ConstructorMap = new Map<DocumentType, CastCtor>([ - // [DocumentType.COL, listSpec(Doc)], - // [DocumentType.AUDIO, AudioField], - // [DocumentType.IMG, ImageField], - // [DocumentType.IMPORT, listSpec(Doc)], - // [DocumentType.RTF, "string"] - // ]); - - // const tryCast = (view: DocumentView, type: DocumentType) => { - // const ctor = ConstructorMap.get(type); - // if (!ctor) { - // return false; - // } - // return Cast(Doc.GetProto(view.props.Document).data, ctor) !== undefined; - // }; - - // const validate = (target: DocumentView, types: DocumentType[]) => { - // for (const type of types) { - // if (tryCast(target, type)) { - // return true; - // } - // } - // return false; - // }; - - // const interpretNumber = (number: string) => { - // const initial = parseInt(number); - // if (!isNaN(initial)) { - // return initial; - // } - // const converted = interpreter.wordsToNumbers(number, { fuzzy: true }); - // if (converted === null) { - // return NaN; - // } - // return typeof converted === "string" ? parseInt(converted) : converted; - // }; - - // const Independent = new Map<string, IndependentEntry>([ - - // ["clear", { - // action: (target: DocumentView) => Doc.GetProto(target.props.Document).data = new List(), - // restrictTo: [DocumentType.COL] - // }], - - // ["open fields", { - // action: (target: DocumentView) => { - // const kvp = Docs.Create.KVPDocument(target.props.Document, { _width: 300, _height: 300 }); - // target.props.addDocTab(kvp, "add:right"); - // } - // }], - - // ["new outline", { - // action: (target: DocumentView) => { - // const newBox = Docs.Create.TextDocument("", { _width: 400, _height: 200, title: "My Outline", _autoHeight: true }); - // const proto = newBox.proto!; - // const prompt = "Press alt + r to start dictating here..."; - // const head = 3; - // const anchor = head + prompt.length; - // const proseMirrorState = `{"doc":{"type":"doc","content":[{"type":"ordered_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"${prompt}"}]}]}]}]},"selection":{"type":"text","anchor":${anchor},"head":${head}}}`; - // proto.data = new RichTextField(proseMirrorState); - // proto.backgroundColor = "#eeffff"; - // target.props.addDocTab(newBox, "add:right"); - // } - // }] - - // ]); - - // const Dependent = new Array<DependentEntry>( - - // { - // expression: /create (\w+) documents of type (image|nested collection)/g, - // action: (target: DocumentView, matches: RegExpExecArray) => { - // const count = interpretNumber(matches[1]); - // const what = matches[2]; - // const dataDoc = Doc.GetProto(target.props.Document); - // const fieldKey = "data"; - // if (isNaN(count)) { - // return; - // } - // for (let i = 0; i < count; i++) { - // let created: Doc | undefined; - // switch (what) { - // case "image": - // created = Docs.Create.ImageDocument("https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg"); - // break; - // case "nested collection": - // created = Docs.Create.FreeformDocument([], {}); - // break; - // } - // created && Doc.AddDocToList(dataDoc, fieldKey, created); - // } - // }, - // restrictTo: [DocumentType.COL] - // }, - - // { - // expression: /view as (freeform|stacking|masonry|schema|tree)/g, - // action: (target: DocumentView, matches: RegExpExecArray) => { - // const mode = matches[1]; - // mode && (target.props.Document._viewType = mode); - // }, - // restrictTo: [DocumentType.COL] - // } - - // ); - - // } - -}
\ No newline at end of file + export namespace Commands { + export const dictationFadeDuration = 2000; + + export type IndependentAction = (target: DocumentView) => any | Promise<any>; + export type IndependentEntry = { action: IndependentAction; restrictTo?: DocumentType[] }; + + export type DependentAction = (target: DocumentView, matches: RegExpExecArray) => any | Promise<any>; + export type DependentEntry = { expression: RegExp; action: DependentAction; restrictTo?: DocumentType[] }; + + export const RegisterIndependent = (key: string, value: IndependentEntry) => Independent.set(key, value); + export const RegisterDependent = (entry: DependentEntry) => Dependent.push(entry); + + export const execute = async (phrase: string) => { + return UndoManager.RunInBatch(async () => { + console.log('PHRASE: ' + phrase); + const targets = SelectionManager.Views(); + if (!targets || !targets.length) { + return; + } + + phrase = phrase.toLowerCase(); + const entry = Independent.get(phrase); + + if (entry) { + let success = false; + const restrictTo = entry.restrictTo; + for (const target of targets) { + if (!restrictTo || validate(target, restrictTo)) { + await entry.action(target); + success = true; + } + } + return success; + } + + for (const entry of Dependent) { + const regex = entry.expression; + const matches = regex.exec(phrase); + regex.lastIndex = 0; + if (matches !== null) { + let success = false; + const restrictTo = entry.restrictTo; + for (const target of targets) { + if (!restrictTo || validate(target, restrictTo)) { + await entry.action(target, matches); + success = true; + } + } + return success; + } + } + + return false; + }, 'Execute Command'); + }; + + const ConstructorMap = new Map<DocumentType, CastCtor>([ + [DocumentType.COL, listSpec(Doc)], + [DocumentType.AUDIO, AudioField], + [DocumentType.IMG, ImageField], + [DocumentType.IMPORT, listSpec(Doc)], + [DocumentType.RTF, 'string'], + ]); + + const tryCast = (view: DocumentView, type: DocumentType) => { + const ctor = ConstructorMap.get(type); + if (!ctor) { + return false; + } + return Cast(Doc.GetProto(view.props.Document).data, ctor) !== undefined; + }; + + const validate = (target: DocumentView, types: DocumentType[]) => { + for (const type of types) { + if (tryCast(target, type)) { + return true; + } + } + return false; + }; + + const interpretNumber = (number: string) => { + const initial = parseInt(number); + if (!isNaN(initial)) { + return initial; + } + const converted = interpreter.wordsToNumbers(number, { fuzzy: true }); + if (converted === null) { + return NaN; + } + return typeof converted === 'string' ? parseInt(converted) : converted; + }; + + const Independent = new Map<string, IndependentEntry>([ + [ + 'clear', + { + action: (target: DocumentView) => (Doc.GetProto(target.props.Document).data = new List()), + restrictTo: [DocumentType.COL], + }, + ], + + [ + 'open fields', + { + action: (target: DocumentView) => { + const kvp = Docs.Create.KVPDocument(target.props.Document, { _width: 300, _height: 300 }); + target.props.addDocTab(kvp, 'add:right'); + }, + }, + ], + + [ + 'new outline', + { + action: (target: DocumentView) => { + const newBox = Docs.Create.TextDocument('', { _width: 400, _height: 200, title: 'My Outline', _autoHeight: true }); + const proto = newBox.proto!; + const prompt = 'Press alt + r to start dictating here...'; + const head = 3; + const anchor = head + prompt.length; + const proseMirrorState = `{"doc":{"type":"doc","content":[{"type":"ordered_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"${prompt}"}]}]}]}]},"selection":{"type":"text","anchor":${anchor},"head":${head}}}`; + proto.data = new RichTextField(proseMirrorState); + proto.backgroundColor = '#eeffff'; + target.props.addDocTab(newBox, 'add:right'); + }, + }, + ], + ]); + + const Dependent = new Array<DependentEntry>( + { + expression: /create (\w+) documents of type (image|nested collection)/g, + action: (target: DocumentView, matches: RegExpExecArray) => { + const count = interpretNumber(matches[1]); + const what = matches[2]; + const dataDoc = Doc.GetProto(target.props.Document); + const fieldKey = 'data'; + if (isNaN(count)) { + return; + } + for (let i = 0; i < count; i++) { + let created: Doc | undefined; + switch (what) { + case 'image': + created = Docs.Create.ImageDocument('https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg'); + break; + case 'nested collection': + created = Docs.Create.FreeformDocument([], {}); + break; + } + created && Doc.AddDocToList(dataDoc, fieldKey, created); + } + }, + restrictTo: [DocumentType.COL], + }, + + { + expression: /view as (freeform|stacking|masonry|schema|tree)/g, + action: (target: DocumentView, matches: RegExpExecArray) => { + const mode = matches[1]; + mode && (target.props.Document._viewType = mode); + }, + restrictTo: [DocumentType.COL], + } + ); + } +} diff --git a/src/client/util/DocumentManager.ts b/src/client/util/DocumentManager.ts index d3ac2f03f..52b643c04 100644 --- a/src/client/util/DocumentManager.ts +++ b/src/client/util/DocumentManager.ts @@ -12,6 +12,9 @@ import { CollectionFreeFormView } from '../views/collections/collectionFreeForm' import { CollectionView } from '../views/collections/CollectionView'; import { ScriptingGlobals } from './ScriptingGlobals'; import { SelectionManager } from './SelectionManager'; +import { listSpec } from '../../fields/Schema'; +import { AudioField } from '../../fields/URLField'; +const { Howl } = require('howler'); export class DocumentManager { //global holds all of the nodes (regardless of which collection they're in) @@ -186,6 +189,20 @@ export class DocumentManager { } else { finalTargetDoc.hidden && (finalTargetDoc.hidden = undefined); !noSelect && docView?.select(false); + if (originatingDoc?.followLinkAudio) { + const anno = Cast(finalTargetDoc[Doc.LayoutFieldKey(finalTargetDoc) + '-audioAnnotations'], listSpec(AudioField), null).lastElement(); + if (anno) { + if (anno instanceof AudioField) { + new Howl({ + src: [anno.url.href], + format: ['mp3'], + autoplay: true, + loop: false, + volume: 0.5, + }); + } + } + } } finished?.(); }; diff --git a/src/client/util/DragManager.ts b/src/client/util/DragManager.ts index 4338072df..ccd94c56e 100644 --- a/src/client/util/DragManager.ts +++ b/src/client/util/DragManager.ts @@ -1,4 +1,4 @@ -import { action } from 'mobx'; +import { action, observable, runInAction } from 'mobx'; import { DateField } from '../../fields/DateField'; import { Doc, Field, Opt } from '../../fields/Doc'; import { List } from '../../fields/List'; @@ -320,7 +320,7 @@ export namespace DragManager { y: snapVal([yFromTop, yFromBottom], e.pageY, SnappingManager.horizSnapLines()), }; } - export let docsBeingDragged: Doc[] = []; + export let docsBeingDragged: Doc[] = observable([] as Doc[]); export let CanEmbed = false; export let DocDragData: DocumentDragData | undefined; export function StartDrag(eles: HTMLElement[], dragData: { [id: string]: any }, downX: number, downY: number, options?: DragOptions, finishDrag?: (dropData: DragCompleteEvent) => void) { @@ -349,13 +349,13 @@ export namespace DragManager { xs: number[] = [], ys: number[] = []; - docsBeingDragged = dragData instanceof DocumentDragData ? dragData.draggedDocuments : dragData instanceof AnchorAnnoDragData ? [dragData.dragDocument] : []; const elesCont = { left: Number.MAX_SAFE_INTEGER, right: Number.MIN_SAFE_INTEGER, top: Number.MAX_SAFE_INTEGER, bottom: Number.MIN_SAFE_INTEGER, }; + const docsToDrag = dragData instanceof DocumentDragData ? dragData.draggedDocuments : dragData instanceof AnchorAnnoDragData ? [dragData.dragDocument] : []; const dragElements = eles.map(ele => { if (!ele.parentNode) dragDiv.appendChild(ele); const dragElement = ele.parentNode === dragDiv ? ele : (ele.cloneNode(true) as HTMLElement); @@ -405,7 +405,7 @@ export namespace DragManager { }); dragLabel.style.transform = `translate(${rect.left + (options?.offsetX || 0)}px, ${rect.top + (options?.offsetY || 0) - 20}px)`; - if (docsBeingDragged.length) { + if (docsToDrag.length) { const pdfBox = dragElement.getElementsByTagName('canvas'); const pdfBoxSrc = ele.getElementsByTagName('canvas'); Array.from(pdfBox) @@ -429,6 +429,8 @@ export namespace DragManager { return dragElement; }); + runInAction(() => docsBeingDragged.push(...docsToDrag)); + const hideDragShowOriginalElements = (hide: boolean) => { dragLabel.style.display = hide ? '' : 'none'; !hide && dragElements.map(dragElement => dragElement.parentNode === dragDiv && dragDiv.removeChild(dragElement)); @@ -446,16 +448,18 @@ export namespace DragManager { AbortDrag = () => { options?.dragComplete?.(new DragCompleteEvent(true, dragData)); - cleanupDrag(); + cleanupDrag(true); }; - const cleanupDrag = action(() => { + const cleanupDrag = action((undo: boolean) => { hideDragShowOriginalElements(false); document.removeEventListener('pointermove', moveHandler, true); document.removeEventListener('pointerup', upHandler, true); SnappingManager.SetIsDragging(false); SnappingManager.clearSnapLines(); - batch.end(); + const ended = batch.end(); + if (undo && ended) UndoManager.Undo(); + docsBeingDragged.length = 0; }); var startWindowDragTimer: any; const moveHandler = (e: PointerEvent) => { @@ -545,7 +549,7 @@ export namespace DragManager { const upHandler = (e: PointerEvent) => { clearTimeout(startWindowDragTimer); startWindowDragTimer = undefined; - dispatchDrag(document.elementFromPoint(e.x, e.y) || document.body, e, new DragCompleteEvent(false, dragData), snapDrag(e, xFromLeft, yFromTop, xFromRight, yFromBottom), finishDrag, options, cleanupDrag); + dispatchDrag(document.elementFromPoint(e.x, e.y) || document.body, e, new DragCompleteEvent(false, dragData), snapDrag(e, xFromLeft, yFromTop, xFromRight, yFromBottom), finishDrag, options, () => cleanupDrag(false)); }; document.addEventListener('pointermove', moveHandler, true); document.addEventListener('pointerup', upHandler, true); diff --git a/src/client/util/UndoManager.ts b/src/client/util/UndoManager.ts index d1f1a0099..d0aec45a6 100644 --- a/src/client/util/UndoManager.ts +++ b/src/client/util/UndoManager.ts @@ -1,5 +1,5 @@ -import { observable, action, runInAction } from "mobx"; -import { Without } from "../../Utils"; +import { observable, action, runInAction } from 'mobx'; +import { Without } from '../../Utils'; function getBatchName(target: any, key: string | symbol): string { const keyName = key.toString(); @@ -28,9 +28,9 @@ function propertyDecorator(target: any, key: string | symbol) { } finally { batch.end(); } - } + }, }); - } + }, }); } @@ -39,7 +39,7 @@ export function undoBatch(fn: (...args: any[]) => any): (...args: any[]) => any; export function undoBatch(target: any, key?: string | symbol, descriptor?: TypedPropertyDescriptor<any>): any { if (!key) { return function () { - const batch = UndoManager.StartBatch(""); + const batch = UndoManager.StartBatch(''); try { return target.apply(undefined, arguments); } finally { @@ -96,7 +96,7 @@ export namespace UndoManager { } export function PrintBatches(): void { - console.log("Open Undo Batches:"); + console.log('Open Undo Batches:'); GetOpenBatches().forEach(batch => console.log(batch.batchName)); } @@ -106,23 +106,25 @@ export namespace UndoManager { } export function FilterBatches(fieldTypes: string[]) { const fieldCounts: { [key: string]: number } = {}; - const lastStack = UndoManager.undoStack.slice(-1)[0];//.lastElement(); + const lastStack = UndoManager.undoStack.slice(-1)[0]; //.lastElement(); if (lastStack) { lastStack.forEach(ev => fieldTypes.includes(ev.prop) && (fieldCounts[ev.prop] = (fieldCounts[ev.prop] || 0) + 1)); const fieldCount2: { [key: string]: number } = {}; - runInAction(() => - UndoManager.undoStack[UndoManager.undoStack.length - 1] = lastStack.filter(ev => { - if (fieldTypes.includes(ev.prop)) { - fieldCount2[ev.prop] = (fieldCount2[ev.prop] || 0) + 1; - if (fieldCount2[ev.prop] === 1 || fieldCount2[ev.prop] === fieldCounts[ev.prop]) return true; - return false; - } - return true; - })); + runInAction( + () => + (UndoManager.undoStack[UndoManager.undoStack.length - 1] = lastStack.filter(ev => { + if (fieldTypes.includes(ev.prop)) { + fieldCount2[ev.prop] = (fieldCount2[ev.prop] || 0) + 1; + if (fieldCount2[ev.prop] === 1 || fieldCount2[ev.prop] === fieldCounts[ev.prop]) return true; + return false; + } + return true; + })) + ); } } export function TraceOpenBatches() { - console.log(`Open batches:\n\t${openBatches.map(batch => batch.batchName).join("\n\t")}\n`); + console.log(`Open batches:\n\t${openBatches.map(batch => batch.batchName).join('\n\t')}\n`); } export class Batch { private disposed: boolean = false; @@ -133,15 +135,15 @@ export namespace UndoManager { private dispose = (cancel: boolean) => { if (this.disposed) { - throw new Error("Cannot dispose an already disposed batch"); + throw new Error('Cannot dispose an already disposed batch'); } this.disposed = true; openBatches.splice(openBatches.indexOf(this)); - EndBatch(cancel); - } + return EndBatch(cancel); + }; - end = () => { this.dispose(false); }; - cancel = () => { this.dispose(true); }; + end = () => this.dispose(false); + cancel = () => this.dispose(true); } export function StartBatch(batchName: string): Batch { @@ -163,7 +165,9 @@ export namespace UndoManager { } redoStack.length = 0; currentBatch = undefined; + return true; } + return false; }); export function RunInTempBatch<T>(fn: () => T) { @@ -232,5 +236,4 @@ export namespace UndoManager { undoStack.push(commands); }); - -}
\ No newline at end of file +} diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx index 1d4056759..1f8550ad6 100644 --- a/src/client/views/DocumentButtonBar.tsx +++ b/src/client/views/DocumentButtonBar.tsx @@ -6,7 +6,7 @@ import { observer } from 'mobx-react'; import { Doc } from '../../fields/Doc'; import { RichTextField } from '../../fields/RichTextField'; import { Cast, NumCast } from '../../fields/Types'; -import { emptyFunction, setupMoveUpEvents, simulateMouseClick } from '../../Utils'; +import { emptyFunction, returnFalse, setupMoveUpEvents, simulateMouseClick } from '../../Utils'; import { GoogleAuthenticationManager } from '../apis/GoogleAuthenticationManager'; import { Pulls, Pushes } from '../apis/google_docs/GoogleApiClientUtils'; import { Docs } from '../documents/Documents'; @@ -21,7 +21,7 @@ import './DocumentButtonBar.scss'; import { Colors } from './global/globalEnums'; import { MetadataEntryMenu } from './MetadataEntryMenu'; import { DocumentLinksButton } from './nodes/DocumentLinksButton'; -import { DocumentView } from './nodes/DocumentView'; +import { DocumentView, DocumentViewInternal } from './nodes/DocumentView'; import { DashFieldView } from './nodes/formattedText/DashFieldView'; import { GoogleRef } from './nodes/formattedText/FormattedTextBox'; import { TemplateMenu } from './TemplateMenu'; @@ -274,12 +274,7 @@ export class DocumentButtonBar extends React.Component<{ views: () => (DocumentV get menuButton() { const targetDoc = this.view0?.props.Document; return !targetDoc ? null : ( - <Tooltip - title={ - <> - <div className="dash-tooltip">{`Open Context Menu`}</div> - </> - }> + <Tooltip title={<div className="dash-tooltip">{`Open Context Menu`}</div>}> <div className="documentButtonBar-icon" style={{ color: 'white', cursor: 'pointer' }} onClick={e => this.openContextMenu(e)}> <FontAwesomeIcon className="documentdecorations-icon" icon="bars" /> </div> @@ -334,6 +329,35 @@ export class DocumentButtonBar extends React.Component<{ views: () => (DocumentV </Tooltip> ); } + + @observable _isRecording = false; + @computed + get recordButton() { + const targetDoc = this.view0?.props.Document; + return !targetDoc ? null : ( + <Tooltip title={<div className="dash-tooltip">{'Click to record 5 second annotation'}</div>}> + <div + className="documentButtonBar-icon" + style={{ backgroundColor: this._isRecording ? 'red' : targetDoc.isLinkButton ? Colors.LIGHT_BLUE : Colors.DARK_GRAY, color: targetDoc.isLinkButton ? Colors.BLACK : Colors.WHITE }} + onClick={undoBatch( + action(e => { + this._isRecording = true; + this.props.views().map( + view => + view && + DocumentViewInternal.recordAudioAnnotation( + view.dataDoc, + view.LayoutFieldKey, + action(() => (this._isRecording = false)) + ) + ); + }) + )}> + <FontAwesomeIcon className="documentdecorations-icon" size="sm" icon="microphone" /> + </div> + </Tooltip> + ); + } @observable _aliasDown = false; onTemplateButton = action((e: React.PointerEvent): void => { this._tooltipOpen = false; @@ -411,6 +435,7 @@ export class DocumentButtonBar extends React.Component<{ views: () => (DocumentV <DocumentLinksButton View={this.view0} AlwaysOn={true} InMenu={true} StartLink={false} /> </div> ) : null} + <div className="documentButtonBar-button">{this.recordButton}</div> { Doc.noviceMode ? null : <div className="documentButtonBar-button">{this.templateButton}</div> /*<div className="documentButtonBar-button"> diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index c53e61699..3544f74b4 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -201,7 +201,10 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number; P const finished = action((force?: boolean) => { if ((force || --iconifyingCount === 0) && this._iconifyBatch) { if (this._deleteAfterIconify) { - views.forEach(iconView => iconView.props.removeDocument?.(iconView.props.Document)); + views.forEach(iconView => { + Doc.setNativeView(iconView.props.Document); + iconView.props.removeDocument?.(iconView.props.Document); + }); SelectionManager.DeselectAll(); } this._iconifyBatch?.end(); @@ -351,7 +354,7 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number; P @action onPointerDown = (e: React.PointerEvent): void => { - DragManager.docsBeingDragged = SelectionManager.Views().map(dv => dv.rootDoc); + DragManager.docsBeingDragged.push(...SelectionManager.Views().map(dv => dv.rootDoc)); this._inkDragDocs = DragManager.docsBeingDragged .filter(doc => doc.type === DocumentType.INK) .map(doc => { @@ -491,7 +494,6 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number; P let actualdW = Math.max(width + dW * scale, 20); let actualdH = Math.max(height + dH * scale, 20); const fixedAspect = nwidth && nheight && (!doc._fitWidth || e.ctrlKey || doc.nativeHeightUnfrozen); - console.log(fixedAspect); if (fixedAspect) { if ((Math.abs(dW) > Math.abs(dH) && ((!dragBottom && !dragTop) || !modifyNativeDim)) || dragRight) { if (dragRight && modifyNativeDim) { diff --git a/src/client/views/MarqueeAnnotator.tsx b/src/client/views/MarqueeAnnotator.tsx index b01ee5f42..f90ad8bb5 100644 --- a/src/client/views/MarqueeAnnotator.tsx +++ b/src/client/views/MarqueeAnnotator.tsx @@ -65,6 +65,7 @@ export class MarqueeAnnotator extends React.Component<MarqueeAnnotatorProps> { AnchorMenu.Instance.OnCrop = (e: PointerEvent) => this.props.anchorMenuCrop?.(this.highlight('rgba(173, 216, 230, 0.75)', true), true); AnchorMenu.Instance.OnClick = (e: PointerEvent) => this.props.anchorMenuClick?.()?.(this.highlight('rgba(173, 216, 230, 0.75)', true)); + AnchorMenu.Instance.OnAudio = unimplementedFunction; AnchorMenu.Instance.Highlight = this.highlight; AnchorMenu.Instance.GetAnchor = (savedAnnotations?: ObservableMap<number, HTMLDivElement[]>) => this.highlight('rgba(173, 216, 230, 0.75)', true, savedAnnotations); AnchorMenu.Instance.onMakeAnchor = AnchorMenu.Instance.GetAnchor; diff --git a/src/client/views/PropertiesButtons.tsx b/src/client/views/PropertiesButtons.tsx index 8c4c1d00b..80c2c7705 100644 --- a/src/client/views/PropertiesButtons.tsx +++ b/src/client/views/PropertiesButtons.tsx @@ -68,14 +68,6 @@ export class PropertiesButtons extends React.Component<{}, {}> { on => 'thumbtack' ); } - @computed get dictationButton() { - return this.propertyToggleBtn( - 'Dictate', - '_showAudio', - on => `${on ? 'Hide' : 'Show'} dictation/recording controls`, - on => 'microphone' - ); - } @computed get maskButton() { return this.propertyToggleBtn( 'Mask', @@ -348,7 +340,8 @@ export class PropertiesButtons extends React.Component<{}, {}> { const isInk = layoutField instanceof InkField; const isMap = this.selectedDoc?.type === DocumentType.MAP; const isCollection = this.selectedDoc?.type === DocumentType.COL; - const isStacking = this.selectedDoc?._viewType === CollectionViewType.Stacking; + //TODO: will likely need to create separate note-taking view type here + const isStacking = this.selectedDoc?._viewType === CollectionViewType.Stacking || this.selectedDoc?._viewType === CollectionViewType.NoteTaking; const isFreeForm = this.selectedDoc?._viewType === CollectionViewType.Freeform; const isTree = this.selectedDoc?._viewType === CollectionViewType.Tree; const isTabView = this.selectedTabView; @@ -364,7 +357,6 @@ export class PropertiesButtons extends React.Component<{}, {}> { {toggle(this.titleButton)} {toggle(this.captionButton)} {toggle(this.lockButton)} - {toggle(this.dictationButton, { display: isNovice ? 'none' : '' })} {toggle(this.onClickButton)} {toggle(this.fitWidthButton)} {toggle(this.freezeThumb)} diff --git a/src/client/views/PropertiesView.tsx b/src/client/views/PropertiesView.tsx index aecbc4255..ef0e057dc 100644 --- a/src/client/views/PropertiesView.tsx +++ b/src/client/views/PropertiesView.tsx @@ -1616,7 +1616,7 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { const type = PresBox.Instance.activeItem?.type; const viewType = PresBox.Instance.activeItem?._viewType; const pannable: boolean = (type === DocumentType.COL && viewType === CollectionViewType.Freeform) || type === DocumentType.IMG; - const scrollable: boolean = type === DocumentType.PDF || type === DocumentType.WEB || type === DocumentType.RTF || viewType === CollectionViewType.Stacking; + const scrollable: boolean = type === DocumentType.PDF || type === DocumentType.WEB || type === DocumentType.RTF || viewType === CollectionViewType.Stacking || viewType === CollectionViewType.NoteTaking; return ( <div className="propertiesView" style={{ width: this.props.width }}> <div className="propertiesView-title" style={{ width: this.props.width }}> diff --git a/src/client/views/SidebarAnnos.tsx b/src/client/views/SidebarAnnos.tsx index 1c14c7cd5..90d9c3c43 100644 --- a/src/client/views/SidebarAnnos.tsx +++ b/src/client/views/SidebarAnnos.tsx @@ -74,6 +74,7 @@ export class SidebarAnnos extends React.Component<FieldViewProps & ExtraProps> { DocUtils.MakeLink({ doc: anchor }, { doc: target }, 'inline comment:comment on'); this.addDocument(target); this._stackRef.current?.focusDocument(target); + return target; }; makeDocUnfiltered = (doc: Doc) => { if (DocListCast(this.props.rootDoc[this.sidebarKey]).includes(doc)) { diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx index 334f381be..3bd4f5152 100644 --- a/src/client/views/StyleProvider.tsx +++ b/src/client/views/StyleProvider.tsx @@ -110,6 +110,8 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<DocumentViewProps return undefined; case StyleProp.WidgetColor: return isAnnotated ? Colors.LIGHT_BLUE : darkScheme() ? 'lightgrey' : 'dimgrey'; + // return doc = dragManager.dragDocument ? props.dragEffects.opacity??CastofOpacityonline94 : Cast()) + // same idea for background Color case StyleProp.Opacity: return Cast(doc?._opacity, 'number', Cast(doc?.opacity, 'number', null)); case StyleProp.HideLinkButton: @@ -122,6 +124,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<DocumentViewProps return ( (doc && !doc.presentationTargetDoc && + props?.showTitle?.() !== '' && StrCast( doc._showTitle, props?.showTitle?.() || @@ -154,7 +157,9 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<DocumentViewProps case StyleProp.JitterRotation: return comicStyle() ? random(-1, 1, NumCast(doc?.x), NumCast(doc?.y)) * ((props?.PanelWidth() || 0) > (props?.PanelHeight() || 0) ? 5 : 10) : 0; case StyleProp.HeaderMargin: - return ([CollectionViewType.Stacking, CollectionViewType.Masonry, CollectionViewType.Tree].includes(doc?._viewType as any) || (doc?.type === DocumentType.RTF && !showTitle()?.includes('noMargin')) || doc?.type === DocumentType.LABEL) && + return ([CollectionViewType.Stacking, CollectionViewType.NoteTaking, CollectionViewType.Masonry, CollectionViewType.Tree].includes(doc?._viewType as any) || + (doc?.type === DocumentType.RTF && !showTitle()?.includes('noMargin')) || + doc?.type === DocumentType.LABEL) && showTitle() && !StrCast(doc?.showTitle).includes(':hover') ? 15 diff --git a/src/client/views/collections/CollectionDockingView.tsx b/src/client/views/collections/CollectionDockingView.tsx index 39e2cc17d..6d70cc0d2 100644 --- a/src/client/views/collections/CollectionDockingView.tsx +++ b/src/client/views/collections/CollectionDockingView.tsx @@ -175,7 +175,7 @@ export class CollectionDockingView extends CollectionSubView() { @undoBatch @action public static AddSplit(document: Doc, pullSide: string, stack?: any, panelName?: string) { - if (document._viewType === CollectionViewType.Docking) return DashboardView.openDashboard(document); + if (document?._viewType === CollectionViewType.Docking) return DashboardView.openDashboard(document); if (!CollectionDockingView.Instance) return false; const tab = Array.from(CollectionDockingView.Instance.tabMap).find(tab => tab.DashDoc === document); if (tab) { diff --git a/src/client/views/collections/CollectionMenu.tsx b/src/client/views/collections/CollectionMenu.tsx index 2c0e44bc3..cfbcec2d6 100644 --- a/src/client/views/collections/CollectionMenu.tsx +++ b/src/client/views/collections/CollectionMenu.tsx @@ -317,6 +317,9 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewMenu @computed get _stacking_commands() { return Doc.noviceMode ? undefined : [this._contentCommand, this._templateCommand]; } + @computed get _notetaking_commands() { + return Doc.noviceMode ? undefined : [this._contentCommand, this._templateCommand]; + } @computed get _masonry_commands() { return Doc.noviceMode ? undefined : [this._contentCommand, this._templateCommand]; } @@ -341,6 +344,8 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewMenu return this._schema_commands; case CollectionViewType.Stacking: return this._stacking_commands; + case CollectionViewType.NoteTaking: + return this._notetaking_commands; case CollectionViewType.Masonry: return this._stacking_commands; case CollectionViewType.Time: @@ -386,6 +391,8 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewMenu return <CollectionFreeFormViewChrome key="collchrome" {...this.props} isOverlay={this.props.type === CollectionViewType.Invalid} />; case CollectionViewType.Stacking: return <CollectionStackingViewChrome key="collchrome" {...this.props} />; + case CollectionViewType.NoteTaking: + return <CollectionNoteTakingViewChrome key="collchrome" {...this.props} />; case CollectionViewType.Schema: return <CollectionSchemaViewChrome key="collchrome" {...this.props} />; case CollectionViewType.Tree: @@ -588,7 +595,7 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewMenu const size: number = PresBox.Instance?._selectedArray.size; const presSelected: Doc | undefined = presArray && size ? presArray[size - 1] : undefined; const activeDoc = presSelected ? PresBox.Instance?.childDocs[PresBox.Instance?.childDocs.indexOf(presSelected) + 1] : PresBox.Instance?.childDocs[PresBox.Instance?.childDocs.length - 1]; - if (targetDoc.type === DocumentType.PDF || targetDoc.type === DocumentType.RTF || targetDoc.type === DocumentType.WEB || targetDoc._viewType === CollectionViewType.Stacking) { + if (targetDoc.type === DocumentType.PDF || targetDoc.type === DocumentType.RTF || targetDoc.type === DocumentType.WEB || targetDoc._viewType === CollectionViewType.Stacking || targetDoc._viewType === CollectionViewType.NoteTaking) { const scroll = targetDoc._scrollTop; activeDoc.presPinView = true; activeDoc.presPinViewScroll = scroll; @@ -1146,6 +1153,126 @@ export class CollectionStackingViewChrome extends React.Component<CollectionView } @observer +export class CollectionNoteTakingViewChrome extends React.Component<CollectionViewMenuProps> { + @observable private _currentKey: string = ''; + @observable private suggestions: string[] = []; + + get document() { + return this.props.docView.props.Document; + } + + @computed private get descending() { + return StrCast(this.document._columnsSort) === 'descending'; + } + @computed get pivotField() { + return StrCast(this.document._pivotField); + } + + getKeySuggestions = async (value: string): Promise<string[]> => { + const val = value.toLowerCase(); + const docs = DocListCast(this.document[this.props.fieldKey]); + + if (Doc.UserDoc().noviceMode) { + if (docs instanceof Doc) { + const keys = Object.keys(docs).filter(key => key.indexOf('title') >= 0 || key.indexOf('author') >= 0 || key.indexOf('creationDate') >= 0 || key.indexOf('lastModified') >= 0 || (key[0].toUpperCase() === key[0] && key[0] !== '_')); + return keys.filter(key => key.toLowerCase().indexOf(val) > -1); + } else { + const keys = new Set<string>(); + docs.forEach(doc => Doc.allKeys(doc).forEach(key => keys.add(key))); + const noviceKeys = Array.from(keys).filter( + key => key.indexOf('title') >= 0 || key.indexOf('author') >= 0 || key.indexOf('creationDate') >= 0 || key.indexOf('lastModified') >= 0 || (key[0]?.toUpperCase() === key[0] && key[0] !== '_') + ); + return noviceKeys.filter(key => key.toLowerCase().indexOf(val) > -1); + } + } + + if (docs instanceof Doc) { + return Object.keys(docs).filter(key => key.toLowerCase().indexOf(val) > -1); + } else { + const keys = new Set<string>(); + docs.forEach(doc => Doc.allKeys(doc).forEach(key => keys.add(key))); + return Array.from(keys).filter(key => key.toLowerCase().indexOf(val) > -1); + } + }; + + @action + onKeyChange = (e: React.ChangeEvent, { newValue }: { newValue: string }) => { + this._currentKey = newValue; + }; + + getSuggestionValue = (suggestion: string) => suggestion; + + renderSuggestion = (suggestion: string) => { + return <p>{suggestion}</p>; + }; + + onSuggestionFetch = async ({ value }: { value: string }) => { + const sugg = await this.getKeySuggestions(value); + runInAction(() => { + this.suggestions = sugg; + }); + }; + + @action + onSuggestionClear = () => { + this.suggestions = []; + }; + + @action + setValue = (value: string) => { + this.document._pivotField = value; + return true; + }; + + @action toggleSort = () => { + this.document._columnsSort = this.document._columnsSort === 'descending' ? 'ascending' : this.document._columnsSort === 'ascending' ? undefined : 'descending'; + }; + @action resetValue = () => { + this._currentKey = this.pivotField; + }; + + render() { + const doctype = this.props.docView.Document.type; + const isPres: boolean = doctype === DocumentType.PRES; + return isPres ? null : ( + <div className="collectionStackingViewChrome-cont"> + <div className="collectionStackingViewChrome-pivotField-cont"> + <div className="collectionStackingViewChrome-pivotField-label">GROUP BY:</div> + <div className="collectionStackingViewChrome-sortIcon" onClick={this.toggleSort} style={{ transform: `rotate(${this.descending ? '180' : '0'}deg)` }}> + <FontAwesomeIcon icon="caret-up" size="2x" color="white" /> + </div> + <div className="collectionStackingViewChrome-pivotField"> + <EditableView + GetValue={() => this.pivotField} + autosuggestProps={{ + resetValue: this.resetValue, + value: this._currentKey, + onChange: this.onKeyChange, + autosuggestProps: { + inputProps: { + value: this._currentKey, + onChange: this.onKeyChange, + }, + getSuggestionValue: this.getSuggestionValue, + suggestions: this.suggestions, + alwaysRenderSuggestions: true, + renderSuggestion: this.renderSuggestion, + onSuggestionsFetchRequested: this.onSuggestionFetch, + onSuggestionsClearRequested: this.onSuggestionClear, + }, + }} + oneLine + SetValue={this.setValue} + contents={this.pivotField ? this.pivotField : 'N/A'} + /> + </div> + </div> + </div> + ); + } +} + +@observer export class CollectionSchemaViewChrome extends React.Component<CollectionViewMenuProps> { // private _textwrapAllRows: boolean = Cast(this.document.textwrappedSchemaRows, listSpec("string"), []).length > 0; get document() { diff --git a/src/client/views/collections/CollectionNoteTakingView.scss b/src/client/views/collections/CollectionNoteTakingView.scss new file mode 100644 index 000000000..fe98f307e --- /dev/null +++ b/src/client/views/collections/CollectionNoteTakingView.scss @@ -0,0 +1,482 @@ +@import '../global/globalCssVariables'; + +.collectionNoteTakingView-DocumentButtons { + display: flex; + justify-content: space-between; + margin: auto; +} + +.collectionNoteTakingView-addDocumentButton { + display: flex; + overflow: hidden; + margin: auto; + width: 100%; + overflow: ellipses; + + .editableView-container-editing-oneLine, + .editableView-container-editing { + color: grey; + padding: 10px; + width: 100%; + } + + .editableView-input:hover, + .editableView-container-editing:hover, + .editableView-container-editing-oneLine:hover { + cursor: text; + } + + .editableView-input { + outline-color: black; + letter-spacing: 2px; + color: grey; + border: 0px; + padding: 12px 10px 11px 10px; + } + + font-size: 75%; + letter-spacing: 2px; + cursor: pointer; + + .editableView-input { + outline-color: black; + letter-spacing: 2px; + color: grey; + border: 0px; + padding: 12px 10px 11px 10px; + } +} + +.collectionNoteTakingView { + display: flex; +} + +// TODO:glr Turn this into a seperate class +.documentButtonMenu { + position: relative; + height: fit-content; + border-bottom: $standard-border; + display: flex; + justify-content: center; + flex-direction: column; + align-items: center; + align-content: center; + padding: 5px 0 5px 0; + + .documentExplanation { + width: 90%; + margin: 5px; + font-size: 11px; + background-color: $light-blue; + color: $medium-blue; + padding: 10px; + border-radius: 10px; + border: solid 2px $medium-blue; + } +} + +.collectionNoteTakingView { + height: 100%; + width: 100%; + position: absolute; + top: 0; + overflow-y: auto; + overflow-x: hidden; + flex-wrap: wrap; + transition: top 0.5s; + + > div { + position: relative; + display: block; + } + + .collectionSchemaView-previewDoc { + height: 100%; + position: absolute; + } + + .collectionNoteTakingView-docView-container { + width: 45%; + margin: 5% 2.5%; + padding-left: 2.5%; + height: auto; + } + + .collectionNoteTakingView-Nodes { + width: 100%; + height: 100%; + position: absolute; + display: flex; + flex-direction: column; + align-items: center; + top: 0; + left: 0; + width: 100%; + position: absolute; + } + + .collectionNoteTakingView-description { + font-size: 100%; + margin-bottom: 1vw; + padding: 10px; + height: 2vw; + width: 100%; + font-family: $sans-serif; + background: $dark-gray; + color: $white; + } + + .collectionNoteTakingView-columnDragger { + width: 15; + height: 15; + position: absolute; + margin-left: -5; + } + + // Documents in NoteTaking view + .collectionNoteTakingView-columnDoc { + display: flex; + // margin: auto; // Removed auto so that it is no longer center aligned - this could be something we change + position: relative; + + &:hover { + .hoverButtons { + opacity: 1; + } + } + + .hoverButtons { + display: flex; + opacity: 0; + position: absolute; + height: 100%; + left: -35px; + justify-content: center; + align-items: center; + padding: 0px 10px; + + .buttonWrapper { + padding: 3px; + border-radius: 3px; + + &:hover { + background: rgba(0, 0, 0, 0.26); + } + } + } + } + + .collectionNoteTakingView-collapseBar { + margin-left: 2px; + margin-right: 2px; + margin-top: 2px; + background: $medium-gray; + height: 5px; + + &.active { + margin-left: 0; + margin-right: 0; + background: red; + } + } + + .collectionNoteTakingView-miniHeader { + width: 100%; + + .editableView-container-editing-oneLine { + min-height: 20px; + display: flex; + align-items: center; + flex-direction: row; + } + + span::before, + span::after { + content: ''; + width: 50%; + border-top: dashed gray 1px; + position: relative; + display: inline-block; + } + + span::before { + margin-right: 10px; + } + + span::after { + margin-left: 10px; + } + + span { + position: relative; + text-align: center; + white-space: nowrap; + overflow: visible; + width: 100%; + display: flex; + color: gray; + align-items: center; + } + } + + .collectionNoteTakingView-sectionHeader { + text-align: center; + margin: auto; + margin-bottom: 10px; + background: $medium-gray; + // overflow: hidden; overflow is visible so the color menu isn't hidden -ftong + + .editableView-input { + color: $dark-gray; + } + + .editableView-input:hover, + .editableView-container-editing:hover, + .editableView-container-editing-oneLine:hover { + cursor: text; + } + + .collectionNoteTakingView-sectionHeader-subCont { + outline: none; + border: 0px; + width: 100%; + letter-spacing: 2px; + font-size: 75%; + transition: transform 0.2s; + position: relative; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + color: $dark-gray; + + .editableView-container-editing-oneLine, + .editableView-container-editing { + color: grey; + padding: 10px; + } + + .editableView-input:hover, + .editableView-container-editing:hover, + .editableView-container-editing-oneLine:hover { + cursor: text; + } + + .editableView-input { + padding: 12px 10px 11px 10px; + border: 0px; + color: grey; + text-align: center; + letter-spacing: 2px; + outline-color: black; + } + } + + .collectionNoteTakingView-sectionColor { + position: absolute; + left: 0; + top: 0; + height: 100%; + display: none; + + [class*='css'] { + max-width: 102px; + } + + .collectionNoteTakingView-sectionColorButton { + height: 30px; + display: inherit; + } + + .collectionNoteTakingView-colorPicker { + width: 78px; + z-index: 10; + position: relative; + background: white; + + .colorOptions { + display: flex; + flex-wrap: wrap; + } + + .colorPicker { + cursor: pointer; + width: 20px; + height: 20px; + border-radius: 10px; + margin: 3px; + + &.active { + border: 2px solid white; + box-shadow: 0 0 0 2px lightgray; + } + } + } + } + + .collectionNoteTakingView-sectionOptions { + position: absolute; + right: 0; + top: 0; + height: 100%; + display: none; + + [class*='css'] { + max-width: 102px; + } + + .collectionNoteTakingView-sectionOptionButton { + height: 30px; + } + + .collectionNoteTakingView-optionPicker { + .optionOptions { + display: inline; + } + + .optionPicker { + cursor: pointer; + height: 20px; + border-radius: 10px; + margin: 3px; + width: max-content; + + &.active { + color: red; + } + } + } + } + + .collectionNoteTakingView-sectionDelete { + position: absolute; + right: 25px; + top: 0; + height: 100%; + display: none; + } + } + + .collectionNoteTakingView-sectionHeader:hover { + .collectionNoteTakingView-sectionColor { + display: unset; + } + + .collectionNoteTakingView-sectionOptions { + display: unset; + } + + .collectionNoteTakingView-sectionDelete { + display: unset; + } + } + + .collectionNoteTakingView-addDocumentButton, + .collectionNoteTakingView-addGroupButton { + display: flex; + overflow: hidden; + margin: auto; + width: 90%; + overflow: ellipses; + + .editableView-container-editing-oneLine, + .editableView-container-editing { + color: grey; + padding: 10px; + width: 100%; + } + + .editableView-input:hover, + .editableView-container-editing:hover, + .editableView-container-editing-oneLine:hover { + cursor: text; + } + + .editableView-input { + outline-color: black; + letter-spacing: 2px; + color: grey; + border: 0px; + padding: 12px 10px 11px 10px; + } + } + + .collectionNoteTakingView-addDocumentButton { + font-size: 75%; + letter-spacing: 2px; + cursor: pointer; + + .editableView-input { + outline-color: black; + letter-spacing: 2px; + color: grey; + border: 0px; + padding: 12px 10px 11px 10px; + } + } + + .collectionNoteTakingView-addGroupButton { + background: rgb(238, 238, 238); + font-size: 75%; + text-align: center; + letter-spacing: 2px; + height: fit-content; + } + + .rc-switch { + position: absolute; + display: inline-block; + bottom: 4px; + right: 4px; + width: 70px; + height: 30px; + border-radius: 40px 40px; + background-color: lightslategrey; + } + + .rc-switch:after { + position: absolute; + width: 22px; + height: 22px; + left: 3px; + top: 4px; + border-radius: 50% 50%; + background-color: #fff; + content: ' '; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.26); + -webkit-transform: scale(1); + transform: scale(1); + transition: left 0.3s cubic-bezier(0.35, 0, 0.25, 1); + -webkit-animation-timing-function: cubic-bezier(0.35, 0, 0.25, 1); + animation-timing-function: cubic-bezier(0.35, 0, 0.25, 1); + -webkit-animation-duration: 0.3s; + animation-duration: 0.3s; + } + + .rc-switch-checked:after { + left: 44px; + } + + .rc-switch-inner { + color: #fff; + font-size: 12px; + position: absolute; + left: 28px; + top: 8px; + } + + .rc-switch-checked .rc-switch-inner { + left: 8px; + } +} + +@media only screen and (max-device-width: 480px) { + .collectionNoteTakingView .collectionNoteTakingView-columnDragger, + .collectionNoteTakingView-columnDragger { + width: 0.1; + height: 0.1; + opacity: 0; + font-size: 0; + } +} diff --git a/src/client/views/collections/CollectionNoteTakingView.tsx b/src/client/views/collections/CollectionNoteTakingView.tsx new file mode 100644 index 000000000..5c8b10ae1 --- /dev/null +++ b/src/client/views/collections/CollectionNoteTakingView.tsx @@ -0,0 +1,730 @@ +import React = require('react'); +import { CursorProperty } from 'csstype'; +import { action, computed, IReactionDisposer, observable, reaction, runInAction } from 'mobx'; +import { observer } from 'mobx-react'; +import { DataSym, Doc, Field, HeightSym, Opt, WidthSym } from '../../../fields/Doc'; +import { Id } from '../../../fields/FieldSymbols'; +import { List } from '../../../fields/List'; +import { listSpec } from '../../../fields/Schema'; +import { SchemaHeaderField } from '../../../fields/SchemaHeaderField'; +import { BoolCast, Cast, NumCast, ScriptCast, StrCast } from '../../../fields/Types'; +import { TraceMobx } from '../../../fields/util'; +import { emptyFunction, returnEmptyDoclist, returnFalse, returnTrue, returnZero, smoothScroll, Utils } from '../../../Utils'; +import { Docs, DocUtils } from '../../documents/Documents'; +import { CollectionViewType, DocumentType } from '../../documents/DocumentTypes'; +import { DragManager, dropActionType } from '../../util/DragManager'; +import { SnappingManager } from '../../util/SnappingManager'; +import { Transform } from '../../util/Transform'; +import { undoBatch } from '../../util/UndoManager'; +import { ContextMenu } from '../ContextMenu'; +import { ContextMenuProps } from '../ContextMenuItem'; +import { LightboxView } from '../LightboxView'; +import { CollectionFreeFormDocumentView } from '../nodes/CollectionFreeFormDocumentView'; +import { DocFocusOptions, DocumentView, DocumentViewProps, ViewAdjustment } from '../nodes/DocumentView'; +import { FieldViewProps } from '../nodes/FieldView'; +import { FormattedTextBox } from '../nodes/formattedText/FormattedTextBox'; +import { StyleProp } from '../StyleProvider'; +import './CollectionNoteTakingView.scss'; +import { CollectionNoteTakingViewColumn } from './CollectionNoteTakingViewColumn'; +import { CollectionNoteTakingViewDivider } from './CollectionNoteTakingViewDivider'; +import { CollectionSubView } from './CollectionSubView'; +const _global = (window /* browser */ || global) /* node */ as any; + +export type collectionNoteTakingViewProps = { + chromeHidden?: boolean; + viewType?: CollectionViewType; + NativeWidth?: () => number; + NativeHeight?: () => number; +}; + +//TODO: somehow need to update the mapping and then have everything else rerender. Maybe with a refresh boolean like +// in Hypermedia? + +@observer +export class CollectionNoteTakingView extends CollectionSubView<Partial<collectionNoteTakingViewProps>>() { + _disposers: { [key: string]: IReactionDisposer } = {}; + _masonryGridRef: HTMLDivElement | null = null; + _draggerRef = React.createRef<HTMLDivElement>(); // change to relative widths for deleting. change storage from columnStartXCoords to columnHeaders (schemaHeaderFields has a widgth alrady) + @observable columnStartXCoords: number[] = []; // columnHeaders -- SchemaHeaderField -- widht + @observable docsDraggedRowCol: number[] = []; + @observable _cursor: CursorProperty = 'grab'; + @observable _scroll = 0; // used to force the document decoration to update when scrolling + @computed get chromeHidden() { + return this.props.chromeHidden || BoolCast(this.layoutDoc.chromeHidden); + } + @computed get columnHeaders() { + const columnHeaders = Array.from(Cast(this.dataDoc.columnHeaders, listSpec(SchemaHeaderField), null)); + const needsUnsetCategory = this.childDocs.some(d => !d[this.notetakingCategoryField] && !columnHeaders.find(sh => sh.heading === 'unset')); + + // @#Oberable draggedColIndex = ... + //@observable cloneDivXYcoords + // @observable clonedDiv... + + // render() { + // { this.clonedDiv ? <div clone styule={{transform: clonedDivXYCoords}} : null} + // } + + // in NoteatakinView Column code, add a poinerDown handler that calls setupMoveUpEvents() which will clone the column div + // and re-render it under the cursor during move events. + // that move move event will update 2 observales -- the draggedColIndex up above, and the location of the clonedDiv so that the render in this view will know where to render the cloned div + // add observable variable that tells drag column to rnder in a different location than where the schemaHeaderFiel sa y ot. + // if (col 1 is where col 3) { + // return 3 2 1 4 56 + // } + if (needsUnsetCategory) { + columnHeaders.push(new SchemaHeaderField('unset')); + } + return columnHeaders; + } + @computed get notetakingCategoryField() { + return 'NotetakingCategory'; + } + @computed get filteredChildren() { + return this.childLayoutPairs.filter(pair => pair.layout instanceof Doc && !pair.layout.hidden).map(pair => pair.layout); + } + @computed get headerMargin() { + return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.HeaderMargin); + } + @computed get xMargin() { + return NumCast(this.layoutDoc._xMargin, 2 * Math.min(this.gridGap, 0.05 * this.props.PanelWidth())); + } + @computed get yMargin() { + return this.props.yPadding || NumCast(this.layoutDoc._yMargin, 5); + } // 2 * this.gridGap)); } + @computed get gridGap() { + return NumCast(this.layoutDoc._gridGap, 10); + } + @computed get numGroupColumns() { + return this.columnHeaders.length; + } + @computed get PanelWidth() { + return this.props.PanelWidth(); + } + @computed get maxColWdith() { + return this.props.PanelWidth() - 2 * this.xMargin; + } + + // If the user has not yet created any docs (in another view), this will create a single column. Otherwise, + // it will adjust according to the + constructor(props: any) { + super(props); + if (this.columnHeaders === undefined) { + this.dataDoc.columnHeaders = new List<SchemaHeaderField>([new SchemaHeaderField('New Column')]); + // add all of the docs that have not been added to a column to this new column + } + } + + // passed as a prop to the NoteTakingField, which uses this function + // to render the docs you see within an individual column. + children = (docs: Doc[]) => { + TraceMobx(); + return docs.map((d, i) => { + const height = () => this.getDocHeight(d); + const width = () => this.getDocWidth(d); + const style = { width: width(), marginTop: this.gridGap, height: height() }; + return ( + <div className={`collectionNoteTakingView-columnDoc`} key={d[Id]} style={style}> + {this.getDisplayDoc(d, width)} + </div> + ); + }); + }; + + // [CAVEATS] (1) keep track of the offsetting + // (2) documentView gets unmounted as you remove it from the list + @computed get Sections() { + TraceMobx(); + const columnHeaders = this.columnHeaders; + let docs = this.childDocs; + const sections = new Map<SchemaHeaderField, Doc[]>(columnHeaders.map(sh => [sh, []] as [SchemaHeaderField, []])); + const rowCol = this.docsDraggedRowCol; + + // filter out the currently dragged docs from the child docs, since we will insert them later + if (rowCol.length && DragManager.docsBeingDragged.length) { + const docIdsToRemove = new Set(); + DragManager.docsBeingDragged.forEach(d => docIdsToRemove.add(d[Id])); + docs = docs.filter(d => !docIdsToRemove.has(d[Id])); + } + + // this will sort the docs into the correct columns (minus the ones you're currently dragging) + docs.map(d => { + const sectionValue = (d[this.notetakingCategoryField] as object) ?? `unset`; + + // look for if header exists already + const existingHeader = columnHeaders.find(sh => sh.heading === sectionValue.toString()); + if (existingHeader) { + sections.get(existingHeader)!.push(d); + } + }); + + // now we add back in the docs that we're dragging + if (rowCol.length && DragManager.docsBeingDragged.length) { + const colHeader = columnHeaders[rowCol[1]]; + // TODO: get the actual offset that occurs if the docs were in that column + const offset = 0; + sections.get(colHeader)?.splice(rowCol[0] - offset, 0, ...DragManager.docsBeingDragged); + } + return sections; + } + + removeDocDragHighlight = () => { + setTimeout( + action(() => (this.docsDraggedRowCol.length = 0)), + 100 + ); + }; + componentDidMount() { + super.componentDidMount?.(); + document.addEventListener('pointerup', this.removeDocDragHighlight, true); + this._disposers.autoHeight = reaction( + () => this.layoutDoc._autoHeight, + autoHeight => autoHeight && this.props.setHeight?.(Math.min(NumCast(this.layoutDoc._maxHeight, Number.MAX_SAFE_INTEGER), this.headerMargin + Math.max(...this.refList.map(r => Number(getComputedStyle(r).height.replace('px', '')))))) + ); + this._disposers.headers = reaction( + () => this.columnHeaders.slice(), + headers => this.resizeColumns(headers.length), + { fireImmediately: true } + ); + } + + componentWillUnmount() { + document.removeEventListener('pointerup', this.removeDocDragHighlight, true); + super.componentWillUnmount(); + Object.keys(this._disposers).forEach(key => this._disposers[key]()); + } + + @action + moveDocument = (doc: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean): boolean => { + return this.props.removeDocument?.(doc) && addDocument?.(doc) ? true : false; + }; + + createRef = (ele: HTMLDivElement | null) => { + this._masonryGridRef = ele; + this.createDashEventsTarget(ele!); //so the whole grid is the drop target? + }; + + @computed get onChildClickHandler() { + return () => this.props.childClickScript || ScriptCast(this.Document.onChildClick); + } + @computed get onChildDoubleClickHandler() { + return () => this.props.childDoubleClickScript || ScriptCast(this.Document.onChildDoubleClick); + } + + addDocTab = (doc: Doc, where: string) => { + if (where === 'inPlace' && this.layoutDoc.isInPlaceContainer) { + this.dataDoc[this.props.fieldKey] = new List<Doc>([doc]); + return true; + } + return this.props.addDocTab(doc, where); + }; + + scrollToBottom = () => { + smoothScroll(500, this._mainCont!, this._mainCont!.scrollHeight); + }; + + // let's dive in and get the actual document we want to drag/move around + focusDocument = (doc: Doc, options?: DocFocusOptions) => { + Doc.BrushDoc(doc); + + let focusSpeed = 0; + const found = this._mainCont && Array.from(this._mainCont.getElementsByClassName('documentView-node')).find((node: any) => node.id === doc[Id]); + if (found) { + const top = found.getBoundingClientRect().top; + const localTop = this.props.ScreenToLocalTransform().transformPoint(0, top); + if (Math.floor(localTop[1]) !== 0) { + smoothScroll((focusSpeed = doc.presTransition || doc.presTransition === 0 ? NumCast(doc.presTransition) : 500), this._mainCont!, localTop[1] + this._mainCont!.scrollTop); + } + } + const endFocus = async (moved: boolean) => (options?.afterFocus ? options?.afterFocus(moved) : ViewAdjustment.doNothing); + this.props.focus(this.rootDoc, { + willZoom: options?.willZoom, + scale: options?.scale, + afterFocus: (didFocus: boolean) => new Promise<ViewAdjustment>(res => setTimeout(async () => res(await endFocus(didFocus)), focusSpeed)), + }); + }; + + styleProvider = (doc: Doc | undefined, props: Opt<DocumentViewProps>, property: string) => { + if (property === StyleProp.BoxShadow && doc && DragManager.docsBeingDragged.includes(doc)) { + return `#9c9396 ${StrCast(doc?.boxShadow, '10px 10px 0.9vw')}`; + } + if (property === StyleProp.Opacity && doc) { + if (this.props.childOpacity) { + return this.props.childOpacity(); + } + if (this.Document._currentFrame !== undefined) { + return CollectionFreeFormDocumentView.getValues(doc, NumCast(this.Document._currentFrame))?.opacity; + } + } + return this.props.styleProvider?.(doc, props, property); + }; + + isContentActive = () => this.props.isSelected() || this.props.isContentActive(); + + // rules for displaying the documents + getDisplayDoc(doc: Doc, width: () => number) { + const dataDoc = !doc.isTemplateDoc && !doc.isTemplateForField && !doc.PARAMS ? undefined : this.props.DataDoc; + const height = () => this.getDocHeight(doc); + let dref: Opt<DocumentView>; + const noteTakingDocTransform = () => this.getDocTransform(doc, dref); + return ( + <DocumentView + ref={r => (dref = r || undefined)} + Document={doc} + DataDoc={dataDoc || (!Doc.AreProtosEqual(doc[DataSym], doc) && doc[DataSym])} + renderDepth={this.props.renderDepth + 1} + PanelWidth={width} + PanelHeight={height} + styleProvider={this.styleProvider} + docViewPath={this.props.docViewPath} + fitWidth={this.props.childFitWidth} + isContentActive={emptyFunction} + onKey={this.onKeyDown} + //TODO: change this from a prop to a parameter passed into a function + dontHideOnDrag={true} + isDocumentActive={this.isContentActive} + LayoutTemplate={this.props.childLayoutTemplate} + LayoutTemplateString={this.props.childLayoutString} + NativeWidth={this.props.childIgnoreNativeSize ? returnZero : this.props.childFitWidth?.(doc) || (doc._fitWidth && !Doc.NativeWidth(doc)) ? width : undefined} // explicitly ignore nativeWidth/height if childIgnoreNativeSize is set- used by PresBox + NativeHeight={this.props.childIgnoreNativeSize ? returnZero : this.props.childFitWidth?.(doc) || (doc._fitWidth && !Doc.NativeHeight(doc)) ? height : undefined} + dontCenter={this.props.childIgnoreNativeSize ? 'xy' : undefined} + dontRegisterView={dataDoc ? true : BoolCast(this.layoutDoc.childDontRegisterViews, this.props.dontRegisterView)} + rootSelected={this.rootSelected} + showTitle={this.props.childShowTitle} + dropAction={StrCast(this.layoutDoc.childDropAction) as dropActionType} + onClick={this.onChildClickHandler} + onDoubleClick={this.onChildDoubleClickHandler} + ScreenToLocalTransform={noteTakingDocTransform} + focus={this.focusDocument} + docFilters={this.childDocFilters} + hideDecorationTitle={this.props.childHideDecorationTitle?.()} + hideResizeHandles={this.props.childHideResizeHandles?.()} + 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} + contentPointerEvents={StrCast(this.layoutDoc.contentPointerEvents)} + whenChildContentsActiveChanged={this.props.whenChildContentsActiveChanged} + addDocTab={this.addDocTab} + bringToFront={returnFalse} + scriptContext={this.props.scriptContext} + pinToPres={this.props.pinToPres} + /> + ); + } + + // This is used to get the coordinates of a document when we go from a view like freeform to columns + getDocTransform(doc: Doc, dref?: DocumentView) { + const y = this._scroll; // required for document decorations to update when the text box container is scrolled + const { scale, translateX, translateY } = Utils.GetScreenTransform(dref?.ContentDiv || undefined); + // the document view may center its contents and if so, will prepend that onto the screenToLocalTansform. so we have to subtract that off + return new Transform(-translateX + (dref?.centeringX || 0), -translateY + (dref?.centeringY || 0), 1).scale(this.props.ScreenToLocalTransform().Scale); + } + + // how to get the width of a document. Currently returns the width of the column (minus margins) + // if a note doc. Otherwise, returns the normal width (for graphs, images, etc...) + getDocWidth(d: Doc) { + const heading = !d[this.notetakingCategoryField] ? 'unset' : Field.toString(d[this.notetakingCategoryField] as Field); + const existingHeader = this.columnHeaders.find(sh => sh.heading === heading); + const index = existingHeader ? this.columnHeaders.indexOf(existingHeader) : 0; + const endColValue = index === this.columnHeaders.length - 1 || index > this.columnStartXCoords.length - 1 ? this.PanelWidth : this.columnStartXCoords[index + 1]; + const maxWidth = index > this.columnStartXCoords.length - 1 ? this.PanelWidth : endColValue - this.columnStartXCoords[index] - 3 * this.xMargin; + if (d.type === DocumentType.RTF) { + return maxWidth; + } + const width = d[WidthSym](); + return width < maxWidth ? width : maxWidth; + } + + // how to get the height of a document. Nothing special here. + 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 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); + if (nw && nh) { + // const colWid = this.columnWidth / this.numGroupColumns; + // const docWid = this.layoutDoc._columnsFill ? colWid : Math.min(this.getDocWidth(d), colWid); + const docWid = this.getDocWidth(d); + return Math.min(maxHeight, (docWid * nh) / nw); + } + const childHeight = NumCast(childLayoutDoc._height); + const panelHeight = childLayoutDoc._fitWidth || this.props.childFitWidth?.(d) ? Number.MAX_SAFE_INTEGER : this.props.PanelHeight() - 2 * this.yMargin; + return Math.min(childHeight, maxHeight, panelHeight); + } + + // called when a column is either added or deleted. This function creates n evenly spaced columns + @action + resizeColumns = (n: number) => { + const totalWidth = this.PanelWidth; + const dividerWidth = 32; + const totaldividerWidth = (n - 1) * dividerWidth; + const colWidth = (totalWidth - totaldividerWidth) / n; + const newColXCoords: number[] = []; + let colStart = 0; + for (let i = 0; i < n; i++) { + newColXCoords.push(colStart); + colStart += colWidth + dividerWidth; + } + this.columnStartXCoords = newColXCoords; + }; + + // This function is used to preview where a document will drop in a column once a drag is complete. + @action + onPointerMove = (force: boolean, ex: number, ey: number) => { + if (this.childDocList && (this.childDocList.includes(DragManager.DocDragData?.draggedDocuments.lastElement()!) || force || this.isContentActive())) { + // get the current docs for the column based on the mouse's x coordinate + // will use again later, which is why we're saving as local + const xCoord = this.props.ScreenToLocalTransform().transformPoint(ex, ey)[0] - 2 * this.gridGap; + const colDocs = this.getDocsFromXCoord(xCoord); + // get the index for where you need to insert the doc you are currently dragging + const clientY = this.props.ScreenToLocalTransform().transformPoint(ex, ey)[1]; + let dropInd = -1; + let pos0 = (this.refList.lastElement() as HTMLDivElement).children[0].getBoundingClientRect().height + this.yMargin * 2; + colDocs.forEach((doc, i) => { + let pos1 = this.getDocHeight(doc) + 2 * this.gridGap; + pos1 += pos0; + // updating drop position based on y coordinates + const yCoordInBetween = clientY > pos0 && clientY < pos1; + if (yCoordInBetween || (clientY < pos0 && i === 0)) { + dropInd = i; + } else if (i === colDocs.length - 1 && dropInd === -1) { + dropInd = !colDocs.includes(DragManager.docsBeingDragged.lastElement()) ? i + 1 : i; + } + pos0 = pos1; + }); + // we alter the pivot fields of the docs in case they are moved to a new column. + const colIndex = this.getColumnFromXCoord(xCoord); + const colHeader = StrCast(this.columnHeaders[colIndex].heading); + DragManager.docsBeingDragged.forEach(d => (d[this.notetakingCategoryField] = colHeader)); + // used to notify sections to re-render + this.docsDraggedRowCol.length = 0; + this.docsDraggedRowCol.push(dropInd, this.getColumnFromXCoord(xCoord)); + } + }; + + // returns the column index for a given x-coordinate + getColumnFromXCoord = (xCoord: number): number => { + const numColumns = this.columnHeaders.length; + const coords = this.columnStartXCoords.slice(); + coords.push(this.PanelWidth); + let colIndex = 0; + for (let i = 0; i < numColumns; i++) { + if (xCoord > coords[i] && xCoord < coords[i + 1]) { + colIndex = i; + break; + } + } + return colIndex; + }; + + // returns the docs of a column based on the x-coordinate provided. + getDocsFromXCoord = (xCoord: number): Doc[] => { + const colIndex = this.getColumnFromXCoord(xCoord); + const colHeader = StrCast(this.columnHeaders[colIndex].heading); + // const docs = this.childDocList + const docs = this.childDocs; + const docsMatchingHeader: Doc[] = []; + if (docs) { + docs.map(d => { + if (d instanceof Promise) return; + const sectionValue = (d[this.notetakingCategoryField] as object) ?? 'unset'; + if (sectionValue.toString() == colHeader) { + docsMatchingHeader.push(d); + } + }); + } + return docsMatchingHeader; + }; + + @undoBatch + @action + onKeyDown = (e: React.KeyboardEvent, fieldProps: FieldViewProps) => { + const docView = fieldProps.DocumentView?.(); + if (docView && (e.ctrlKey || docView.rootDoc._singleLine) && ['Enter'].includes(e.key)) { + e.stopPropagation?.(); + const newDoc = Doc.MakeCopy(docView.rootDoc, true); + Doc.GetProto(newDoc).text = undefined; + FormattedTextBox.SelectOnLoad = newDoc[Id]; + return this.addDocument?.(newDoc); + } + }; + + @undoBatch + @action + onInternalDrop = (e: Event, de: DragManager.DropEvent) => { + if (de.complete.docDragData) { + if (super.onInternalDrop(e, de)) { + // filter out the currently dragged docs from the child docs, since we will insert them later + const rowCol = this.docsDraggedRowCol; + const droppedDocs = this.childDocs.slice().filter((d: Doc, ind: number) => ind >= this.childDocs.length); // if the drop operation adds something to the end of the list, then use that as the new document (may be different than what was dropped e.g., in the case of a button which is dropped but which creates say, a note). + const newDocs = droppedDocs.length ? droppedDocs : de.complete.docDragData.droppedDocuments; + + // const docs = this.childDocs + const docs = this.childDocList; + if (docs && newDocs.length) { + // remove the dragged documents from the childDocList + newDocs.filter(d => docs.indexOf(d) !== -1).forEach(d => docs.splice(docs.indexOf(d), 1)); + // if the doc starts a columnm (or the drop index is undefined), we can just push it to the front. Otherwise we need to add it to the column properly + //TODO: you need to update childDocList instead. It seems that childDocs is a copy of the actual array we want to modify + if (rowCol[0] <= 0) { + docs.splice(0, 0, ...newDocs); + } else { + const colDocs = this.getDocsFromXCoord(this.props.ScreenToLocalTransform().transformPoint(de.x, de.y)[0]); + const previousDoc = colDocs[rowCol[0] - 1]; + const previousDocIndex = docs.indexOf(previousDoc); + docs.splice(previousDocIndex + 1, 0, ...newDocs); + } + } + } + } // it seems like we're creating a link here. Weird. I didn't know that you could establish links by dragging + else if (de.complete.linkDragData?.dragDocument.context === this.props.Document && de.complete.linkDragData?.linkDragView?.props.CollectionFreeFormDocumentView?.()) { + const source = Docs.Create.TextDocument('', { _width: 200, _height: 75, _fitWidth: true, title: 'dropped annotation' }); + this.props.addDocument?.(source); + de.complete.linkDocument = DocUtils.MakeLink({ doc: source }, { doc: de.complete.linkDragData.linkSourceGetAnchor() }, 'doc annotation', ''); // TODODO this is where in text links get passed + e.stopPropagation(); + } else if (de.complete.annoDragData?.dragDocument && super.onInternalDrop(e, de)) return this.internalAnchorAnnoDrop(e, de.complete.annoDragData); + return false; + }; + + @undoBatch + internalAnchorAnnoDrop(e: Event, annoDragData: DragManager.AnchorAnnoDragData) { + const dropCreator = annoDragData.dropDocCreator; + annoDragData.dropDocCreator = (annotationOn: Doc | undefined) => { + const dropDoc = dropCreator(annotationOn); + return dropDoc || this.rootDoc; + }; + return true; + } + + // when dropping outside of the current noteTaking context (like a new tab, freeform view, etc...) + onExternalDrop = async (e: React.DragEvent): Promise<void> => { + const targInd = this.docsDraggedRowCol?.[0] || 0; + const colInd = this.docsDraggedRowCol?.[1] || 0; + super.onExternalDrop( + e, + {}, + undoBatch( + action(docus => { + this.onPointerMove(true, e.clientX, e.clientY); + docus?.map((doc: Doc) => this.addDocument(doc)); + const newDoc = this.childDocs.lastElement(); + const colHeader = StrCast(this.columnHeaders[colInd].heading); + newDoc[this.notetakingCategoryField] = colHeader; + const docs = this.childDocList; + if (docs && targInd !== -1) { + docs.splice(docs.length - 1, 1); + docs.splice(targInd, 0, newDoc); + } + this.removeDocDragHighlight(); + }) + ) + ); + }; + + headings = () => Array.from(this.Sections); + + refList: any[] = []; + editableViewProps = () => ({ + GetValue: () => '', + SetValue: this.addGroup, + contents: '+ New Column', + }); + + sectionNoteTaking = (heading: SchemaHeaderField | undefined, docList: Doc[]) => { + const type = 'number'; + return ( + <CollectionNoteTakingViewColumn + unobserveHeight={ref => this.refList.splice(this.refList.indexOf(ref), 1)} + observeHeight={ref => { + if (ref) { + this.refList.push(ref); + this.observer = new _global.ResizeObserver( + action((entries: any) => { + if (this.layoutDoc._autoHeight && ref && this.refList.length && !SnappingManager.GetIsDragging()) { + const height = this.headerMargin + Math.min(NumCast(this.layoutDoc._maxHeight, Number.MAX_SAFE_INTEGER), Math.max(...this.refList.map(r => Number(getComputedStyle(r).height.replace('px', ''))))); + if (!LightboxView.IsLightboxDocView(this.props.docViewPath())) { + this.props.setHeight?.(height); + } + } + }) + ); + this.observer.observe(ref); + } + }} + addDocument={this.addDocument} + // docsByColumnHeader={this._docsByColumnHeader} + // setDocsForColHeader={this.setDocsForColHeader} + chromeHidden={this.chromeHidden} + columnHeaders={this.columnHeaders} + Document={this.props.Document} + DataDoc={this.props.DataDoc} + resizeColumns={this.resizeColumns} + renderChildren={this.children} + numGroupColumns={this.numGroupColumns} + gridGap={this.gridGap} + pivotField={this.notetakingCategoryField} + columnStartXCoords={this.columnStartXCoords} + maxColWidth={this.maxColWdith} + PanelWidth={this.PanelWidth} + key={heading?.heading ?? ''} + headings={this.headings} + heading={heading?.heading ?? ''} + headingObject={heading} + docList={docList} + yMargin={this.yMargin} + type={type} + createDropTarget={this.createDashEventsTarget} + screenToLocalTransform={this.props.ScreenToLocalTransform} + editableViewProps={this.editableViewProps} + /> + ); + }; + + // called when adding a new columnHeader + @undoBatch + @action + addGroup = (value: string) => { + const columnHeaders = Cast(this.props.Document.columnHeaders, listSpec(SchemaHeaderField), null); + return value && columnHeaders?.push(new SchemaHeaderField(value)) ? true : false; + }; + + sortFunc = (a: [SchemaHeaderField, Doc[]], b: [SchemaHeaderField, Doc[]]): 1 | -1 => { + const descending = StrCast(this.layoutDoc._columnsSort) === 'descending'; + const firstEntry = descending ? b : a; + const secondEntry = descending ? a : b; + return firstEntry[0].heading > secondEntry[0].heading ? 1 : -1; + }; + + onContextMenu = (e: React.MouseEvent): void => { + // need to test if propagation has stopped because GoldenLayout forces a parallel react hierarchy to be created for its top-level layout + if (!e.isPropagationStopped()) { + const subItems: ContextMenuProps[] = []; + subItems.push({ description: `${this.layoutDoc._columnsFill ? 'Variable Size' : 'Autosize'} Column`, event: () => (this.layoutDoc._columnsFill = !this.layoutDoc._columnsFill), icon: 'plus' }); + subItems.push({ description: `${this.layoutDoc._autoHeight ? 'Variable Height' : 'Auto Height'}`, event: () => (this.layoutDoc._autoHeight = !this.layoutDoc._autoHeight), icon: 'plus' }); + subItems.push({ description: 'Clear All', event: () => (this.dataDoc.data = new List([])), icon: 'times' }); + ContextMenu.Instance.addItem({ description: 'Options...', subitems: subItems, icon: 'eye' }); + } + }; + + // used to reset column sizes when using the drag handlers + @action + setColumnStartXCoords = (movementX: number, colIndex: number) => { + const coords = [...this.columnStartXCoords]; + coords[colIndex] += movementX; + this.columnStartXCoords = coords; + }; + + @computed get renderedSections() { + TraceMobx(); + // let sections = [[undefined, this.filteredChildren] as [SchemaHeaderField | undefined, Doc[]]]; + // if (this.pivotField) { + // const entries = Array.from(this.Sections.entries()); + // sections = this.layoutDoc._columnsSort ? entries.sort(this.sortFunc) : entries; + // } + const entries = Array.from(this.Sections.entries()); + const sections = entries; //.sort(this.sortFunc); + const eles: JSX.Element[] = []; + for (let i = 0; i < sections.length; i++) { + const col = this.sectionNoteTaking(sections[i][0], sections[i][1]); + eles.push(col); + if (i < sections.length - 1) { + eles.push(<CollectionNoteTakingViewDivider key={`divider${i}`} index={i + 1} setColumnStartXCoords={this.setColumnStartXCoords} xMargin={this.xMargin} />); + } + } + return eles; + } + + @computed get buttonMenu() { + const menuDoc: Doc = Cast(this.rootDoc.buttonMenuDoc, Doc, null); + // TODO:glr Allow support for multiple buttons + if (menuDoc) { + const width: number = NumCast(menuDoc._width, 30); + const height: number = NumCast(menuDoc._height, 30); + return ( + <div className="buttonMenu-docBtn" style={{ width: width, height: height }}> + <DocumentView + Document={menuDoc} + DataDoc={menuDoc} + isContentActive={this.props.isContentActive} + isDocumentActive={returnTrue} + addDocument={this.props.addDocument} + moveDocument={this.props.moveDocument} + addDocTab={this.props.addDocTab} + pinToPres={emptyFunction} + rootSelected={this.props.isSelected} + removeDocument={this.props.removeDocument} + ScreenToLocalTransform={Transform.Identity} + PanelWidth={() => 35} + PanelHeight={() => 35} + renderDepth={this.props.renderDepth} + focus={emptyFunction} + styleProvider={this.props.styleProvider} + docViewPath={returnEmptyDoclist} + whenChildContentsActiveChanged={emptyFunction} + bringToFront={emptyFunction} + docFilters={this.props.docFilters} + docRangeFilters={this.props.docRangeFilters} + searchFilterDocs={this.props.searchFilterDocs} + ContainingCollectionView={undefined} + ContainingCollectionDoc={undefined} + /> + </div> + ); + } + } + + @computed get nativeWidth() { + return this.props.NativeWidth?.() ?? Doc.NativeWidth(this.layoutDoc); + } + @computed get nativeHeight() { + return this.props.NativeHeight?.() ?? Doc.NativeHeight(this.layoutDoc); + } + + @computed get scaling() { + return !this.nativeWidth ? 1 : this.props.PanelHeight() / this.nativeHeight; + } + + @computed get backgroundEvents() { + return SnappingManager.GetIsDragging(); + } + observer: any; + render() { + TraceMobx(); + const buttonMenu = this.rootDoc.buttonMenu; + const noviceExplainer = StrCast(this.rootDoc.explainer); + return ( + <> + {buttonMenu || noviceExplainer ? ( + <div className="documentButtonMenu" key="buttons"> + {buttonMenu ? this.buttonMenu : null} + {Doc.UserDoc().noviceMode && noviceExplainer ? <div className="documentExplanation">{noviceExplainer}</div> : null} + </div> + ) : null} + <div + className="collectionNoteTakingView" + ref={this.createRef} + key="notes" + style={{ + overflowY: this.props.isContentActive() ? 'auto' : 'hidden', + background: this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.BackgroundColor), + pointerEvents: this.backgroundEvents ? 'all' : undefined, + }} + onScroll={action(e => (this._scroll = e.currentTarget.scrollTop))} + onPointerLeave={action(e => (this.docsDraggedRowCol.length = 0))} + onPointerMove={e => e.buttons && this.onPointerMove(false, e.clientX, e.clientY)} + onDragOver={e => this.onPointerMove(true, e.clientX, e.clientY)} + onDrop={this.onExternalDrop.bind(this)} + onContextMenu={this.onContextMenu} + onWheel={e => this.props.isContentActive(true) && e.stopPropagation()}> + {this.renderedSections} + </div> + </> + ); + } +} diff --git a/src/client/views/collections/CollectionNoteTakingViewColumn.tsx b/src/client/views/collections/CollectionNoteTakingViewColumn.tsx new file mode 100644 index 000000000..624beca96 --- /dev/null +++ b/src/client/views/collections/CollectionNoteTakingViewColumn.tsx @@ -0,0 +1,325 @@ +import React = require('react'); +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { action, computed, observable } from 'mobx'; +import { observer } from 'mobx-react'; +import { Doc, DocListCast, Opt } from '../../../fields/Doc'; +import { Id } from '../../../fields/FieldSymbols'; +import { RichTextField } from '../../../fields/RichTextField'; +import { SchemaHeaderField } from '../../../fields/SchemaHeaderField'; +import { ScriptField } from '../../../fields/ScriptField'; +import { ImageField } from '../../../fields/URLField'; +import { TraceMobx } from '../../../fields/util'; +import { emptyFunction, returnEmptyString, setupMoveUpEvents } from '../../../Utils'; +import { Docs, DocUtils } from '../../documents/Documents'; +import { DocumentType } from '../../documents/DocumentTypes'; +import { DragManager } from '../../util/DragManager'; +import { SnappingManager } from '../../util/SnappingManager'; +import { Transform } from '../../util/Transform'; +import { undoBatch } from '../../util/UndoManager'; +import { ContextMenu } from '../ContextMenu'; +import { ContextMenuProps } from '../ContextMenuItem'; +import { EditableView } from '../EditableView'; +import { FormattedTextBox } from '../nodes/formattedText/FormattedTextBox'; +import './CollectionNoteTakingView.scss'; +import { listSpec } from '../../../fields/Schema'; +import { Cast } from '../../../fields/Types'; +const higflyout = require('@hig/flyout'); +export const { anchorPoints } = higflyout; +export const Flyout = higflyout.default; + +// So this is how we are storing a column +interface CSVFieldColumnProps { + Document: Doc; + DataDoc: Opt<Doc>; + docList: Doc[]; + heading: string; + pivotField: string; + chromeHidden?: boolean; + columnHeaders: SchemaHeaderField[] | undefined; + headingObject: SchemaHeaderField | undefined; + yMargin: number; + // columnWidth: number; + numGroupColumns: number; + gridGap: number; + type: 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function' | undefined; + headings: () => object[]; + renderChildren: (docs: Doc[]) => JSX.Element[]; + addDocument: (doc: Doc | Doc[]) => boolean; + createDropTarget: (ele: HTMLDivElement) => void; + screenToLocalTransform: () => Transform; + observeHeight: (myref: any) => void; + unobserveHeight: (myref: any) => void; + //setDraggedCol:(clonedDiv:any, header:SchemaHeaderField, xycoors: ) + editableViewProps: () => any; + resizeColumns: (n: number) => void; + columnStartXCoords: number[]; + PanelWidth: number; + maxColWidth: number; + // docsByColumnHeader: Map<string, Doc[]> + // setDocsForColHeader: (key: string, docs: Doc[]) => void +} + +@observer +export class CollectionNoteTakingViewColumn extends React.Component<CSVFieldColumnProps> { + @observable private _background = 'inherit'; + + @computed get columnWidth() { + // base cases + if (!this.props.columnHeaders || !this.props.headingObject || this.props.columnHeaders.length == 1) { + return this.props.maxColWidth; + } + const i = this.props.columnHeaders.indexOf(this.props.headingObject); + if (i < 0 || i > this.props.columnStartXCoords.length - 1) { + return this.props.maxColWidth; + } + const endColValue = i == this.props.numGroupColumns - 1 ? this.props.PanelWidth : this.props.columnStartXCoords[i + 1]; + // TODO make the math work here. 35 is half of 70, which is the current width of the divider + return endColValue - this.props.columnStartXCoords[i] - 30; + } + + private dropDisposer?: DragManager.DragDropDisposer; + private _headerRef: React.RefObject<HTMLDivElement> = React.createRef(); + + @observable _heading = this.props.headingObject ? this.props.headingObject.heading : this.props.heading; + @observable _color = this.props.headingObject ? this.props.headingObject.color : '#f1efeb'; + _ele: HTMLElement | null = null; + + // This is likely similar to what we will be doing. Why do we need to make these refs? + // is that the only way to have drop targets? + createColumnDropRef = (ele: HTMLDivElement | null) => { + this.dropDisposer?.(); + if (ele) { + this._ele = ele; + this.props.observeHeight(ele); + this.dropDisposer = DragManager.MakeDropTarget(ele, this.columnDrop.bind(this)); + } + }; + + componentWillUnmount() { + this.props.unobserveHeight(this._ele); + } + + @undoBatch + columnDrop = action((e: Event, de: DragManager.DropEvent) => { + const drop = { docs: de.complete.docDragData?.droppedDocuments, val: this.getValue(this._heading) }; + drop.docs?.forEach(d => Doc.SetInPlace(d, this.props.pivotField, drop.val, false)); + }); + + getValue = (value: string): any => { + const parsed = parseInt(value); + if (!isNaN(parsed)) return parsed; + if (value.toLowerCase().indexOf('true') > -1) return true; + if (value.toLowerCase().indexOf('false') > -1) return false; + return value; + }; + + @action + headingChanged = (value: string, shiftDown?: boolean) => { + const castedValue = this.getValue(value); + if (castedValue) { + if (this.props.columnHeaders?.map(i => i.heading).indexOf(castedValue.toString()) !== -1) { + return false; + } + this.props.docList.forEach(d => (d[this.props.pivotField] = castedValue)); + if (this.props.headingObject) { + this.props.headingObject.setHeading(castedValue.toString()); + this._heading = this.props.headingObject.heading; + } + return true; + } + return false; + }; + + @action pointerEntered = () => SnappingManager.GetIsDragging() && (this._background = '#b4b4b4'); + @action pointerLeave = () => (this._background = 'inherit'); + textCallback = (char: string) => this.addNewTextDoc('-typed text-', false, true); + + @action + addNewTextDoc = (value: string, shiftDown?: boolean, forceEmptyNote?: boolean) => { + if (!value && !forceEmptyNote) return false; + const key = this.props.pivotField; + const newDoc = Docs.Create.TextDocument(value, { _height: 18, _width: 200, _fitWidth: true, title: value, _autoHeight: true }); + const colValue = this.getValue(this.props.heading); + newDoc[key] = colValue; + FormattedTextBox.SelectOnLoad = newDoc[Id]; + FormattedTextBox.SelectOnLoadChar = forceEmptyNote ? '' : ' '; + return this.props.addDocument?.(newDoc) || false; + }; + + @undoBatch + @action + deleteColumn = () => { + const columnHeaders = Cast(this.props.Document.columnHeaders, listSpec(SchemaHeaderField), null); + if (columnHeaders && this.props.headingObject) { + const index = columnHeaders.indexOf(this.props.headingObject); + this.props.docList.forEach(d => (d[this.props.pivotField] = 'unset')); + columnHeaders.splice(index, 1); + } + }; + + menuCallback = (x: number, y: number) => { + ContextMenu.Instance.clearItems(); + const layoutItems: ContextMenuProps[] = []; + const docItems: ContextMenuProps[] = []; + const dataDoc = this.props.DataDoc || this.props.Document; + const pivotValue = this.getValue(this.props.heading); + + DocUtils.addDocumentCreatorMenuItems( + doc => { + const key = this.props.pivotField; + doc[key] = this.getValue(this.props.heading); + FormattedTextBox.SelectOnLoad = doc[Id]; + return this.props.addDocument?.(doc); + }, + this.props.addDocument, + x, + y, + true, + this.props.pivotField, + pivotValue + ); + + Array.from(Object.keys(Doc.GetProto(dataDoc))) + .filter(fieldKey => dataDoc[fieldKey] instanceof RichTextField || dataDoc[fieldKey] instanceof ImageField || typeof dataDoc[fieldKey] === 'string') + .map(fieldKey => + docItems.push({ + description: ':' + fieldKey, + event: () => { + const created = DocUtils.DocumentFromField(dataDoc, fieldKey, Doc.GetProto(this.props.Document)); + if (created) { + if (this.props.Document.isTemplateDoc) { + Doc.MakeMetadataFieldTemplate(created, this.props.Document); + } + return this.props.addDocument?.(created); + } + }, + icon: 'compress-arrows-alt', + }) + ); + Array.from(Object.keys(Doc.GetProto(dataDoc))) + .filter(fieldKey => DocListCast(dataDoc[fieldKey]).length) + .map(fieldKey => + docItems.push({ + description: ':' + fieldKey, + event: () => { + const created = Docs.Create.CarouselDocument([], { _width: 400, _height: 200, title: fieldKey }); + if (created) { + const container = this.props.Document.resolvedDataDoc ? Doc.GetProto(this.props.Document) : this.props.Document; + if (container.isTemplateDoc) { + Doc.MakeMetadataFieldTemplate(created, container); + return Doc.AddDocToList(container, Doc.LayoutFieldKey(container), created); + } + return this.props.addDocument?.(created) || false; + } + }, + icon: 'compress-arrows-alt', + }) + ); + !Doc.UserDoc().noviceMode && ContextMenu.Instance.addItem({ description: 'Doc Fields ...', subitems: docItems, icon: 'eye' }); + !Doc.UserDoc().noviceMode && ContextMenu.Instance.addItem({ description: 'Containers ...', subitems: layoutItems, icon: 'eye' }); + ContextMenu.Instance.setDefaultItem('::', (name: string): void => { + Doc.GetProto(this.props.Document)[name] = ''; + const created = Docs.Create.TextDocument('', { title: name, _width: 250, _autoHeight: true }); + if (created) { + if (this.props.Document.isTemplateDoc) { + Doc.MakeMetadataFieldTemplate(created, this.props.Document); + } + this.props.addDocument?.(created); + } + }); + ContextMenu.Instance.displayMenu(x, y, undefined, true); + }; + + @computed get innards() { + TraceMobx(); + const key = this.props.pivotField; + const heading = this._heading; + const columnYMargin = this.props.headingObject ? 0 : this.props.yMargin; + const evContents = heading ? heading : '25'; + const headingView = this.props.headingObject ? ( + <div + key={heading} + className="collectionNoteTakingView-sectionHeader" + ref={this._headerRef} + style={{ + marginTop: 2 * this.props.yMargin, + // width: (this.props.columnWidth) / + // ((uniqueHeadings.length) || 1) + width: this.columnWidth - 20, + }}> + <div + className="collectionNoteTakingView-sectionHeader-subCont" + title={evContents === `No Value` ? `Documents that don't have a ${key} value will go here. This column cannot be removed.` : ''} + style={{ background: evContents !== `No Value` ? this._color : 'inherit' }}> + <EditableView GetValue={() => evContents} SetValue={this.headingChanged} contents={evContents} oneLine={true} /> + </div> + </div> + ) : null; + // const templatecols = `${this.props.columnWidth / this.props.numGroupColumns}px `; + const templatecols = `${this.columnWidth}px `; + const type = this.props.Document.type; + return ( + <> + {headingView} + { + <div style={{ height: '100%' }}> + <div + key={`${heading}-stack`} + className={`collectionNoteTakingView-Nodes`} + style={{ + padding: `${columnYMargin}px ${0}px ${this.props.yMargin}px ${0}px`, + margin: 'auto', + width: 'max-content', //singleColumn ? undefined : `${cols * (style.columnWidth + style.gridGap) + 2 * style.xMargin - style.gridGap}px`, + height: 'max-content', + position: 'relative', + gridGap: this.props.gridGap, + gridTemplateColumns: templatecols, + gridAutoRows: '0px', + }}> + {this.props.renderChildren(this.props.docList)} + </div> + + {!this.props.chromeHidden && type !== DocumentType.PRES ? ( + <div + className="collectionNoteTakingView-DocumentButtons" + // style={{ width: this.props.columnWidth / this.props.numGroupColumns, marginBottom: 10 }}> + style={{ width: this.columnWidth - 20, marginBottom: 10 }}> + <div key={`${heading}-add-document`} className="collectionNoteTakingView-addDocumentButton"> + <EditableView GetValue={returnEmptyString} SetValue={this.addNewTextDoc} textCallback={this.textCallback} placeholder={"Type ':' for commands"} contents={'+ New Node'} menuCallback={this.menuCallback} /> + </div> + <div key={`${this.props.Document[Id]}-addGroup`} className="collectionNoteTakingView-addDocumentButton"> + <EditableView {...this.props.editableViewProps()} /> + </div> + {this.props.columnHeaders?.length && this.props.columnHeaders.length > 1 && ( + <button className="collectionNoteTakingView-sectionDelete" onClick={this.deleteColumn}> + <FontAwesomeIcon icon="trash" size="lg" /> + </button> + )} + </div> + ) : null} + </div> + } + </> + ); + } + + render() { + TraceMobx(); + const heading = this._heading; + return ( + <div + className={'collectionNoteTakingViewFieldColumn' + (SnappingManager.GetIsDragging() ? 'Dragging' : '')} + key={heading} + style={{ + //TODO: change this so that it's based on the column width + width: this.columnWidth, + background: this._background, + }} + ref={this.createColumnDropRef} + onPointerEnter={this.pointerEntered} + onPointerLeave={this.pointerLeave}> + {this.innards} + </div> + ); + } +} diff --git a/src/client/views/collections/CollectionNoteTakingViewDivider.tsx b/src/client/views/collections/CollectionNoteTakingViewDivider.tsx new file mode 100644 index 000000000..7d31b3193 --- /dev/null +++ b/src/client/views/collections/CollectionNoteTakingViewDivider.tsx @@ -0,0 +1,63 @@ +import { action, observable } from 'mobx'; +import * as React from 'react'; + +interface DividerProps { + index: number; + xMargin: number; + setColumnStartXCoords: (movementX: number, colIndex: number) => void; +} + +export class CollectionNoteTakingViewDivider extends React.Component<DividerProps> { + @observable private isHoverActive = false; + @observable private isResizingActive = false; + + @action + private registerResizing = (e: React.PointerEvent<HTMLDivElement>) => { + e.stopPropagation(); + e.preventDefault(); + window.removeEventListener('pointermove', this.onPointerMove); + window.removeEventListener('pointerup', this.onPointerUp); + window.addEventListener('pointermove', this.onPointerMove); + window.addEventListener('pointerup', this.onPointerUp); + this.isResizingActive = true; + }; + + @action + private onPointerUp = () => { + this.isResizingActive = false; + this.isHoverActive = false; + window.removeEventListener('pointermove', this.onPointerMove); + window.removeEventListener('pointerup', this.onPointerUp); + }; + + @action + onPointerMove = ({ movementX }: PointerEvent) => { + this.props.setColumnStartXCoords(movementX, this.props.index); + }; + + render() { + return ( + <div + className="columnResizer" + style={{ + display: 'flex', + alignItems: 'center', + cursor: 'col-resize', + }} + onPointerEnter={action(() => (this.isHoverActive = true))} + onPointerLeave={action(() => !this.isResizingActive && (this.isHoverActive = false))}> + <div + className="columnResizer-handler" + onPointerDown={e => this.registerResizing(e)} + style={{ + height: '95%', + width: 12, + borderRight: '4px solid #282828', + borderLeft: '4px solid #282828', + margin: '0px 10px', + }} + /> + </div> + ); + } +} diff --git a/src/client/views/collections/CollectionStackingView.scss b/src/client/views/collections/CollectionStackingView.scss index 73572299a..7385f933b 100644 --- a/src/client/views/collections/CollectionStackingView.scss +++ b/src/client/views/collections/CollectionStackingView.scss @@ -1,4 +1,4 @@ -@import "../global/globalCssVariables"; +@import '../global/globalCssVariables'; .collectionMasonryView { display: inline; @@ -33,7 +33,7 @@ color: $medium-blue; padding: 10px; border-radius: 5px; - border: solid .5px $medium-blue; + border: solid 0.5px $medium-blue; } } @@ -46,9 +46,9 @@ overflow-y: auto; overflow-x: hidden; flex-wrap: wrap; - transition: top .5s; + transition: top 0.5s; - >div { + > div { position: relative; display: block; } @@ -130,9 +130,11 @@ margin-left: -5; } + // Documents in stacking view .collectionStackingView-columnDoc { - display: inline-block; - margin: auto; + display: flex; + // margin: auto; // Removed auto so that it is no longer center aligned - this could be something we change + position: relative; } .collectionStackingView-masonryDoc { @@ -173,7 +175,7 @@ span::before, span::after { - content: ""; + content: ''; width: 50%; border-top: dashed gray 1px; position: relative; @@ -203,6 +205,7 @@ .collectionStackingView-sectionHeader { text-align: center; margin: auto; + margin-bottom: 10px; background: $medium-gray; // overflow: hidden; overflow is visible so the color menu isn't hidden -ftong @@ -213,7 +216,7 @@ .editableView-input:hover, .editableView-container-editing:hover, .editableView-container-editing-oneLine:hover { - cursor: text + cursor: text; } .collectionStackingView-sectionHeader-subCont { @@ -259,7 +262,7 @@ height: 100%; display: none; - [class*="css"] { + [class*='css'] { max-width: 102px; } @@ -301,7 +304,7 @@ height: 100%; display: none; - [class*="css"] { + [class*='css'] { max-width: 102px; } @@ -310,7 +313,6 @@ } .collectionStackingView-optionPicker { - .optionOptions { display: inline; } @@ -370,7 +372,7 @@ .editableView-input:hover, .editableView-container-editing:hover, .editableView-container-editing-oneLine:hover { - cursor: text + cursor: text; } .editableView-input { @@ -385,6 +387,7 @@ .collectionStackingView-addDocumentButton { font-size: 75%; letter-spacing: 2px; + cursor: pointer; .editableView-input { outline-color: black; @@ -422,7 +425,7 @@ top: 4px; border-radius: 50% 50%; background-color: #fff; - content: " "; + content: ' '; cursor: pointer; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.26); -webkit-transform: scale(1); @@ -452,7 +455,6 @@ } @media only screen and (max-device-width: 480px) { - .collectionStackingView .collectionStackingView-columnDragger, .collectionMasonryView .collectionStackingView-columnDragger { width: 0.1; @@ -460,4 +462,4 @@ opacity: 0; font-size: 0; } -}
\ No newline at end of file +} diff --git a/src/client/views/collections/CollectionStackingView.tsx b/src/client/views/collections/CollectionStackingView.tsx index 7e66aa844..d4efef47a 100644 --- a/src/client/views/collections/CollectionStackingView.tsx +++ b/src/client/views/collections/CollectionStackingView.tsx @@ -34,6 +34,7 @@ const _global = (window /* browser */ || global) /* node */ as any; export type collectionStackingViewProps = { chromeHidden?: boolean; + // view type is stacking viewType?: CollectionViewType; NativeWidth?: () => number; NativeHeight?: () => number; @@ -42,26 +43,39 @@ export type collectionStackingViewProps = { @observer export class CollectionStackingView extends CollectionSubView<Partial<collectionStackingViewProps>>() { _masonryGridRef: HTMLDivElement | null = null; + // used in a column dragger, likely due for the masonry grid view. We want to use this _draggerRef = React.createRef<HTMLDivElement>(); + // Not sure what a pivot field is. Seems like we cause reaction in MobX get rid of it once we exit this view _pivotFieldDisposer?: IReactionDisposer; + // Seems like we cause reaction in MobX get rid of our height once we exit this view _autoHeightDisposer?: IReactionDisposer; + // keeping track of documents. Updated on internal and external drops. What's the difference? _docXfs: { height: () => number; width: () => number; stackedDocTransform: () => Transform }[] = []; + // Doesn't look like this field is being used anywhere. Obsolete? _columnStart: number = 0; + // map of node headers to their heights. Used in Masonry @observable _heightMap = new Map<string, number>(); + // Assuming that this is the current css cursor style @observable _cursor: CursorProperty = 'grab'; + // gets reset whenever we scroll. Not sure what it is @observable _scroll = 0; // used to force the document decoration to update when scrolling + // does this mean whether the browser is hidden? Or is chrome something else entirely? @computed get chromeHidden() { return this.props.chromeHidden || BoolCast(this.layoutDoc.chromeHidden); } + // it looks like this gets the column headers that Mehek was showing just now @computed get columnHeaders() { return Cast(this.layoutDoc._columnHeaders, listSpec(SchemaHeaderField), null); } + // Still not sure what a pivot is, but it appears that we can actually filter docs somehow? @computed get pivotField() { return StrCast(this.layoutDoc._pivotField); } + // filteredChildren is what you want to work with. It's the list of things that you're currently displaying @computed get filteredChildren() { return this.childLayoutPairs.filter(pair => pair.layout instanceof Doc && !pair.layout.hidden).map(pair => pair.layout); } + // how much margin we give the header @computed get headerMargin() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.HeaderMargin); } @@ -74,18 +88,23 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection @computed get gridGap() { return NumCast(this.layoutDoc._gridGap, 10); } + // are we stacking or masonry? @computed get isStackingView() { return (this.props.viewType ?? this.layoutDoc._viewType) === CollectionViewType.Stacking; } + // this is the number of StackingViewFieldColumns that we have @computed get numGroupColumns() { return this.isStackingView ? Math.max(1, this.Sections.size + (this.showAddAGroup ? 1 : 0)) : 1; } + // reveals a button to add a group in masonry view @computed get showAddAGroup() { return this.pivotField && !this.chromeHidden; } + // columnWidth handles the margin on the left and right side of the documents @computed get columnWidth() { return Math.min(this.props.PanelWidth() - 2 * this.xMargin, this.isStackingView ? Number.MAX_VALUE : this.layoutDoc._columnWidth === -1 ? this.props.PanelWidth() - 2 * this.xMargin : NumCast(this.layoutDoc._columnWidth, 250)); } + @computed get NodeWidth() { return this.props.PanelWidth() - this.gridGap; } @@ -94,18 +113,26 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection super(props); if (this.columnHeaders === undefined) { + // TODO: what is a layout doc? Is it literally how this document is supposed to be layed out? + // here we're making an empty list of column headers (again, what Mehek showed us) this.layoutDoc._columnHeaders = new List<SchemaHeaderField>(); } } + // TODO: plj - these are the children children = (docs: Doc[]) => { + //TODO: can somebody explain me to what exactly TraceMobX is? TraceMobx(); + // appears that we are going to reset the _docXfs. TODO: what is Xfs? this._docXfs.length = 0; return docs.map((d, i) => { const height = () => this.getDocHeight(d); const width = () => this.getDocWidth(d); + // assuming we need to get rowSpan because we might be dealing with many columns. Grid gap makes sense if multiple columns const rowSpan = Math.ceil((height() + this.gridGap) / this.gridGap); + // just getting the style const style = this.isStackingView ? { width: width(), marginTop: i ? this.gridGap : 0, height: height() } : { gridRowEnd: `span ${rowSpan}` }; + // So we're choosing whether we're going to render a column or a masonry doc return ( <div className={`collectionStackingView-${this.isStackingView ? 'columnDoc' : 'masonryDoc'}`} key={d[Id]} style={style}> {this.getDisplayDoc(d, width)} @@ -118,7 +145,10 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection this._heightMap.set(key, sectionHeight); }; + // is sections that all collections inherit? I think this is how we show the masonry/columns + //TODO: this seems important get Sections() { + // appears that pivot field IS actually for sorting if (!this.pivotField || this.columnHeaders instanceof Promise) return new Map<SchemaHeaderField, Doc[]>(); if (this.columnHeaders === undefined) { @@ -146,6 +176,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection } }); // remove all empty columns if hideHeadings is set + // we will want to have something like this, so that we can hide columns and add them back in if (this.layoutDoc._columnsHideIfEmpty) { Array.from(fields.keys()) .filter(key => !fields.get(key)!.length) @@ -218,6 +249,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection smoothScroll(500, this._mainCont!, this._mainCont!.scrollHeight); }; + // let's dive in and get the actual document we want to drag/move around focusDocument = (doc: Doc, options?: DocFocusOptions) => { Doc.BrushDoc(doc); @@ -268,8 +300,12 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection return this.addDocument?.(newDoc); } }; - isContentActive = () => this.props.isSelected() || this.props.isContentActive(); - isChildContentActive = () => (this.props.isDocumentActive?.() && (this.props.childDocumentsActive?.() || BoolCast(this.rootDoc.childDocumentsActive)) ? true : undefined); + isContentActive = () => (this.props.isSelected() || this.props.isContentActive() ? true : this.props.isSelected() === false || this.props.isContentActive() === false ? false : undefined); + + isChildContentActive = () => + this.props.isDocumentActive?.() && (this.props.childDocumentsActive?.() || BoolCast(this.rootDoc.childDocumentsActive)) ? true : this.props.childDocumentsActive?.() === false || this.rootDoc.childDocumentsActive === false ? false : undefined; + // 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) { const dataDoc = !doc.isTemplateDoc && !doc.isTemplateForField && !doc.PARAMS ? undefined : this.props.DataDoc; const height = () => this.getDocHeight(doc); @@ -277,6 +313,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection let dref: Opt<DocumentView>; const stackedDocTransform = () => this.getDocTransform(doc, dref); this._docXfs.push({ stackedDocTransform, width, height }); + //DocumentView is how the node will be rendered return ( <DocumentView ref={r => (dref = r || undefined)} @@ -357,6 +394,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection return Math.min(childHeight, maxHeight, panelHeight); } + // This following three functions must be from the view Mehek showed columnDividerDown = (e: React.PointerEvent) => { runInAction(() => (this._cursor = 'grabbing')); setupMoveUpEvents( @@ -384,10 +422,13 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection @undoBatch @action onInternalDrop = (e: Event, de: DragManager.DropEvent) => { + // Fairly confident that this is where the swapping of nodes in the various arrays happens const where = [de.x, de.y]; + // start at -1 until we're sure we want to add it to the column let dropInd = -1; let dropAfter = 0; if (de.complete.docDragData) { + // going to re-add the docs to the _docXFs based on position of where we just dropped this._docXfs.map((cd, i) => { const pos = cd .stackedDocTransform() @@ -402,17 +443,20 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection }); const oldDocs = this.childDocs.length; if (super.onInternalDrop(e, de)) { + // check to see if we actually need anything to the new column of nodes (if droppedDocs != empty) const droppedDocs = this.childDocs.slice().filter((d: Doc, ind: number) => ind >= oldDocs); // if the drop operation adds something to the end of the list, then use that as the new document (may be different than what was dropped e.g., in the case of a button which is dropped but which creates say, a note). const newDocs = droppedDocs.length ? droppedDocs : de.complete.docDragData.droppedDocuments; // if nothing was added to the end of the list, then presumably the dropped documents were already in the list, but possibly got reordered so we use them. const docs = this.childDocList; - DragManager.docsBeingDragged = []; + // still figuring out where to add the document if (docs && newDocs.length) { const insertInd = dropInd === -1 ? docs.length : dropInd + dropAfter; const offset = newDocs.reduce((off, ndoc) => (this.filteredChildren.find((fdoc, i) => ndoc === fdoc && i < insertInd) ? off + 1 : off), 0); newDocs.filter(ndoc => docs.indexOf(ndoc) !== -1).forEach(ndoc => docs.splice(docs.indexOf(ndoc), 1)); docs.splice(insertInd - offset, 0, ...newDocs); } + // reset drag manager docs, because we just dropped + DragManager.docsBeingDragged.length = 0; } } else if (de.complete.linkDragData?.dragDocument.context === this.props.Document && de.complete.linkDragData?.linkDragView?.props.CollectionFreeFormDocumentView?.()) { const source = Docs.Create.TextDocument('', { _width: 200, _height: 75, _fitWidth: true, title: 'dropped annotation' }); @@ -433,6 +477,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection return true; } + /// an item from outside of Dash is being dropped onto this stacking view (e.g, a document from the file system) @undoBatch @action onExternalDrop = async (e: React.DragEvent): Promise<void> => { @@ -461,6 +506,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection }; headings = () => Array.from(this.Sections); refList: any[] = []; + // what a section looks like if we're in stacking view sectionStacking = (heading: SchemaHeaderField | undefined, docList: Doc[]) => { const key = this.pivotField; let type: 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function' | undefined = undefined; @@ -512,6 +558,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection ); }; + // what a section looks like if we're in masonry. Shouldn't actually need to use this. sectionMasonry = (heading: SchemaHeaderField | undefined, docList: Doc[], first: boolean) => { const key = this.pivotField; let type: 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function' | undefined = undefined; @@ -556,6 +603,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection ); }; + /// add a new group category (column) to the active set of note categories. (e.g., if the pivot field is 'transportation', groups might be 'car', 'plane', 'bike', etc) @action addGroup = (value: string) => { if (value && this.columnHeaders) { @@ -584,6 +632,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection } }; + // @computed get renderedSections() { TraceMobx(); let sections = [[undefined, this.filteredChildren] as [SchemaHeaderField | undefined, Doc[]]]; diff --git a/src/client/views/collections/CollectionStackingViewFieldColumn.tsx b/src/client/views/collections/CollectionStackingViewFieldColumn.tsx index f3a798143..7b268cd49 100644 --- a/src/client/views/collections/CollectionStackingViewFieldColumn.tsx +++ b/src/client/views/collections/CollectionStackingViewFieldColumn.tsx @@ -1,31 +1,32 @@ -import React = require("react"); -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { action, computed, observable } from "mobx"; -import { observer } from "mobx-react"; -import { Doc, DocListCast, Opt } from "../../../fields/Doc"; -import { RichTextField } from "../../../fields/RichTextField"; -import { PastelSchemaPalette, SchemaHeaderField } from "../../../fields/SchemaHeaderField"; -import { ScriptField } from "../../../fields/ScriptField"; -import { Cast, NumCast, StrCast } from "../../../fields/Types"; -import { ImageField } from "../../../fields/URLField"; -import { TraceMobx } from "../../../fields/util"; -import { emptyFunction, setupMoveUpEvents, returnFalse, returnEmptyString } from "../../../Utils"; -import { Docs, DocUtils } from "../../documents/Documents"; -import { DocumentType } from "../../documents/DocumentTypes"; -import { DragManager } from "../../util/DragManager"; -import { SnappingManager } from "../../util/SnappingManager"; -import { Transform } from "../../util/Transform"; -import { undoBatch } from "../../util/UndoManager"; -import { ContextMenu } from "../ContextMenu"; -import { ContextMenuProps } from "../ContextMenuItem"; -import { EditableView } from "../EditableView"; -import "./CollectionStackingView.scss"; -import { FormattedTextBox } from "../nodes/formattedText/FormattedTextBox"; -import { Id } from "../../../fields/FieldSymbols"; -const higflyout = require("@hig/flyout"); +import React = require('react'); +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { action, computed, observable } from 'mobx'; +import { observer } from 'mobx-react'; +import { Doc, DocListCast, Opt } from '../../../fields/Doc'; +import { RichTextField } from '../../../fields/RichTextField'; +import { PastelSchemaPalette, SchemaHeaderField } from '../../../fields/SchemaHeaderField'; +import { ScriptField } from '../../../fields/ScriptField'; +import { Cast, NumCast, StrCast } from '../../../fields/Types'; +import { ImageField } from '../../../fields/URLField'; +import { TraceMobx } from '../../../fields/util'; +import { emptyFunction, setupMoveUpEvents, returnFalse, returnEmptyString } from '../../../Utils'; +import { Docs, DocUtils } from '../../documents/Documents'; +import { DocumentType } from '../../documents/DocumentTypes'; +import { DragManager } from '../../util/DragManager'; +import { SnappingManager } from '../../util/SnappingManager'; +import { Transform } from '../../util/Transform'; +import { undoBatch } from '../../util/UndoManager'; +import { ContextMenu } from '../ContextMenu'; +import { ContextMenuProps } from '../ContextMenuItem'; +import { EditableView } from '../EditableView'; +import './CollectionStackingView.scss'; +import { FormattedTextBox } from '../nodes/formattedText/FormattedTextBox'; +import { Id } from '../../../fields/FieldSymbols'; +const higflyout = require('@hig/flyout'); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; +// So this is how we are storing a column interface CSVFieldColumnProps { Document: Doc; DataDoc: Opt<Doc>; @@ -39,8 +40,9 @@ interface CSVFieldColumnProps { columnWidth: number; numGroupColumns: number; gridGap: number; - type: "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function" | undefined; + type: 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function' | undefined; headings: () => object[]; + // I think that stacking view actually has a single column and then supposedly you can add more columns? Unsure renderChildren: (docs: Doc[]) => JSX.Element[]; addDocument: (doc: Doc | Doc[]) => boolean; createDropTarget: (ele: HTMLDivElement) => void; @@ -51,17 +53,19 @@ interface CSVFieldColumnProps { @observer export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldColumnProps> { - @observable private _background = "inherit"; + @observable private _background = 'inherit'; private dropDisposer?: DragManager.DragDropDisposer; private _headerRef: React.RefObject<HTMLDivElement> = React.createRef(); @observable _paletteOn = false; @observable _heading = this.props.headingObject ? this.props.headingObject.heading : this.props.heading; - @observable _color = this.props.headingObject ? this.props.headingObject.color : "#f1efeb"; + @observable _color = this.props.headingObject ? this.props.headingObject.color : '#f1efeb'; _ele: HTMLElement | null = null; + // This is likely similar to what we will be doing. Why do we need to make these refs? + // is that the only way to have drop targets? createColumnDropRef = (ele: HTMLDivElement | null) => { this.dropDisposer?.(); if (ele) { @@ -69,12 +73,13 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC this.props.observeHeight(ele); this.dropDisposer = DragManager.MakeDropTarget(ele, this.columnDrop.bind(this)); } - } + }; componentWillUnmount() { this.props.unobserveHeight(this._ele); } + //TODO: what is scripting? I found it in SetInPlace def but don't know what that is @undoBatch columnDrop = action((e: Event, de: DragManager.DropEvent) => { const drop = { docs: de.complete.docDragData?.droppedDocuments, val: this.getValue(this._heading) }; @@ -83,10 +88,10 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC getValue = (value: string): any => { const parsed = parseInt(value); if (!isNaN(parsed)) return parsed; - if (value.toLowerCase().indexOf("true") > -1) return true; - if (value.toLowerCase().indexOf("false") > -1) return false; + if (value.toLowerCase().indexOf('true') > -1) return true; + if (value.toLowerCase().indexOf('false') > -1) return false; return value; - } + }; @action headingChanged = (value: string, shiftDown?: boolean) => { @@ -95,7 +100,7 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC if (this.props.columnHeaders?.map(i => i.heading).indexOf(castedValue.toString()) !== -1) { return false; } - this.props.docList.forEach(d => d[this.props.pivotField] = castedValue); + this.props.docList.forEach(d => (d[this.props.pivotField] = castedValue)); if (this.props.headingObject) { this.props.headingObject.setHeading(castedValue.toString()); this._heading = this.props.headingObject.heading; @@ -103,17 +108,17 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC return true; } return false; - } + }; @action changeColumnColor = (color: string) => { this.props.headingObject?.setColor(color); this._color = color; - } + }; - @action pointerEntered = () => SnappingManager.GetIsDragging() && (this._background = "#b4b4b4"); - @action pointerLeave = () => this._background = "inherit"; - textCallback = (char: string) => this.addNewTextDoc("-typed text-", false, true); + @action pointerEntered = () => SnappingManager.GetIsDragging() && (this._background = '#b4b4b4'); + @action pointerLeave = () => (this._background = 'inherit'); + textCallback = (char: string) => this.addNewTextDoc('-typed text-', false, true); @action addNewTextDoc = (value: string, shiftDown?: boolean, forceEmptyNote?: boolean) => { @@ -121,71 +126,78 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC const key = this.props.pivotField; const newDoc = Docs.Create.TextDocument(value, { _height: 18, _width: 200, _fitWidth: true, title: value, _autoHeight: true }); newDoc[key] = this.getValue(this.props.heading); - const maxHeading = this.props.docList.reduce((maxHeading, doc) => NumCast(doc.heading) > maxHeading ? NumCast(doc.heading) : maxHeading, 0); + const maxHeading = this.props.docList.reduce((maxHeading, doc) => (NumCast(doc.heading) > maxHeading ? NumCast(doc.heading) : maxHeading), 0); const heading = maxHeading === 0 || this.props.docList.length === 0 ? 1 : maxHeading === 1 ? 2 : 3; newDoc.heading = heading; FormattedTextBox.SelectOnLoad = newDoc[Id]; - FormattedTextBox.SelectOnLoadChar = forceEmptyNote ? "" : " "; + FormattedTextBox.SelectOnLoadChar = forceEmptyNote ? '' : ' '; return this.props.addDocument?.(newDoc) || false; - } + }; @action deleteColumn = () => { - this.props.docList.forEach(d => d[this.props.pivotField] = undefined); + this.props.docList.forEach(d => (d[this.props.pivotField] = undefined)); if (this.props.columnHeaders && this.props.headingObject) { const index = this.props.columnHeaders.indexOf(this.props.headingObject); this.props.columnHeaders.splice(index, 1); } - } + }; @action collapseSection = () => { this.props.headingObject?.setCollapsed(!this.props.headingObject.collapsed); this.toggleVisibility(); - } + }; headerDown = (e: React.PointerEvent<HTMLDivElement>) => setupMoveUpEvents(this, e, this.startDrag, emptyFunction, emptyFunction); + //TODO: I think this is where I'm supposed to edit stuff startDrag = (e: PointerEvent, down: number[], delta: number[]) => { + // is MakeAlias a way to make a copy of a doc without rendering it? const alias = Doc.MakeAlias(this.props.Document); alias._width = this.props.columnWidth / (this.props.columnHeaders?.length || 1); alias._pivotField = undefined; let value = this.getValue(this._heading); - value = typeof value === "string" ? `"${value}"` : value; + value = typeof value === 'string' ? `"${value}"` : value; alias.viewSpecScript = ScriptField.MakeFunction(`doc.${this.props.pivotField} === ${value}`, { doc: Doc.name }); if (alias.viewSpecScript) { DragManager.StartDocumentDrag([this._headerRef.current!], new DragManager.DocumentDragData([alias]), e.clientX, e.clientY); return true; } return false; - } + }; renderColorPicker = () => { - const gray = "#f1efeb"; + const gray = '#f1efeb'; const selected = this.props.headingObject ? this.props.headingObject.color : gray; - const colors = ["pink2", "purple4", "bluegreen1", "yellow4", "gray", "red2", "bluegreen7", "bluegreen5", "orange1"]; - return <div className="collectionStackingView-colorPicker"> - <div className="colorOptions"> - {colors.map(col => { - const palette = PastelSchemaPalette.get(col); - return <div className={"colorPicker" + (selected === palette ? " active" : "")} - style={{ backgroundColor: palette }} onClick={() => this.changeColumnColor(palette!)} />; - })} + const colors = ['pink2', 'purple4', 'bluegreen1', 'yellow4', 'gray', 'red2', 'bluegreen7', 'bluegreen5', 'orange1']; + return ( + <div className="collectionStackingView-colorPicker"> + <div className="colorOptions"> + {colors.map(col => { + const palette = PastelSchemaPalette.get(col); + return <div className={'colorPicker' + (selected === palette ? ' active' : '')} style={{ backgroundColor: palette }} onClick={() => this.changeColumnColor(palette!)} />; + })} + </div> </div> - </div>; - } + ); + }; renderMenu = () => { - return <div className="collectionStackingView-optionPicker"> - <div className="optionOptions"> - <div className={"optionPicker" + (true ? " active" : "")} onClick={action(() => { })}>Add options here</div> + return ( + <div className="collectionStackingView-optionPicker"> + <div className="optionOptions"> + <div className={'optionPicker' + (true ? ' active' : '')} onClick={action(() => {})}> + Add options here + </div> + </div> </div> - </div >; - } + ); + }; @observable private collapsed: boolean = false; - private toggleVisibility = action(() => this.collapsed = !this.collapsed); + private toggleVisibility = action(() => (this.collapsed = !this.collapsed)); menuCallback = (x: number, y: number) => { ContextMenu.Instance.clearItems(); @@ -193,42 +205,58 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC const docItems: ContextMenuProps[] = []; const dataDoc = this.props.DataDoc || this.props.Document; - DocUtils.addDocumentCreatorMenuItems((doc) => { - FormattedTextBox.SelectOnLoad = doc[Id]; - return this.props.addDocument?.(doc); - }, this.props.addDocument, x, y, true); + DocUtils.addDocumentCreatorMenuItems( + doc => { + FormattedTextBox.SelectOnLoad = doc[Id]; + return this.props.addDocument?.(doc); + }, + this.props.addDocument, + x, + y, + true + ); - Array.from(Object.keys(Doc.GetProto(dataDoc))).filter(fieldKey => dataDoc[fieldKey] instanceof RichTextField || dataDoc[fieldKey] instanceof ImageField || typeof (dataDoc[fieldKey]) === "string").map(fieldKey => - docItems.push({ - description: ":" + fieldKey, event: () => { - const created = DocUtils.DocumentFromField(dataDoc, fieldKey, Doc.GetProto(this.props.Document)); - if (created) { - if (this.props.Document.isTemplateDoc) { - Doc.MakeMetadataFieldTemplate(created, this.props.Document); + Array.from(Object.keys(Doc.GetProto(dataDoc))) + .filter(fieldKey => dataDoc[fieldKey] instanceof RichTextField || dataDoc[fieldKey] instanceof ImageField || typeof dataDoc[fieldKey] === 'string') + .map(fieldKey => + docItems.push({ + description: ':' + fieldKey, + event: () => { + const created = DocUtils.DocumentFromField(dataDoc, fieldKey, Doc.GetProto(this.props.Document)); + if (created) { + if (this.props.Document.isTemplateDoc) { + Doc.MakeMetadataFieldTemplate(created, this.props.Document); + } + return this.props.addDocument?.(created); } - return this.props.addDocument?.(created); - } - }, icon: "compress-arrows-alt" - })); - Array.from(Object.keys(Doc.GetProto(dataDoc))).filter(fieldKey => DocListCast(dataDoc[fieldKey]).length).map(fieldKey => - docItems.push({ - description: ":" + fieldKey, event: () => { - const created = Docs.Create.CarouselDocument([], { _width: 400, _height: 200, title: fieldKey }); - if (created) { - const container = this.props.Document.resolvedDataDoc ? Doc.GetProto(this.props.Document) : this.props.Document; - if (container.isTemplateDoc) { - Doc.MakeMetadataFieldTemplate(created, container); - return Doc.AddDocToList(container, Doc.LayoutFieldKey(container), created); + }, + icon: 'compress-arrows-alt', + }) + ); + Array.from(Object.keys(Doc.GetProto(dataDoc))) + .filter(fieldKey => DocListCast(dataDoc[fieldKey]).length) + .map(fieldKey => + docItems.push({ + description: ':' + fieldKey, + event: () => { + const created = Docs.Create.CarouselDocument([], { _width: 400, _height: 200, title: fieldKey }); + if (created) { + const container = this.props.Document.resolvedDataDoc ? Doc.GetProto(this.props.Document) : this.props.Document; + if (container.isTemplateDoc) { + Doc.MakeMetadataFieldTemplate(created, container); + return Doc.AddDocToList(container, Doc.LayoutFieldKey(container), created); + } + return this.props.addDocument?.(created) || false; } - return this.props.addDocument?.(created) || false; - } - }, icon: "compress-arrows-alt" - })); - !Doc.noviceMode && ContextMenu.Instance.addItem({ description: "Doc Fields ...", subitems: docItems, icon: "eye" }); - !Doc.noviceMode && ContextMenu.Instance.addItem({ description: "Containers ...", subitems: layoutItems, icon: "eye" }); - ContextMenu.Instance.setDefaultItem("::", (name: string): void => { - Doc.GetProto(this.props.Document)[name] = ""; - const created = Docs.Create.TextDocument("", { title: name, _width: 250, _autoHeight: true }); + }, + icon: 'compress-arrows-alt', + }) + ); + !Doc.noviceMode && ContextMenu.Instance.addItem({ description: 'Doc Fields ...', subitems: docItems, icon: 'eye' }); + !Doc.noviceMode && ContextMenu.Instance.addItem({ description: 'Containers ...', subitems: layoutItems, icon: 'eye' }); + ContextMenu.Instance.setDefaultItem('::', (name: string): void => { + Doc.GetProto(this.props.Document)[name] = ''; + const created = Docs.Create.TextDocument('', { title: name, _width: 250, _autoHeight: true }); if (created) { if (this.props.Document.isTemplateDoc) { Doc.MakeMetadataFieldTemplate(created, this.props.Document); @@ -238,9 +266,7 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC }); const pt = this.props.screenToLocalTransform().inverse().transformPoint(x, y); ContextMenu.Instance.displayMenu(x, y, undefined, true); - } - - + }; @computed get innards() { TraceMobx(); @@ -249,39 +275,39 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC const heading = this._heading; const columnYMargin = this.props.headingObject ? 0 : this.props.yMargin; const uniqueHeadings = headings.map((i, idx) => headings.indexOf(i) === idx); - const evContents = heading ? heading : this.props?.type === "number" ? "0" : `NO ${key.toUpperCase()} VALUE`; - const headingView = this.props.headingObject ? - <div key={heading} className="collectionStackingView-sectionHeader" ref={this._headerRef} + const evContents = heading ? heading : this.props?.type === 'number' ? '0' : `NO ${key.toUpperCase()} VALUE`; + const headingView = this.props.headingObject ? ( + <div + key={heading} + className="collectionStackingView-sectionHeader" + ref={this._headerRef} style={{ marginTop: this.props.yMargin, - width: (this.props.columnWidth) / - ((uniqueHeadings.length + (this.props.chromeHidden ? 0 : 1)) || 1) + width: this.props.columnWidth / (uniqueHeadings.length + (this.props.chromeHidden ? 0 : 1) || 1), }}> - <div className={"collectionStackingView-collapseBar" + (this.props.headingObject.collapsed === true ? " active" : "")} onClick={this.collapseSection}></div> + <div className={'collectionStackingView-collapseBar' + (this.props.headingObject.collapsed === true ? ' active' : '')} onClick={this.collapseSection}></div> {/* the default bucket (no key value) has a tooltip that describes what it is. Further, it does not have a color and cannot be deleted. */} - <div className="collectionStackingView-sectionHeader-subCont" onPointerDown={this.headerDown} - title={evContents === `NO ${key.toUpperCase()} VALUE` ? - `Documents that don't have a ${key} value will go here. This column cannot be removed.` : ""} - style={{ background: evContents !== `NO ${key.toUpperCase()} VALUE` ? this._color : "inherit" }}> - <EditableView - GetValue={() => evContents} - SetValue={this.headingChanged} - contents={evContents} - oneLine={true} - toggle={this.toggleVisibility} /> - {evContents === `NO ${key.toUpperCase()} VALUE` ? (null) : + <div + className="collectionStackingView-sectionHeader-subCont" + onPointerDown={this.headerDown} + title={evContents === `NO ${key.toUpperCase()} VALUE` ? `Documents that don't have a ${key} value will go here. This column cannot be removed.` : ''} + style={{ background: evContents !== `NO ${key.toUpperCase()} VALUE` ? this._color : 'inherit' }}> + <EditableView GetValue={() => evContents} SetValue={this.headingChanged} contents={evContents} oneLine={true} toggle={this.toggleVisibility} /> + {evContents === `NO ${key.toUpperCase()} VALUE` ? null : ( <div className="collectionStackingView-sectionColor"> - <button className="collectionStackingView-sectionColorButton" onClick={action(e => this._paletteOn = !this._paletteOn)}> + <button className="collectionStackingView-sectionColorButton" onClick={action(e => (this._paletteOn = !this._paletteOn))}> <FontAwesomeIcon icon="palette" size="lg" /> </button> - {this._paletteOn ? this.renderColorPicker() : (null)} + {this._paletteOn ? this.renderColorPicker() : null} </div> + )} + { + <button className="collectionStackingView-sectionDelete" onClick={this.deleteColumn}> + <FontAwesomeIcon icon="trash" size="lg" /> + </button> } - {<button className="collectionStackingView-sectionDelete" onClick={this.deleteColumn}> - <FontAwesomeIcon icon="trash" size="lg" /> - </button>} - {evContents === `NO ${key.toUpperCase()} VALUE` ? (null) : + {evContents === `NO ${key.toUpperCase()} VALUE` ? null : ( <div className="collectionStackingView-sectionOptions"> <Flyout anchorPoint={anchorPoints.TOP_RIGHT} content={this.renderMenu()}> <button className="collectionStackingView-sectionOptionButton"> @@ -289,65 +315,76 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC </button> </Flyout> </div> - } + )} </div> - </div> : (null); + </div> + ) : null; const templatecols = `${this.props.columnWidth / this.props.numGroupColumns}px `; const type = this.props.Document.type; - return <> - {this.props.Document._columnsHideIfEmpty ? (null) : headingView} - { - this.collapsed ? (null) : + return ( + <> + {this.props.Document._columnsHideIfEmpty ? null : headingView} + {this.collapsed ? null : ( <div> - <div key={`${heading}-stack`} className={`collectionStackingView-masonrySingle`} + <div + key={`${heading}-stack`} + className={`collectionStackingView-masonrySingle`} style={{ padding: `${columnYMargin}px ${0}px ${this.props.yMargin}px ${0}px`, - margin: "auto", - width: "max-content", //singleColumn ? undefined : `${cols * (style.columnWidth + style.gridGap) + 2 * style.xMargin - style.gridGap}px`, + margin: 'auto', + width: 'max-content', //singleColumn ? undefined : `${cols * (style.columnWidth + style.gridGap) + 2 * style.xMargin - style.gridGap}px`, height: 'max-content', - position: "relative", + position: 'relative', gridGap: this.props.gridGap, gridTemplateColumns: templatecols, - gridAutoRows: "0px" + gridAutoRows: '0px', }}> {this.props.renderChildren(this.props.docList)} </div> - {!this.props.chromeHidden ? - <div key={`${heading}-add-document`} className="collectionStackingView-addDocumentButton" - style={{ width: this.props.columnWidth / this.props.numGroupColumns, marginBottom: 10 }}> + {!this.props.chromeHidden && type !== DocumentType.PRES ? ( + // TODO: this is the "new" button: see what you can work with here + // change cursor to pointer for this, and update dragging cursor + //TODO: there is a bug that occurs when adding a freeform document and trying to move it around + //TODO: would be great if there was additional space beyond the frame, so that you can actually see your + // bottom note + //TODO: ok, so we are using a single column, and this is it! + <div key={`${heading}-add-document`} className="collectionStackingView-addDocumentButton" style={{ width: this.props.columnWidth / this.props.numGroupColumns, marginBottom: 10, marginLeft: 25 }}> <EditableView GetValue={returnEmptyString} SetValue={this.addNewTextDoc} textCallback={this.textCallback} - contents={"+ NEW"} + placeholder={"Type ':' for commands"} + contents={<FontAwesomeIcon icon={'plus'} />} toggle={this.toggleVisibility} - menuCallback={this.menuCallback} /> + menuCallback={this.menuCallback} + /> </div> - : null - } - - + ) : null} </div> - } - </>; + )} + </> + ); } - render() { TraceMobx(); const headings = this.props.headings(); const heading = this._heading; const uniqueHeadings = headings.map((i, idx) => headings.indexOf(i) === idx); return ( - <div className={"collectionStackingViewFieldColumn" + (SnappingManager.GetIsDragging() ? "Dragging" : "")} key={heading} + <div + className={'collectionStackingViewFieldColumn' + (SnappingManager.GetIsDragging() ? 'Dragging' : '')} + key={heading} style={{ width: `${100 / (uniqueHeadings.length + (this.props.chromeHidden ? 0 : 1) || 1)}%`, height: undefined, // DraggingManager.GetIsDragging() ? "100%" : undefined, - background: this._background + background: this._background, }} - ref={this.createColumnDropRef} onPointerEnter={this.pointerEntered} onPointerLeave={this.pointerLeave}> + ref={this.createColumnDropRef} + onPointerEnter={this.pointerEntered} + onPointerLeave={this.pointerLeave}> {this.innards} - </div > + </div> ); } -}
\ No newline at end of file +} diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index f0cb23eab..1ee77d4ce 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -26,6 +26,7 @@ import { CollectionGridView } from './collectionGrid/CollectionGridView'; import { CollectionLinearView } from './collectionLinear'; import { CollectionMulticolumnView } from './collectionMulticolumn/CollectionMulticolumnView'; import { CollectionMultirowView } from './collectionMulticolumn/CollectionMultirowView'; +import { CollectionNoteTakingView } from './CollectionNoteTakingView'; import { CollectionPileView } from './CollectionPileView'; import { CollectionSchemaView } from './collectionSchema/CollectionSchemaView'; import { CollectionStackingView } from './CollectionStackingView'; @@ -125,6 +126,7 @@ export class CollectionView extends ViewBoxAnnotatableComponent<ViewBoxAnnotatab case CollectionViewType.Carousel: return <CollectionCarouselView key="collview" {...props} />; case CollectionViewType.Carousel3D: return <CollectionCarousel3DView key="collview" {...props} />; case CollectionViewType.Stacking: return <CollectionStackingView key="collview" {...props} />; + case CollectionViewType.NoteTaking: return <CollectionNoteTakingView key="collview" {...props} />; case CollectionViewType.Masonry: return <CollectionStackingView key="collview" {...props} />; case CollectionViewType.Time: return <CollectionTimeView key="collview" {...props} />; case CollectionViewType.Grid: return <CollectionGridView key="collview" {...props} />; @@ -141,6 +143,7 @@ export class CollectionView extends ViewBoxAnnotatableComponent<ViewBoxAnnotatab 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' }); diff --git a/src/client/views/collections/TabDocView.tsx b/src/client/views/collections/TabDocView.tsx index 2d08b1c09..ec291e72c 100644 --- a/src/client/views/collections/TabDocView.tsx +++ b/src/client/views/collections/TabDocView.tsx @@ -212,11 +212,11 @@ export class TabDocView extends React.Component<TabDocViewProps> { @action public static PinDoc(docs: Doc | Doc[], pinProps?: PinProps) { const docList = docs instanceof Doc ? [docs] : docs; - const batch = UndoManager.StartBatch('pinning doc'); // all docs will be added to the ActivePresentation as stored on CurrentUserUtils const curPres = Doc.ActivePresentation; - curPres && + if (curPres) { + const batch = UndoManager.StartBatch('pinning doc'); docList.forEach(doc => { // Edge Case 1: Cannot pin document to itself if (doc === curPres) { @@ -301,18 +301,19 @@ export class TabDocView extends React.Component<TabDocViewProps> { PresBox.Instance?._selectedArray.clear(); pinDoc && PresBox.Instance?._selectedArray.set(pinDoc, undefined); //Update selected array }); - if ( - CollectionDockingView.Instance && - !Array.from(CollectionDockingView.Instance.tabMap) - .map(d => d.DashDoc) - .includes(curPres) - ) { - const docs = Cast(Doc.MyOverlayDocs.data, listSpec(Doc), []); - if (docs.includes(curPres)) docs.splice(docs.indexOf(curPres), 1); - CollectionDockingView.AddSplit(curPres, 'right'); - setTimeout(() => DocumentManager.Instance.jumpToDocument(docList.lastElement(), false, undefined, []), 100); // keeps the pinned doc in view since the sidebar shifts things + if ( + CollectionDockingView.Instance && + !Array.from(CollectionDockingView.Instance.tabMap) + .map(d => d.DashDoc) + .includes(curPres) + ) { + const docs = Cast(Doc.MyOverlayDocs.data, listSpec(Doc), []); + if (docs.includes(curPres)) docs.splice(docs.indexOf(curPres), 1); + CollectionDockingView.AddSplit(curPres, 'right'); + setTimeout(() => DocumentManager.Instance.jumpToDocument(docList.lastElement(), false, undefined, []), 100); // keeps the pinned doc in view since the sidebar shifts things + } + setTimeout(batch.end, 500); // need to wait until dockingview (goldenlayout) updates all its structurs } - setTimeout(batch.end, 500); // need to wait until dockingview (goldenlayout) updates all its structurs } componentDidMount() { diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx index a0ebe4cdc..3d85d32a0 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx @@ -393,8 +393,10 @@ function normalizeResults( 0 ); aggBounds.r = aggBounds.x + Math.max(minWidth, aggBounds.r - aggBounds.x); - const wscale = panelDim[0] / (aggBounds.r - aggBounds.x); - let scale = wscale * (aggBounds.b - aggBounds.y) > panelDim[1] ? panelDim[1] / (aggBounds.b - aggBounds.y) : wscale; + const width = aggBounds.r - aggBounds.x === 0 ? 1 : aggBounds.r - aggBounds.x; + const height = aggBounds.b - aggBounds.y === 0 ? 1 : aggBounds.b - aggBounds.y; + const wscale = panelDim[0] / width; + let scale = wscale * height > panelDim[1] ? panelDim[1] / height : wscale; if (Number.isNaN(scale)) scale = 1; Array.from(docMap.entries()) diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx index d979ef961..bf9de6760 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx @@ -195,7 +195,10 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo const bDocBounds = (B.props as any).DocumentView?.().getBounds() || { left: 0, right: 0, top: 0, bottom: 0 }; const aleft = this.visibleX(adiv); const bleft = this.visibleX(bdiv); - const clipped = aleft !== a.left || atop !== a.top || bleft !== b.left || btop !== b.top; + const aclipped = aleft !== a.left || atop !== a.top; + const bclipped = bleft !== b.left || btop !== b.top; + if (aclipped && bclipped) return undefined; + const clipped = aclipped || bclipped; const pt1 = [aleft + a.width / 2, atop + a.height / 2]; const pt2 = [bleft + b.width / 2, btop + b.width / 2]; const pt1vec = [(bDocBounds.left + bDocBounds.right) / 2 - pt1[0], (bDocBounds.top + bDocBounds.bottom) / 2 - pt1[1]]; diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx index 8720c9097..b8344dc0c 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx @@ -5,11 +5,14 @@ import { DocumentManager } from '../../../util/DocumentManager'; import './CollectionFreeFormLinksView.scss'; import { CollectionFreeFormLinkView } from './CollectionFreeFormLinkView'; import React = require('react'); +import { LightboxView } from '../../LightboxView'; @observer export class CollectionFreeFormLinksView extends React.Component<React.PropsWithChildren<{}>> { @computed get uniqueConnections() { - return Array.from(new Set(DocumentManager.Instance.LinkedDocumentViews)).map(c => <CollectionFreeFormLinkView key={c.l[Id]} A={c.a} B={c.b} LinkDocs={[c.l]} />); + return Array.from(new Set(DocumentManager.Instance.LinkedDocumentViews)) + .filter(c => !LightboxView.LightboxDoc || (LightboxView.IsLightboxDocView(c.a.docViewPath) && LightboxView.IsLightboxDocView(c.b.docViewPath))) + .map(c => <CollectionFreeFormLinkView key={c.l[Id]} A={c.a} B={c.b} LinkDocs={[c.l]} />); } render() { diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 3e938ec1c..82b377dfa 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -496,7 +496,6 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection const currentCol = DocListCast(this.rootDoc.currentInkDoc); const rootDocList = DocListCast(this.rootDoc.data); currentCol.push(rootDocList[rootDocList.length - 1]); - console.log(currentCol); this._batch?.end(); } diff --git a/src/client/views/linking/LinkEditor.scss b/src/client/views/linking/LinkEditor.scss index 1d6496d3c..b0ee4e46d 100644 --- a/src/client/views/linking/LinkEditor.scss +++ b/src/client/views/linking/LinkEditor.scss @@ -1,10 +1,11 @@ -@import "../global/globalCssVariables"; +@import '../global/globalCssVariables'; .linkEditor { width: 100%; height: auto; font-size: 13px; // TODO user-select: none; + max-width: 280px; } .linkEditor-button-back { @@ -20,19 +21,20 @@ } .linkEditor-info { - //border-bottom: 0.5px solid $light-gray; - //padding-bottom: 1px; - padding: 12px; + padding-top: 12px; padding-left: 5px; + padding-bottom: 3px; //margin-bottom: 6px; display: flex; justify-content: space-between; color: black; .linkEditor-linkedTo { - width: calc(100% - 26px); - padding-left: 5px; - padding-right: 5px; + width: calc(100% - 46px); + overflow: hidden; + position: relative; + text-overflow: ellipsis; + white-space: pre; .linkEditor-downArrow { &:hover { @@ -77,11 +79,14 @@ width: 20px; } } +.linkEditor-deleteBtn { + padding-left: 3px; +} .linkEditor-description { padding-left: 26px; - padding-right: 6.5px; padding-bottom: 3.5px; + display: flex; .linkEditor-description-label { text-decoration-color: black; @@ -96,7 +101,6 @@ //border: 1px solid grey; //border-radius: 4px; padding-left: 2px; - padding-right: 2px; //margin-right: 4px; color: black; text-decoration-color: grey; @@ -104,17 +108,13 @@ .linkEditor-description-add-button { display: inline; - /* float: right; */ border-radius: 7px; font-size: 9px; background: black; - /* padding: 3px; */ - padding-top: 4px; - padding-left: 7px; - padding-bottom: 4px; - padding-right: 8px; height: 80%; color: white; + padding: 3px; + margin-left: 3px; &:hover { cursor: pointer; @@ -146,6 +146,7 @@ padding-left: 26px; padding-right: 6.5px; padding-bottom: 15px; + display: flex; &:hover { cursor: pointer; @@ -153,12 +154,11 @@ .linkEditor-followingDropdown-label { color: black; + padding-right: 3px; } .linkEditor-followingDropdown-dropdown { - .linkEditor-followingDropdown-header { - border: 1px solid grey; border-radius: 4px; //background-color: rgb(236, 236, 236); @@ -195,27 +195,25 @@ background-color: rgb(187, 220, 231); } } - } } - - } - - - - - .linkEditor-button, .linkEditor-addbutton { - width: 18px; - height: 18px; - padding: 0; - // font-size: 12px; - border-radius: 10px; - - + width: 15%; + border-radius: 7px; + font-size: 9px; + background: black; + padding: 3px; + height: 80%; + color: white; + text-align: center; + margin: auto; + margin-left: 3px; + > svg { + margin: auto; + } &:disabled { background-color: gray; } @@ -270,7 +268,6 @@ } } - .linkEditor-dropdown { width: 100%; position: relative; @@ -334,4 +331,4 @@ .linkEditor-button { margin-left: 3px; } -}
\ No newline at end of file +} diff --git a/src/client/views/linking/LinkEditor.tsx b/src/client/views/linking/LinkEditor.tsx index ba301962b..1697062f4 100644 --- a/src/client/views/linking/LinkEditor.tsx +++ b/src/client/views/linking/LinkEditor.tsx @@ -3,32 +3,27 @@ import { Tooltip } from '@material-ui/core'; import { action, computed, observable } from 'mobx'; import { observer } from 'mobx-react'; import { Doc, NumListCast, StrListCast, Field } from '../../../fields/Doc'; -import { DateCast, StrCast, Cast } from '../../../fields/Types'; +import { DateCast, StrCast, Cast, BoolCast } from '../../../fields/Types'; import { LinkManager } from '../../util/LinkManager'; import { undoBatch } from '../../util/UndoManager'; import './LinkEditor.scss'; import { LinkRelationshipSearch } from './LinkRelationshipSearch'; import React = require('react'); +import { emptyFunction, returnFalse, setupMoveUpEvents } from '../../../Utils'; interface LinkEditorProps { sourceDoc: Doc; linkDoc: Doc; - showLinks: () => void; + showLinks?: () => void; hideback?: boolean; } @observer export class LinkEditor extends React.Component<LinkEditorProps> { @observable description = Field.toString(LinkManager.currentLink?.description as any as Field); @observable relationship = StrCast(LinkManager.currentLink?.linkRelationship); - @observable zoomFollow = StrCast(this.props.sourceDoc.followLinkZoom); + @observable zoomFollow = BoolCast(this.props.sourceDoc.followLinkZoom); + @observable audioFollow = BoolCast(this.props.sourceDoc.followLinkAudio); @observable openDropdown: boolean = false; - @observable showInfo: boolean = false; - @computed get infoIcon() { - if (this.showInfo) { - return 'chevron-up'; - } - return 'chevron-down'; - } @observable private buttonColor: string = ''; @observable private relationshipButtonColor: string = ''; @observable private relationshipSearchVisibility: string = 'none'; @@ -37,12 +32,6 @@ export class LinkEditor extends React.Component<LinkEditorProps> { //@observable description = this.props.linkDoc.description ? StrCast(this.props.linkDoc.description) : "DESCRIPTION"; @undoBatch - deleteLink = (): void => { - LinkManager.Instance.deleteLink(this.props.linkDoc); - this.props.showLinks(); - }; - - @undoBatch setRelationshipValue = action((value: string) => { if (LinkManager.currentLink) { const prevRelationship = LinkManager.currentLink.linkRelationship as string; @@ -159,8 +148,12 @@ export class LinkEditor extends React.Component<LinkEditorProps> { this.relationship = e.target.value; }; @action - handleZoomFollowChange = (e: React.ChangeEvent<HTMLInputElement>) => { - this.props.sourceDoc.followLinkZoom = e.target.checked; + handleZoomFollowChange = () => { + this.props.sourceDoc.followLinkZoom = !this.props.sourceDoc.followLinkZoom; + }; + @action + handleAudioFollowChange = () => { + this.props.sourceDoc.followLinkAudio = !this.props.sourceDoc.followLinkAudio; }; @action handleRelationshipSearchChange = (result: string) => { @@ -173,7 +166,7 @@ export class LinkEditor extends React.Component<LinkEditorProps> { //NOTE: confusingly, the classnames for the following relationship JSX elements are the same as the for the description elements for shared CSS return ( <div className="linkEditor-description"> - <div className="linkEditor-description-label">Link Relationship:</div> + <div className="linkEditor-description-label">Relationship:</div> <div className="linkEditor-description-input"> <div className="linkEditor-description-editing"> <input @@ -203,7 +196,23 @@ export class LinkEditor extends React.Component<LinkEditorProps> { <div className="linkEditor-zoomFollow-label">Zoom To Link Target:</div> <div className="linkEditor-zoomFollow-input"> <div className="linkEditor-zoomFollow-editing"> - <input style={{ width: '100%' }} type="checkbox" value={this.zoomFollow} onChange={this.handleZoomFollowChange} /> + <input type="checkbox" onPointerDown={e => setupMoveUpEvents(this, e, returnFalse, emptyFunction, this.handleZoomFollowChange)} defaultChecked={this.zoomFollow} /> + </div> + </div> + </div> + ); + } + + @computed + get editAudioFollow() { + //NOTE: confusingly, the classnames for the following relationship JSX elements are the same as the for the description elements for shared CSS + console.log('AudioFollow:' + this.audioFollow); + return ( + <div className="linkEditor-zoomFollow"> + <div className="linkEditor-zoomFollow-label">Play Target Audio:</div> + <div className="linkEditor-zoomFollow-input"> + <div className="linkEditor-zoomFollow-editing"> + <input type="checkbox" onPointerDown={e => setupMoveUpEvents(this, e, returnFalse, emptyFunction, this.handleAudioFollowChange)} defaultChecked={this.audioFollow} /> </div> </div> </div> @@ -214,7 +223,7 @@ export class LinkEditor extends React.Component<LinkEditorProps> { get editDescription() { return ( <div className="linkEditor-description"> - <div className="linkEditor-description-label">Link Description:</div> + <div className="linkEditor-description-label">Description:</div> <div className="linkEditor-description-input"> <div className="linkEditor-description-editing"> <input style={{ width: '100%' }} autoComplete={'off'} id="input" value={this.description} placeholder={'Enter link description'} onKeyDown={this.onDescriptionKey} onChange={this.handleDescriptionChange}></input> @@ -284,68 +293,100 @@ export class LinkEditor extends React.Component<LinkEditorProps> { ); } - @action - changeInfo = () => { - this.showInfo = !this.showInfo; + autoMove = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, returnFalse, emptyFunction, undoBatch(action(() => (this.props.linkDoc.linkAutoMove = !this.props.linkDoc.linkAutoMove)))); + }; + + showAnchor = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, returnFalse, emptyFunction, undoBatch(action(() => (this.props.linkDoc.hidden = !this.props.linkDoc.hidden)))); + }; + + showLink = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, returnFalse, emptyFunction, undoBatch(action(() => (this.props.linkDoc.linkDisplay = !this.props.linkDoc.linkDisplay)))); + }; + + deleteLink = (e: React.PointerEvent): void => { + setupMoveUpEvents(this, e, returnFalse, emptyFunction, undoBatch(action(() => LinkManager.Instance.deleteLink(this.props.linkDoc)))); }; render() { const destination = LinkManager.getOppositeAnchor(this.props.linkDoc, this.props.sourceDoc); return !destination ? null : ( - <div className="linkEditor" tabIndex={0} onKeyDown={e => e.stopPropagation()}> + <div className="linkEditor" tabIndex={0} onKeyDown={e => e.stopPropagation()} onPointerDown={e => e.stopPropagation()}> <div className="linkEditor-info"> - <Tooltip - title={ - <> - <div className="dash-tooltip">Return to link menu</div> - </> - } - placement="top"> - <button className="linkEditor-button-back" style={{ display: this.props.hideback ? 'none' : '' }} onClick={this.props.showLinks}> - <FontAwesomeIcon icon="arrow-left" size="sm" />{' '} - </button> - </Tooltip> + {!this.props.showLinks ? null : ( + <Tooltip title={<div className="dash-tooltip">Return to link menu</div>} placement="top"> + <button className="linkEditor-button-back" style={{ display: this.props.hideback ? 'none' : '' }} onPointerDown={e => setupMoveUpEvents(this, e, returnFalse, emptyFunction, () => this.props.showLinks?.())}> + <FontAwesomeIcon icon="arrow-left" size="sm" />{' '} + </button> + </Tooltip> + )} <p className="linkEditor-linkedTo"> Editing Link to: <b>{StrCast(destination.proto?.title, StrCast(destination.title, 'untitled'))}</b> </p> - <Tooltip - title={ - <> - <div className="dash-tooltip">Show more link information</div> - </> - } - placement="top"> - <div className="linkEditor-downArrow"> - <FontAwesomeIcon className="button" icon={this.infoIcon} size="lg" onPointerDown={this.changeInfo} /> + <Tooltip title={<div className="dash-tooltip">Delete Link</div>}> + <div className="linkEditor-deleteBtn" onPointerDown={this.deleteLink} onClick={e => e.stopPropagation()}> + <FontAwesomeIcon className="fa-icon" icon="trash" size="sm" /> </div> </Tooltip> </div> - {this.showInfo ? ( - <div className="linkEditor-moreInfo"> - <div> - {this.props.linkDoc.author ? ( - <div> - {' '} - <b>Author:</b> {StrCast(this.props.linkDoc.author)} - </div> - ) : null} - </div> - <div> - {this.props.linkDoc.creationDate ? ( - <div> - {' '} - <b>Creation Date:</b> - {DateCast(this.props.linkDoc.creationDate).toString()} - </div> - ) : null} - </div> - </div> - ) : null} + <div className="linkEditor-moreInfo"> + {this.props.linkDoc.author ? ( + <> + {' '} + <b>Author:</b> {StrCast(this.props.linkDoc.author)} + </> + ) : null} + {this.props.linkDoc.creationDate ? ( + <> + {' '} + <b>Creation Date:</b> + {DateCast(this.props.linkDoc.creationDate).toString()} + </> + ) : null} + </div> {this.editDescription} {this.editRelationship} {this.editZoomFollow} + {this.editAudioFollow} + <div className="linkEditor-description"> + Show Anchor: + <Tooltip title={<div className="dash-tooltip">{this.props.linkDoc.hidden ? 'Show Link Anchor' : 'Hide Link Anchor'}</div>}> + <div + className="linkEditor-button" + style={{ background: this.props.linkDoc.hidden ? 'gray' : '#4476f7' /* $medium-blue */ }} + onPointerDown={this.showAnchor} + onClick={e => e.stopPropagation()}> + <FontAwesomeIcon className="fa-icon" icon={'eye'} size="sm" /> + </div> + </Tooltip> + </div> + <div className="linkEditor-description"> + Show Link Line: + <Tooltip title={<div className="dash-tooltip">{this.props.linkDoc.linkDisplay ? 'Hide Link Line' : 'Show Link Line'}</div>}> + <div + className="linkEditor-button" + style={{ background: this.props.linkDoc.hidden ? 'gray' : this.props.linkDoc.linkDisplay ? '#4476f7' /* $medium-blue */ : '' }} + onPointerDown={this.showLink} + onClick={e => e.stopPropagation()}> + <FontAwesomeIcon className="fa-icon" icon={'project-diagram'} size="sm" /> + </div> + </Tooltip> + </div> + <div className="linkEditor-description"> + Freeze Anchor: + <Tooltip title={<div className="dash-tooltip">{this.props.linkDoc.linkAutoMove ? 'Click to freeze link anchor position' : 'Click to auto move link anchor'}</div>}> + <div + className="linkEditor-button" + style={{ background: this.props.linkDoc.hidden ? 'gray' : this.props.linkDoc.linkAutoMove ? '' : '#4476f7' /* $medium-blue */ }} + onPointerDown={this.autoMove} + onClick={e => e.stopPropagation()}> + <FontAwesomeIcon className="fa-icon" icon={'play'} size="sm" /> + </div> + </Tooltip> + </div> {this.followingDropdown} </div> ); diff --git a/src/client/views/linking/LinkMenuItem.tsx b/src/client/views/linking/LinkMenuItem.tsx index ed856a4ab..3f9db2612 100644 --- a/src/client/views/linking/LinkMenuItem.tsx +++ b/src/client/views/linking/LinkMenuItem.tsx @@ -6,14 +6,12 @@ import { observer } from 'mobx-react'; import { Doc, DocListCast } from '../../../fields/Doc'; import { Cast, StrCast } from '../../../fields/Types'; import { WebField } from '../../../fields/URLField'; -import { emptyFunction, returnFalse, setupMoveUpEvents } from '../../../Utils'; +import { emptyFunction, setupMoveUpEvents } from '../../../Utils'; import { DocumentType } from '../../documents/DocumentTypes'; import { DocumentManager } from '../../util/DocumentManager'; import { DragManager } from '../../util/DragManager'; -import { Hypothesis } from '../../util/HypothesisUtils'; import { LinkFollower } from '../../util/LinkFollower'; import { LinkManager } from '../../util/LinkManager'; -import { undoBatch } from '../../util/UndoManager'; import { DocumentView } from '../nodes/DocumentView'; import { LinkDocPreview } from '../nodes/LinkDocPreview'; import './LinkMenuItem.scss'; @@ -125,33 +123,6 @@ export class LinkMenuItem extends React.Component<LinkMenuItemProps> { ); }; - deleteLink = (e: React.PointerEvent): void => { - setupMoveUpEvents( - this, - e, - returnFalse, - emptyFunction, - undoBatch( - action(() => { - this.props.linkDoc.linksToAnnotation && Hypothesis.deleteLink(this.props.linkDoc, this.props.sourceDoc, this.props.destinationDoc); - LinkManager.Instance.deleteLink(this.props.linkDoc); - }) - ) - ); - }; - - autoMove = (e: React.PointerEvent) => { - setupMoveUpEvents(this, e, returnFalse, emptyFunction, undoBatch(action(() => (this.props.linkDoc.linkAutoMove = !this.props.linkDoc.linkAutoMove)))); - }; - - showLink = (e: React.PointerEvent) => { - setupMoveUpEvents(this, e, returnFalse, emptyFunction, undoBatch(action(() => (this.props.linkDoc.linkDisplay = !this.props.linkDoc.linkDisplay)))); - }; - - showAnchor = (e: React.PointerEvent) => { - setupMoveUpEvents(this, e, returnFalse, emptyFunction, undoBatch(action(() => (this.props.linkDoc.hidden = !this.props.linkDoc.hidden)))); - }; - render() { const destinationIcon = Doc.toIcon(this.props.destinationDoc) as any as IconProp; @@ -183,7 +154,7 @@ export class LinkMenuItem extends React.Component<LinkMenuItemProps> { linkSrc: this.props.sourceDoc, linkDoc: this.props.linkDoc, showHeader: false, - location: [e.clientX, e.clientY + 20], + location: [this._drag.current?.getBoundingClientRect().right ?? 100, this._drag.current?.getBoundingClientRect().top ?? e.clientY], }) } onPointerDown={this.onLinkButtonDown}> @@ -206,69 +177,11 @@ export class LinkMenuItem extends React.Component<LinkMenuItemProps> { </div> <div className="linkMenu-item-buttons" ref={this._buttonRef}> - <Tooltip - title={ - <> - <div className="dash-tooltip">{this.props.linkDoc.hidden ? 'Show Link Anchor' : 'Hide Link Anchor'}</div> - </> - }> - <div className="button" ref={this._editRef} style={{ background: this.props.linkDoc.hidden ? '' : '#4476f7' /* $medium-blue */ }} onPointerDown={this.showAnchor} onClick={e => e.stopPropagation()}> - <FontAwesomeIcon className="fa-icon" icon={'eye'} size="sm" /> - </div> - </Tooltip> - - <Tooltip - title={ - <> - <div className="dash-tooltip">{this.props.linkDoc.linkDisplay ? 'Hide Link Line' : 'Show Link Line'}</div> - </> - }> - <div - className="button" - ref={this._editRef} - style={{ background: this.props.linkDoc.hidden ? 'gray' : this.props.linkDoc.linkDisplay ? '#4476f7' /* $medium-blue */ : '' }} - onPointerDown={this.showLink} - onClick={e => e.stopPropagation()}> - <FontAwesomeIcon className="fa-icon" icon={'project-diagram'} size="sm" /> - </div> - </Tooltip> - - <Tooltip - title={ - <> - <div className="dash-tooltip">{this.props.linkDoc.linkAutoMove ? 'Click to freeze link anchor position' : 'Click to auto move link anchor'}</div> - </> - }> - <div - className="button" - ref={this._editRef} - style={{ background: this.props.linkDoc.hidden ? 'gray' : !this.props.linkDoc.linkAutoMove ? '' : '#4476f7' /* $medium-blue */ }} - onPointerDown={this.autoMove} - onClick={e => e.stopPropagation()}> - <FontAwesomeIcon className="fa-icon" icon={'play'} size="sm" /> - </div> - </Tooltip> - - <Tooltip - title={ - <> - <div className="dash-tooltip">Edit Link</div> - </> - }> + <Tooltip title={<div className="dash-tooltip">Edit Link</div>}> <div className="button" ref={this._editRef} onPointerDown={this.onEdit} onClick={e => e.stopPropagation()}> <FontAwesomeIcon className="fa-icon" icon="edit" size="sm" /> </div> </Tooltip> - <Tooltip - title={ - <> - <div className="dash-tooltip">Delete Link</div> - </> - }> - <div className="button" onPointerDown={this.deleteLink} onClick={e => e.stopPropagation()}> - <FontAwesomeIcon className="fa-icon" icon="trash" size="sm" /> - </div> - </Tooltip> </div> </div> </div> diff --git a/src/client/views/nodes/DocumentLinksButton.tsx b/src/client/views/nodes/DocumentLinksButton.tsx index 5939d1680..a37de7f69 100644 --- a/src/client/views/nodes/DocumentLinksButton.tsx +++ b/src/client/views/nodes/DocumentLinksButton.tsx @@ -307,7 +307,7 @@ export class DocumentLinksButton extends React.Component<DocumentLinksButtonProp <div className="documentLinksButton-wrapper" style={{ - transform: this.props.InMenu ? undefined : `scale(${this.props.scaling})`, + transform: `scale(${this.props.scaling?.() || 1})`, }}> {(this.props.InMenu && (DocumentLinksButton.StartLink || this.props.StartLink)) || (!DocumentLinksButton.LinkEditorDocView && !this.props.InMenu) ? ( <Tooltip title={<div className="dash-tooltip">{title}</div>}>{this.linkButtonInner}</Tooltip> diff --git a/src/client/views/nodes/DocumentView.scss b/src/client/views/nodes/DocumentView.scss index c02692bfb..6ea697a2f 100644 --- a/src/client/views/nodes/DocumentView.scss +++ b/src/client/views/nodes/DocumentView.scss @@ -65,11 +65,13 @@ width: 25px; height: 25; position: absolute; - top: 10px; - left: 10px; + top: 0; + left: 50%; border-radius: 25px; background: white; opacity: 0.3; + pointer-events: all; + cursor: default; svg { width: 90% !important; diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index edaa40bad..f87581875 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -10,7 +10,7 @@ import { List } from '../../../fields/List'; import { ObjectField } from '../../../fields/ObjectField'; import { listSpec } from '../../../fields/Schema'; import { ScriptField } from '../../../fields/ScriptField'; -import { BoolCast, Cast, ImageCast, NumCast, ScriptCast, StrCast } from '../../../fields/Types'; +import { BoolCast, Cast, DocCast, ImageCast, NumCast, ScriptCast, StrCast } from '../../../fields/Types'; import { AudioField } from '../../../fields/URLField'; import { GetEffectiveAcl, SharingPermissions, TraceMobx } from '../../../fields/util'; import { MobileInterface } from '../../../mobile/MobileInterface'; @@ -52,6 +52,8 @@ import { RadialMenu } from './RadialMenu'; import { ScriptingBox } from './ScriptingBox'; import { PresBox } from './trails/PresBox'; import React = require('react'); +import { DictationManager } from '../../util/DictationManager'; +import { Tooltip } from '@material-ui/core'; const { Howl } = require('howler'); interface Window { @@ -156,6 +158,7 @@ export interface DocumentViewSharedProps { scriptContext?: any; // can be assigned anything and will be passed as 'scriptContext' to any OnClick script that executes on this document createNewFilterDoc?: () => void; updateFilterDoc?: (doc: Doc) => void; + dontHideOnDrag?: boolean; } // these props are specific to DocuentViews @@ -203,7 +206,6 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps public static SelectAfterContextMenu = true; // whether a document should be selected after it's contextmenu is triggered. _animateScaleTime = 300; // milliseconds; @observable _animateScalingTo = 0; - @observable _mediaState = 0; @observable _pendingDoubleClick = false; private _disposers: { [name: string]: IReactionDisposer } = {}; private _downX: number = 0; @@ -502,9 +504,16 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps dragData.treeViewDoc = this.props.treeViewDoc; dragData.removeDocument = this.props.removeDocument; dragData.moveDocument = this.props.moveDocument; + //dragData.dimSource : + // dragEffects field, set dim + // add kv pairs to a doc, swap properties with the node while dragging, and then swap when dropping + // add a dragEffects prop to DocumentView as a function that sets up. Each view has its own prop, when you start dragging: + // in Draganager, figure out which doc(s) you're dragging and change what opacity function returns const ffview = this.props.CollectionFreeFormDocumentView?.().props.CollectionFreeFormView; ffview && runInAction(() => (ffview.ChildDrag = this.props.DocumentView())); - DragManager.StartDocumentDrag([this._mainCont.current], dragData, x, y, { hideSource: hideSource || (!dropAction && !this.layoutDoc.onDragStart) }, () => setTimeout(action(() => ffview && (ffview.ChildDrag = undefined)))); // this needs to happen after the drop event is processed. + DragManager.StartDocumentDrag([this._mainCont.current], dragData, x, y, { hideSource: hideSource || (!dropAction && !this.layoutDoc.onDragStart && !this.props.dontHideOnDrag) }, () => + setTimeout(action(() => ffview && (ffview.ChildDrag = undefined))) + ); // this needs to happen after the drop event is processed. ffview?.setupDragLines(false); } } @@ -869,7 +878,6 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps !appearance && 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) { - !Doc.noviceMode && appearanceItems.splice(0, 0, { description: `${!this.layoutDoc._showAudio ? 'Show' : 'Hide'} Audio Button`, event: action(() => (this.layoutDoc._showAudio = !this.layoutDoc._showAudio)), icon: 'microphone' }); const existingOnClick = cm.findByDescription('OnClick...'); const onClicks: ContextMenuProps[] = existingOnClick && 'subitems' in existingOnClick ? existingOnClick.subitems : []; @@ -997,16 +1005,21 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps linkButtonInverseScaling = () => (this.props.NativeDimScaling?.() || 1) * this.props.DocumentView().screenToLocalTransform().Scale; @computed get contents() { TraceMobx(); - const audioView = !this.layoutDoc._showAudio ? null : ( - <div className="documentView-audioBackground" onPointerDown={this.recordAudioAnnotation} onPointerEnter={this.onPointerEnter}> - <FontAwesomeIcon - className="documentView-audioFont" - style={{ color: [DocListCast(this.dataDoc[this.LayoutFieldKey + '-audioAnnotations']).length ? 'blue' : 'gray', 'green', 'red'][this._mediaState] }} - icon={!DocListCast(this.dataDoc[this.LayoutFieldKey + '-audioAnnotations']).length ? 'microphone' : 'file-audio'} - size="sm" - /> - </div> - ); + const audioAnnosCount = Cast(this.dataDoc[this.LayoutFieldKey + '-audioAnnotations'], listSpec(AudioField), null)?.length; + const audioTextAnnos = Cast(this.dataDoc[this.LayoutFieldKey + '-audioAnnotations-text'], listSpec('string'), null); + const audioView = + (!this.props.isSelected() && !this._isHovering && this.dataDoc.audioAnnoState !== 2) || this.props.renderDepth === -1 || SnappingManager.GetIsDragging() || (!audioAnnosCount && !this.dataDoc.audioAnnoState) ? null : ( + <Tooltip title={<div>{audioTextAnnos?.lastElement()}</div>}> + <div className="documentView-audioBackground" onPointerDown={this.playAnnotation}> + <FontAwesomeIcon + className="documentView-audioFont" + style={{ color: [audioAnnosCount ? 'blue' : 'gray', 'green', 'red'][NumCast(this.dataDoc.audioAnnoState)] }} + icon={!audioAnnosCount ? 'microphone' : 'file-audio'} + size="sm" + /> + </div> + </Tooltip> + ); return ( <div @@ -1062,7 +1075,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps <DocumentLinksButton View={this.props.DocumentView()} scaling={this.linkButtonInverseScaling} - Offset={[this.topMost ? 0 : !this.props.isSelected() ? -15 : -30, undefined, undefined, this.topMost ? 10 : !this.props.isSelected() ? -15 : -30]} + Offset={[this.topMost ? 0 : !this.props.isSelected() ? -15 : -36, undefined, undefined, this.topMost ? 10 : !this.props.isSelected() ? -15 : -28]} /> )} {audioView} @@ -1140,58 +1153,72 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps } @action - onPointerEnter = () => { + playAnnotation = () => { const self = this; - const audioAnnos = DocListCast(this.dataDoc[this.LayoutFieldKey + '-audioAnnotations']); - if (audioAnnos && audioAnnos.length && this._mediaState === 0) { - const anno = audioAnnos[Math.floor(Math.random() * audioAnnos.length)]; - anno.data instanceof AudioField && - new Howl({ - src: [anno.data.url.href], - format: ['mp3'], - autoplay: true, - loop: false, - volume: 0.5, - onend: function () { - runInAction(() => (self._mediaState = 0)); - }, - }); - this._mediaState = 1; + const audioAnnos = Cast(this.dataDoc[this.LayoutFieldKey + '-audioAnnotations'], listSpec(AudioField), null); + const anno = audioAnnos.lastElement(); + if (anno instanceof AudioField && this.dataDoc.audioAnnoState === 0) { + new Howl({ + src: [anno.url.href], + format: ['mp3'], + autoplay: true, + loop: false, + volume: 0.5, + onend: function () { + runInAction(() => { + self.dataDoc.audioAnnoState = 0; + }); + }, + }); + this.dataDoc.audioAnnoState = 1; } }; - recordAudioAnnotation = () => { + + static recordAudioAnnotation(dataDoc: Doc, field: string, onEnd?: () => void) { let gumStream: any; let recorder: any; - const self = this; navigator.mediaDevices .getUserMedia({ audio: true, }) .then(function (stream) { + let audioTextAnnos = Cast(dataDoc[field + '-audioAnnotations-text'], listSpec('string'), null); + if (audioTextAnnos) audioTextAnnos.push(''); + else audioTextAnnos = dataDoc[field + '-audioAnnotations-text'] = new List<string>(['']); + DictationManager.Controls.listen({ + interimHandler: value => (audioTextAnnos[audioTextAnnos.length - 1] = value), + continuous: { indefinite: false }, + }).then(results => { + if (results && [DictationManager.Controls.Infringed].includes(results)) { + DictationManager.Controls.stop(); + } + onEnd?.(); + }); + gumStream = stream; recorder = new MediaRecorder(stream); recorder.ondataavailable = async (e: any) => { const [{ result }] = await Networking.UploadFilesToServer(e.data); if (!(result instanceof Error)) { - const audioDoc = Docs.Create.AudioDocument(result.accessPaths.agnostic.client, { title: 'audio test', _width: 200, _height: 32 }); - audioDoc.treeViewExpandedView = 'layout'; - const audioAnnos = Cast(self.dataDoc[self.LayoutFieldKey + '-audioAnnotations'], listSpec(Doc)); + const audioField = new AudioField(result.accessPaths.agnostic.client); + const audioAnnos = Cast(dataDoc[field + '-audioAnnotations'], listSpec(AudioField), null); if (audioAnnos === undefined) { - self.dataDoc[self.LayoutFieldKey + '-audioAnnotations'] = new List([audioDoc]); + dataDoc[field + '-audioAnnotations'] = new List([audioField]); } else { - audioAnnos.push(audioDoc); + audioAnnos.push(audioField); } } }; - runInAction(() => (self._mediaState = 2)); + runInAction(() => (dataDoc.audioAnnoState = 2)); recorder.start(); setTimeout(() => { recorder.stop(); - runInAction(() => (self._mediaState = 0)); + DictationManager.Controls.stop(false); + runInAction(() => (dataDoc.audioAnnoState = 0)); gumStream.getAudioTracks()[0].stop(); }, 5000); }); - }; + } captionStyleProvider = (doc: Opt<Doc>, props: Opt<DocumentViewInternalProps>, property: string) => this.props?.styleProvider?.(doc, props, property + ':caption'); @computed get innards() { @@ -1290,6 +1317,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps isHovering = () => this._isHovering; @observable _isHovering = false; @observable _: string = ''; + _hoverTimeout: any = undefined; @computed get renderDoc() { TraceMobx(); const thumb = ImageCast(this.layoutDoc['thumb-frozen'], ImageCast(this.layoutDoc.thumb))?.url?.href.replace('.png', '_m.png'); @@ -1300,8 +1328,17 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps <div className={`documentView-node${this.topMost ? '-topmost' : ''}`} id={this.props.Document[Id]} - onPointerEnter={action(() => (this._isHovering = true))} - onPointerLeave={action(() => (this._isHovering = false))} + onPointerEnter={action(() => { + clearTimeout(this._hoverTimeout); + this._isHovering = true; + })} + onPointerLeave={action(() => { + clearTimeout(this._hoverTimeout); + this._hoverTimeout = setTimeout( + action(() => (this._isHovering = false)), + 500 + ); + })} style={{ background: isButton || thumb ? undefined : this.backgroundColor, opacity: this.opacity, diff --git a/src/client/views/nodes/FilterBox.tsx b/src/client/views/nodes/FilterBox.tsx index ff04a293c..dc3fc0396 100644 --- a/src/client/views/nodes/FilterBox.tsx +++ b/src/client/views/nodes/FilterBox.tsx @@ -116,7 +116,7 @@ export class FilterBox extends ViewBoxBaseComponent<FieldViewProps>() { @observable _loaded = false; componentDidMount() { reaction( - () => DocListCastAsync(this.layoutDoc.data), + () => DocListCastAsync(this.layoutDoc[this.fieldKey]), async activeTabsAsync => { const activeTabs = await activeTabsAsync; activeTabs && (await SearchBox.foreachRecursiveDocAsync(activeTabs, emptyFunction)); diff --git a/src/client/views/nodes/FunctionPlotBox.tsx b/src/client/views/nodes/FunctionPlotBox.tsx index 3ab0a3ff2..15d0f88f6 100644 --- a/src/client/views/nodes/FunctionPlotBox.tsx +++ b/src/client/views/nodes/FunctionPlotBox.tsx @@ -1,4 +1,4 @@ -import functionPlot from "function-plot"; +import functionPlot from 'function-plot'; import { action, computed, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; @@ -12,7 +12,6 @@ import { Docs } from '../../documents/Documents'; import { ViewBoxBaseComponent } from '../DocComponent'; import { FieldView, FieldViewProps } from './FieldView'; - const EquationSchema = createSchema({}); type EquationDocument = makeInterface<[typeof EquationSchema, typeof documentSchema]>; @@ -20,74 +19,83 @@ const EquationDocument = makeInterface(EquationSchema, documentSchema); @observer export class FunctionPlotBox extends ViewBoxBaseComponent<FieldViewProps>() { - public static LayoutString(fieldKey: string) { return FieldView.LayoutString(FunctionPlotBox, fieldKey); } + public static LayoutString(fieldKey: string) { + return FieldView.LayoutString(FunctionPlotBox, fieldKey); + } public static GraphCount = 0; _plot: any; - _plotId = ""; + _plotId = ''; _plotEle: any; constructor(props: any) { super(props); - this._plotId = "graph" + FunctionPlotBox.GraphCount++; + this._plotId = 'graph' + FunctionPlotBox.GraphCount++; } componentDidMount() { this.props.setContentView?.(this); - reaction(() => [DocListCast(this.dataDoc.data).lastElement()?.text, this.layoutDoc.width, this.layoutDoc.height, this.dataDoc.xRange, this.dataDoc.yRange], - () => this.createGraph()); + reaction( + () => [DocListCast(this.dataDoc[this.fieldKey]).lastElement()?.text, this.layoutDoc.width, this.layoutDoc.height, this.dataDoc.xRange, this.dataDoc.yRange], + () => this.createGraph() + ); } getAnchor = () => { const anchor = Docs.Create.TextanchorDocument({ annotationOn: this.rootDoc }); anchor.xRange = new List<number>(Array.from(this._plot.options.xAxis.domain)); anchor.yRange = new List<number>(Array.from(this._plot.options.yAxis.domain)); return anchor; - } + }; @action scrollFocus = (doc: Doc, smooth: boolean) => { - this.dataDoc.xRange = new List<number>(Array.from(Cast(doc.xRange, listSpec("number"), Cast(this.dataDoc.xRange, listSpec("number"), [-10, 10])))); - this.dataDoc.yRange = new List<number>(Array.from(Cast(doc.yRange, listSpec("number"), Cast(this.dataDoc.xRange, listSpec("number"), [-1, 9])))); + this.dataDoc.xRange = new List<number>(Array.from(Cast(doc.xRange, listSpec('number'), Cast(this.dataDoc.xRange, listSpec('number'), [-10, 10])))); + this.dataDoc.yRange = new List<number>(Array.from(Cast(doc.yRange, listSpec('number'), Cast(this.dataDoc.xRange, listSpec('number'), [-1, 9])))); return 0; - } + }; createGraph = (ele?: HTMLDivElement) => { this._plotEle = ele || this._plotEle; const width = this.props.PanelWidth(); const height = this.props.PanelHeight(); - const fn = StrCast(DocListCast(this.dataDoc.data).lastElement()?.text, "x^2").replace(/\\frac\{(.*)\}\{(.*)\}/, "($1/$2)"); + const fn = StrCast(DocListCast(this.dataDoc.data).lastElement()?.text, 'x^2').replace(/\\frac\{(.*)\}\{(.*)\}/, '($1/$2)'); try { this._plot = functionPlot({ - target: "#" + this._plotEle.id, + target: '#' + this._plotEle.id, width, height, - xAxis: { domain: Cast(this.dataDoc.xRange, listSpec("number"), [-10, 10]) }, - yAxis: { domain: Cast(this.dataDoc.xRange, listSpec("number"), [-1, 9]) }, + xAxis: { domain: Cast(this.dataDoc.xRange, listSpec('number'), [-10, 10]) }, + yAxis: { domain: Cast(this.dataDoc.xRange, listSpec('number'), [-1, 9]) }, grid: true, data: [ { fn, // derivative: { fn: "2 * x", updateOnMouseMove: true } - } - ] + }, + ], }); } catch (e) { console.log(e); } - } + }; @computed get theGraph() { - return <div id={`${this._plotId}`} ref={r => r && this.createGraph(r)} style={{ position: "absolute", width: "100%", height: "100%" }} - onPointerDown={e => e.stopPropagation()} />; + return <div id={`${this._plotId}`} ref={r => r && this.createGraph(r)} style={{ position: 'absolute', width: '100%', height: '100%' }} onPointerDown={e => e.stopPropagation()} />; } render() { TraceMobx(); - return (<div - style={{ - pointerEvents: !this.isContentActive() ? "all" : undefined, - width: this.props.PanelWidth(), - height: this.props.PanelHeight() - }} - > - {this.theGraph} - <div style={{ - display: this.props.isSelected() ? "none" : undefined, position: "absolute", width: "100%", height: "100%", - pointerEvents: "all" - }} /> - </div>); + return ( + <div + style={{ + pointerEvents: !this.isContentActive() ? 'all' : undefined, + width: this.props.PanelWidth(), + height: this.props.PanelHeight(), + }}> + {this.theGraph} + <div + style={{ + display: this.props.isSelected() ? 'none' : undefined, + position: 'absolute', + width: '100%', + height: '100%', + pointerEvents: 'all', + }} + /> + </div> + ); } -}
\ No newline at end of file +} diff --git a/src/client/views/nodes/LinkAnchorBox.tsx b/src/client/views/nodes/LinkAnchorBox.tsx index 85a8622ec..5102eae51 100644 --- a/src/client/views/nodes/LinkAnchorBox.tsx +++ b/src/client/views/nodes/LinkAnchorBox.tsx @@ -136,7 +136,7 @@ export class LinkAnchorBox extends ViewBoxBaseComponent<FieldViewProps>() { return ( <div className={`linkAnchorBox-cont${small ? '-small' : ''}`} - onPointerLeave={LinkDocPreview.Clear} + //onPointerLeave={} //LinkDocPreview.Clear} onPointerEnter={e => LinkDocPreview.SetLinkInfo({ docProps: this.props, diff --git a/src/client/views/nodes/LinkDocPreview.tsx b/src/client/views/nodes/LinkDocPreview.tsx index a12564839..93ca22d5d 100644 --- a/src/client/views/nodes/LinkDocPreview.tsx +++ b/src/client/views/nodes/LinkDocPreview.tsx @@ -5,7 +5,7 @@ import { observer } from 'mobx-react'; import wiki from 'wikijs'; import { Doc, DocCastAsync, DocListCast, HeightSym, Opt, WidthSym } from '../../../fields/Doc'; import { Cast, NumCast, StrCast } from '../../../fields/Types'; -import { emptyFunction, returnEmptyDoclist, returnEmptyFilter, returnFalse, returnNone, setupMoveUpEvents } from '../../../Utils'; +import { emptyFunction, returnEmptyDoclist, returnEmptyFilter, returnEmptyString, returnFalse, returnNone, setupMoveUpEvents } from '../../../Utils'; import { DocServer } from '../../DocServer'; import { Docs, DocUtils } from '../../documents/Documents'; import { DocumentType } from '../../documents/DocumentTypes'; @@ -17,6 +17,7 @@ import { undoBatch } from '../../util/UndoManager'; import { DocumentView, DocumentViewSharedProps } from './DocumentView'; import './LinkDocPreview.scss'; import React = require('react'); +import { LinkEditor } from '../linking/LinkEditor'; interface LinkDocPreviewProps { linkDoc?: Doc; @@ -118,13 +119,15 @@ export class LinkDocPreview extends React.Component<LinkDocPreviewProps> { } return undefined; } - deleteLink = (e: React.PointerEvent) => { + @observable _showEditor = false; + editLink = (e: React.PointerEvent): void => { + LinkManager.currentLink = this.props.linkDoc; setupMoveUpEvents( this, e, returnFalse, emptyFunction, - undoBatch(() => this._linkDoc && LinkManager.Instance.deleteLink(this._linkDoc)) + action(() => (this._showEditor = !this._showEditor)) ); }; nextHref = (e: React.PointerEvent) => { @@ -183,9 +186,9 @@ export class LinkDocPreview extends React.Component<LinkDocPreviewProps> { </div> </Tooltip> - <Tooltip title={<div className="dash-tooltip">Delete Link</div>} placement="top"> - <div className="linkDocPreview-button" onPointerDown={this.deleteLink}> - <FontAwesomeIcon className="linkDocPreview-fa-icon" icon="trash" color="white" size="sm" /> + <Tooltip title={<div className="dash-tooltip">Edit Link</div>} placement="top"> + <div className="linkDocPreview-button" onPointerDown={this.editLink}> + <FontAwesomeIcon className="linkDocPreview-fa-icon" icon="edit" color="white" size="sm" /> </div> </Tooltip> </div> @@ -236,6 +239,7 @@ export class LinkDocPreview extends React.Component<LinkDocPreviewProps> { isDocumentActive={returnFalse} isContentActive={returnFalse} addDocument={returnFalse} + showTitle={returnEmptyString} removeDocument={returnFalse} addDocTab={returnFalse} pinToPres={returnFalse} @@ -270,8 +274,9 @@ export class LinkDocPreview extends React.Component<LinkDocPreviewProps> { className="linkDocPreview" ref={this._linkDocRef} onPointerDown={this.followLinkPointerDown} - style={{ left: this.props.location[0], top: this.props.location[1], width: this.width() + borders, height: this.height() + borders + (this.props.showHeader ? 37 : 0) }}> - {this.docPreview} + style={{ left: this.props.location[0], top: this.props.location[1], width: this._showEditor ? 'auto' : this.width() + borders, height: this._showEditor ? 'max-content' : this.height() + borders + (this.props.showHeader ? 37 : 0) }}> + {this._showEditor ? null : this.docPreview} + {!this._showEditor || !this._linkSrc || !this._linkDoc ? null : <LinkEditor sourceDoc={this._linkSrc} linkDoc={this._linkDoc} showLinks={action(() => (this._showEditor = !this._showEditor))} />} </div> ); } diff --git a/src/client/views/nodes/MapBox/MapBoxInfoWindow.tsx b/src/client/views/nodes/MapBox/MapBoxInfoWindow.tsx index 630ae18f5..00bedafbe 100644 --- a/src/client/views/nodes/MapBox/MapBoxInfoWindow.tsx +++ b/src/client/views/nodes/MapBox/MapBoxInfoWindow.tsx @@ -1,5 +1,5 @@ import { InfoWindow } from '@react-google-maps/api'; -import { action, computed } from 'mobx'; +import { action } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { Doc } from '../../../../fields/Doc'; @@ -7,6 +7,7 @@ import { Id } from '../../../../fields/FieldSymbols'; import { emptyFunction, OmitKeys, returnAll, returnEmptyFilter, returnFalse, returnOne, returnTrue, returnZero, setupMoveUpEvents } from '../../../../Utils'; import { Docs } from '../../../documents/Documents'; import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes'; +import { CollectionNoteTakingView } from '../../collections/CollectionNoteTakingView'; import { CollectionStackingView } from '../../collections/CollectionStackingView'; import { ViewBoxAnnotatableProps } from '../../DocComponent'; import { FieldViewProps } from '../FieldView'; @@ -40,7 +41,7 @@ export class MapBoxInfoWindow extends React.Component<MapBoxInfoWindowProps & Vi }); }; - _stack: CollectionStackingView | null | undefined; + _stack: CollectionStackingView | CollectionNoteTakingView | null | undefined; childFitWidth = (doc: Doc) => doc.type === DocumentType.RTF; addDoc = (doc: Doc | Doc[]) => (doc instanceof Doc ? [doc] : doc).reduce((p, d) => p && Doc.AddDocToList(this.props.place, 'data', d), true as boolean); removeDoc = (doc: Doc | Doc[]) => (doc instanceof Doc ? [doc] : doc).reduce((p, d) => p && Doc.RemoveDocFromList(this.props.place, 'data', d), true as boolean); diff --git a/src/client/views/nodes/button/FontIconBox.tsx b/src/client/views/nodes/button/FontIconBox.tsx index 78ef85ec2..d3b95e25a 100644 --- a/src/client/views/nodes/button/FontIconBox.tsx +++ b/src/client/views/nodes/button/FontIconBox.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 } from 'mobx'; +import { action, computed, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { ColorState, SketchPicker } from 'react-color'; @@ -710,6 +710,13 @@ ScriptingGlobals.add(function toggleNoAutoLinkAnchor(checkResult?: boolean) { } if (editorView) RichTextMenu.Instance?.toggleNoAutoLinkAnchor(); }); +ScriptingGlobals.add(function toggleDictation(checkResult?: boolean) { + const textView = RichTextMenu.Instance?.TextView; + if (checkResult) { + return textView?._recording ? Colors.MEDIUM_BLUE : 'transparent'; + } + if (textView) runInAction(() => (textView._recording = !textView._recording)); +}); ScriptingGlobals.add(function toggleBold(checkResult?: boolean) { const editorView = RichTextMenu.Instance?.TextView?.EditorView; diff --git a/src/client/views/nodes/formattedText/DashFieldView.tsx b/src/client/views/nodes/formattedText/DashFieldView.tsx index 8c8b74560..d2f7b5677 100644 --- a/src/client/views/nodes/formattedText/DashFieldView.tsx +++ b/src/client/views/nodes/formattedText/DashFieldView.tsx @@ -216,7 +216,7 @@ export class DashFieldViewInternal extends React.Component<IDashFieldViewInterna onPointerDownLabelSpan = (e: any) => { setupMoveUpEvents(this, e, returnFalse, returnFalse, e => { DashFieldViewMenu.createFieldView = this.createPivotForField; - DashFieldViewMenu.Instance.show(e.clientX, e.clientY + 16); + DashFieldViewMenu.Instance.show(e.clientX, e.clientY + 16, this._fieldKey); }); }; @@ -253,17 +253,21 @@ export class DashFieldViewMenu extends AntimodeMenu<AntimodeMenuProps> { DashFieldViewMenu.Instance.fadeOut(true); }; - public show = (x: number, y: number) => { + @observable _fieldKey = ''; + + @action + public show = (x: number, y: number, fieldKey: string) => { + this._fieldKey = fieldKey; this.jumpTo(x, y, true); const hideMenu = () => { this.fadeOut(true); - document.removeEventListener('pointerdown', hideMenu); + document.removeEventListener('pointerdown', hideMenu, true); }; - document.addEventListener('pointerdown', hideMenu); + document.addEventListener('pointerdown', hideMenu, true); }; render() { const buttons = [ - <Tooltip key="trash" title={<div className="dash-tooltip">{'Remove Link Anchor'}</div>}> + <Tooltip key="trash" title={<div className="dash-tooltip">{`Show Pivot Viewer for '${this._fieldKey}'`}</div>}> <button className="antimodeMenu-button" onPointerDown={this.showFields}> <FontAwesomeIcon icon="eye" size="lg" /> </button> diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index b8ee89ef2..f61533619 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -24,7 +24,7 @@ import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, import { GoogleApiClientUtils, Pulls, Pushes } from '../../../apis/google_docs/GoogleApiClientUtils'; import { DocServer } from '../../../DocServer'; import { Docs, DocUtils } from '../../../documents/Documents'; -import { DocumentType } from '../../../documents/DocumentTypes'; +import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes'; import { DictationManager } from '../../../util/DictationManager'; import { DocumentManager } from '../../../util/DocumentManager'; import { DragManager } from '../../../util/DragManager'; @@ -63,6 +63,7 @@ import applyDevTools = require('prosemirror-dev-tools'); import React = require('react'); import { text } from 'body-parser'; import { CollectionTreeView } from '../../collections/CollectionTreeView'; +import { DocumentViewInternal } from '../DocumentView'; const translateGoogleApi = require('translate-google-api'); export interface FormattedTextBoxProps { @@ -249,6 +250,16 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps !this.layoutDoc.showSidebar && this.toggleSidebar(); this._sidebarRef.current?.anchorMenuClick(this.getAnchor()); }; + AnchorMenu.Instance.OnAudio = (e: PointerEvent) => { + !this.layoutDoc.showSidebar && this.toggleSidebar(); + const anchor = this.getAnchor(); + const target = this._sidebarRef.current?.anchorMenuClick(anchor); + if (target) { + anchor.followLinkAudio = true; + DocumentViewInternal.recordAudioAnnotation(Doc.GetProto(target), Doc.LayoutFieldKey(target)); + target.title = ComputedField.MakeFunction(`self["text-audioAnnotations-text"].lastElement()`); + } + }; AnchorMenu.Instance.Highlight = action((color: string, isLinkButton: boolean) => { this._editorView?.state && RichTextMenu.Instance.setHighlight(color, this._editorView, this._editorView?.dispatch); return undefined; @@ -382,9 +393,9 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps }; autoLink = () => { + const newAutoLinks = new Set<Doc>(); + const oldAutoLinks = DocListCast(this.props.Document.links).filter(link => link.linkRelationship === LinkManager.AutoKeywords); if (this._editorView?.state.doc.textContent) { - const newAutoLinks = new Set<Doc>(); - const oldAutoLinks = DocListCast(this.props.Document.links).filter(link => link.linkRelationship === LinkManager.AutoKeywords); const f = this._editorView.state.selection.from; const t = this._editorView.state.selection.to; var tr = this._editorView.state.tr as any; @@ -393,8 +404,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps DocListCast(Doc.MyPublishedDocs.data).forEach(term => (tr = this.hyperlinkTerm(tr, term, newAutoLinks))); tr = tr.setSelection(new TextSelection(tr.doc.resolve(f), tr.doc.resolve(t))); this._editorView?.dispatch(tr); - oldAutoLinks.filter(oldLink => !newAutoLinks.has(oldLink) && oldLink.anchor2 !== this.rootDoc).forEach(LinkManager.Instance.deleteLink); } + oldAutoLinks.filter(oldLink => !newAutoLinks.has(oldLink) && oldLink.anchor2 !== this.rootDoc).forEach(LinkManager.Instance.deleteLink); }; updateTitle = () => { @@ -426,28 +437,26 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps const editorView = this._editorView; if (editorView && (editorView as any).docView && !Doc.AreProtosEqual(target, this.rootDoc)) { const autoLinkTerm = StrCast(target.title).replace(/^@/, ''); - const flattened1 = this.findInNode(editorView, editorView.state.doc, autoLinkTerm); var alink: Doc | undefined; - flattened1.forEach((flat, i) => { - const flattened = this.findInNode(this._editorView!, this._editorView!.state.doc, autoLinkTerm); - this._searchIndex = ++this._searchIndex > flattened.length - 1 ? 0 : this._searchIndex; + this.findInNode(editorView, editorView.state.doc, autoLinkTerm).forEach(sel => { const splitter = editorView.state.schema.marks.splitter.create({ id: Utils.GenerateGuid() }); - const sel = flattened[i]; - tr = tr.addMark(sel.from, sel.to, splitter); - tr.doc.nodesBetween(sel.from, sel.to, (node: any, pos: number, parent: any) => { - if (node.firstChild === null && !node.marks.find((m: Mark) => m.type.name === schema.marks.noAutoLinkAnchor.name) && node.marks.find((m: Mark) => m.type.name === schema.marks.splitter.name)) { - alink = - alink ?? - (DocListCast(this.Document.links).find(link => Doc.AreProtosEqual(Cast(link.anchor1, Doc, null), this.rootDoc) && Doc.AreProtosEqual(Cast(link.anchor2, Doc, null), target)) || - DocUtils.MakeLink({ doc: this.props.Document }, { doc: target }, LinkManager.AutoKeywords)!); - newAutoLinks.add(alink); - const allAnchors = [{ href: Doc.localServerPath(target), title: 'a link', anchorId: this.props.Document[Id] }]; - allAnchors.push(...(node.marks.find((m: Mark) => m.type.name === schema.marks.autoLinkAnchor.name)?.attrs.allAnchors ?? [])); - const link = editorView.state.schema.marks.autoLinkAnchor.create({ allAnchors, title: 'auto term', location: 'add:right' }); - tr = tr.addMark(pos, pos + node.nodeSize, link); - } - }); - tr = tr.removeMark(sel.from, sel.to, splitter); + if (!sel.$anchor.pos || editorView.state.doc.textBetween(sel.$anchor.pos - 1, sel.$to.pos).trim() === autoLinkTerm) { + tr = tr.addMark(sel.from, sel.to, splitter); + tr.doc.nodesBetween(sel.from, sel.to, (node: any, pos: number, parent: any) => { + if (node.firstChild === null && !node.marks.find((m: Mark) => m.type.name === schema.marks.noAutoLinkAnchor.name) && node.marks.find((m: Mark) => m.type.name === schema.marks.splitter.name)) { + alink = + alink ?? + (DocListCast(this.Document.links).find(link => Doc.AreProtosEqual(Cast(link.anchor1, Doc, null), this.rootDoc) && Doc.AreProtosEqual(Cast(link.anchor2, Doc, null), target)) || + DocUtils.MakeLink({ doc: this.props.Document }, { doc: target }, LinkManager.AutoKeywords)!); + newAutoLinks.add(alink); + const allAnchors = [{ href: Doc.localServerPath(target), title: 'a link', anchorId: this.props.Document[Id] }]; + allAnchors.push(...(node.marks.find((m: Mark) => m.type.name === schema.marks.autoLinkAnchor.name)?.attrs.allAnchors ?? [])); + const link = editorView.state.schema.marks.autoLinkAnchor.create({ allAnchors, title: 'auto term', location: 'add:right' }); + tr = tr.addMark(pos, pos + node.nodeSize, link); + } + }); + tr = tr.removeMark(sel.from, sel.to, splitter); + } }); } return tr; @@ -694,9 +703,9 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps const editor = this._editorView!; const pcords = editor.posAtCoords({ left: e.clientX, top: e.clientY }); - let target = (e.target as any).parentElement; // hrefs are stored on the database of the <a> node that wraps the hyerlink <span> + let target = e.target as any; // hrefs are stored on the database of the <a> node that wraps the hyerlink <span> while (target && !target.dataset?.targethrefs) target = target.parentElement; - if (target) { + if (target && !(e.nativeEvent as any).dash) { const hrefs = (target.dataset?.targethrefs as string) ?.trim() .split(' ') @@ -715,6 +724,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps AnchorMenu.Instance.jumpTo(e.clientX, e.clientY, true); }) ); + e.preventDefault(); e.stopPropagation(); return; } @@ -773,7 +783,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps const uicontrols: ContextMenuProps[] = []; !Doc.noviceMode && uicontrols.push({ description: `${FormattedTextBox._canAnnotate ? "Don't" : ''} Show Menu on Selections`, event: () => (FormattedTextBox._canAnnotate = !FormattedTextBox._canAnnotate), icon: 'expand-arrows-alt' }); uicontrols.push({ description: !this.Document._noSidebar ? 'Hide Sidebar Handle' : 'Show Sidebar Handle', event: () => (this.layoutDoc._noSidebar = !this.layoutDoc._noSidebar), icon: 'expand-arrows-alt' }); - uicontrols.push({ description: `${this.layoutDoc._showAudio ? 'Hide' : 'Show'} Dictation Icon`, event: () => (this.layoutDoc._showAudio = !this.layoutDoc._showAudio), icon: 'expand-arrows-alt' }); uicontrols.push({ description: 'Show Highlights...', noexpand: true, subitems: highlighting, icon: 'hand-point-right' }); !Doc.noviceMode && uicontrols.push({ @@ -855,23 +864,28 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps setDictationContent = (value: string) => { if (this._editorView && this._recordingStart) { if (this._break) { - const textanchor = Docs.Create.TextanchorDocument({ title: 'dictation anchor' }); - this.addDocument(textanchor); - const link = DocUtils.MakeLinkToActiveAudio(() => textanchor, false).lastElement(); - link && (Doc.GetProto(link).isDictation = true); - if (!link) return; - const audioanchor = Cast(link.anchor2, Doc, null); - if (!audioanchor) return; - audioanchor.backgroundColor = 'tan'; - const audiotag = this._editorView.state.schema.nodes.audiotag.create({ - timeCode: NumCast(audioanchor._timecodeToShow), - audioId: audioanchor[Id], - textId: textanchor[Id], - }); - Doc.GetProto(textanchor).title = 'dictation:' + audiotag.attrs.timeCode; - const tr = this._editorView.state.tr.insert(this._editorView.state.doc.content.size, audiotag); - const tr2 = tr.setSelection(TextSelection.create(tr.doc, tr.doc.content.size)); - this._editorView.dispatch(tr.setSelection(TextSelection.create(tr2.doc, tr2.doc.content.size))); + const textanchorFunc = () => { + const tanch = Docs.Create.TextanchorDocument({ title: 'dictation anchor' }); + return this.addDocument(tanch) ? tanch : undefined; + }; + const link = DocUtils.MakeLinkToActiveAudio(textanchorFunc, false).lastElement(); + if (link) { + Doc.GetProto(link).isDictation = true; + const audioanchor = Cast(link.anchor2, Doc, null); + const textanchor = Cast(link.anchor1, Doc, null); + if (audioanchor) { + audioanchor.backgroundColor = 'tan'; + const audiotag = this._editorView.state.schema.nodes.audiotag.create({ + timeCode: NumCast(audioanchor._timecodeToShow), + audioId: audioanchor[Id], + textId: textanchor[Id], + }); + Doc.GetProto(textanchor).title = 'dictation:' + audiotag.attrs.timeCode; + const tr = this._editorView.state.tr.insert(this._editorView.state.doc.content.size, audiotag); + const tr2 = tr.setSelection(TextSelection.create(tr.doc, tr.doc.content.size)); + this._editorView.dispatch(tr.setSelection(TextSelection.create(tr2.doc, tr2.doc.content.size))); + } + } } const from = this._editorView.state.selection.from; this._break = false; @@ -1001,7 +1015,9 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps () => ({ sidebarHeight: this.sidebarHeight, textHeight: this.textHeight, autoHeight: this.autoHeight, marginsHeight: this.autoHeightMargins }), ({ sidebarHeight, textHeight, autoHeight, marginsHeight }) => { const newHeight = this.contentScaling * (marginsHeight + Math.max(sidebarHeight, textHeight)); - autoHeight && newHeight && this.props.setHeight?.(newHeight); + if (autoHeight && newHeight && newHeight !== this.rootDoc.height) { + this.props.setHeight?.(newHeight); + } }, { fireImmediately: true } ); @@ -1459,14 +1475,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps document.removeEventListener('pointermove', this.onSelectMove); }; onPointerUp = (e: React.PointerEvent): void => { - if (!this._editorView?.state.selection.empty && FormattedTextBox._canAnnotate) this.setupAnchorMenu(); + if (!this._editorView?.state.selection.empty && FormattedTextBox._canAnnotate && !(e.nativeEvent as any).dash) this.setupAnchorMenu(); if (!this._downEvent) return; this._downEvent = false; - if (this.props.isContentActive(true)) { + if (this.props.isContentActive(true) && !(e.nativeEvent as any).dash) { const editor = this._editorView!; const pcords = editor.posAtCoords({ left: e.clientX, top: e.clientY }); !this.props.isSelected(true) && editor.dispatch(editor.state.tr.setSelection(new TextSelection(editor.state.doc.resolve(pcords?.pos || 0)))); - let target = (e.target as any).parentElement; // hrefs are stored on the database of the <a> node that wraps the hyerlink <span> + let target = e.target as any; // hrefs are stored on the dataset of the <a> node that wraps the hyerlink <span> while (target && !target.dataset?.targethrefs) target = target.parentElement; FormattedTextBoxComment.update(this, editor, undefined, target?.dataset?.targethrefs, target?.dataset.linkdoc); } @@ -1695,7 +1711,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps if (this.props.setHeight && scrollHeight && this.props.renderDepth && !this.props.dontRegisterView) { // if top === 0, then the text box is growing upward (as the overlay caption) which doesn't contribute to the height computation const setScrollHeight = () => (this.rootDoc[this.fieldKey + '-scrollHeight'] = scrollHeight); - if (this.rootDoc === this.layoutDoc.doc || this.layoutDoc.resolvedDataDoc) { + if (this.rootDoc === this.layoutDoc || this.layoutDoc.resolvedDataDoc) { setScrollHeight(); } else { setTimeout(setScrollHeight, 10); // if we have a template that hasn't been resolved yet, we can't set the height or we'd be setting it on the unresolved template. So set a timeout and hope its arrived... @@ -1722,9 +1738,19 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps .scale(1 / NumCast(this.layoutDoc._viewScale, 1) / (this.props.NativeDimScaling?.() || 1)); @computed get audioHandle() { - return ( - <div className="formattedTextBox-dictation" onClick={action(e => (this._recording = !this._recording))}> - <FontAwesomeIcon className="formattedTextBox-audioFont" style={{ color: this._recording ? 'red' : 'blue', transitionDelay: '0.6s', opacity: this._recording ? 1 : 0.25 }} icon={'microphone'} size="sm" /> + return !this._recording ? null : ( + <div + className="formattedTextBox-dictation" + onPointerDown={e => + setupMoveUpEvents( + this, + e, + returnFalse, + emptyFunction, + action(e => (this._recording = !this._recording)) + ) + }> + <FontAwesomeIcon className="formattedTextBox-audioFont" style={{ color: 'red' }} icon={'microphone'} size="sm" /> </div> ); } @@ -1749,7 +1775,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps } @computed get sidebarCollection() { const renderComponent = (tag: string) => { - const ComponentTag = tag === 'freeform' ? CollectionFreeFormView : tag === 'tree' ? CollectionTreeView : tag === 'translation' ? FormattedTextBox : CollectionStackingView; + const ComponentTag = tag === CollectionViewType.Freeform ? CollectionFreeFormView : tag === CollectionViewType.Tree ? CollectionTreeView : tag === 'translation' ? FormattedTextBox : CollectionStackingView; return ComponentTag === CollectionStackingView ? ( <SidebarAnnos ref={this._sidebarRef} @@ -1770,7 +1796,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps /> ) : ( <div onPointerDown={e => setupMoveUpEvents(this, e, returnFalse, emptyFunction, () => SelectionManager.SelectView(this.props.DocumentView?.()!, false), true)}> - //@ts-ignore <ComponentTag {...OmitKeys(this.props, ['NativeWidth', 'NativeHeight', 'setContentView']).omit} NativeWidth={returnZero} @@ -1859,7 +1884,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps style={{ width: this.props.dontSelectOnLoad ? '100%' : `calc(100% - ${this.sidebarWidthPercent})`, pointerEvents: !active && !SnappingManager.GetIsDragging() ? 'none' : undefined, - overflow: this.layoutDoc._singleLine ? 'hidden' : undefined, + overflow: this.layoutDoc._singleLine ? 'hidden' : this.layoutDoc._autoHeight ? 'visible' : undefined, }} onScroll={this.onScroll} onDrop={this.ondrop}> @@ -1878,7 +1903,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps </div> {this.noSidebar || this.props.dontSelectOnLoad || !this.SidebarShown || this.sidebarWidthPercent === '0%' ? null : this.sidebarCollection} {this.noSidebar || this.Document._noSidebar || this.props.dontSelectOnLoad || this.Document._singleLine ? null : this.sidebarHandle} - {!this.layoutDoc._showAudio ? null : this.audioHandle} + {this.audioHandle} </div> </div> ); diff --git a/src/client/views/nodes/formattedText/RichTextMenu.tsx b/src/client/views/nodes/formattedText/RichTextMenu.tsx index 21326efaa..2a77210ae 100644 --- a/src/client/views/nodes/formattedText/RichTextMenu.tsx +++ b/src/client/views/nodes/formattedText/RichTextMenu.tsx @@ -60,7 +60,6 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> { @observable private showLinkDropdown: boolean = false; _reaction: IReactionDisposer | undefined; - _delayHide = false; constructor(props: Readonly<{}>) { super(props); runInAction(() => { @@ -70,16 +69,6 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> { }); } - componentDidMount() { - this._reaction = reaction( - () => SelectionManager.Views(), - () => this._delayHide && !(this._delayHide = false) && this.fadeOut(true) - ); - } - componentWillUnmount() { - this._reaction?.(); - } - @computed get noAutoLink() { return this._noLinkActive; } @@ -108,8 +97,6 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> { return this._activeAlignment; } - public delayHide = () => (this._delayHide = true); - @action public updateMenu(view: EditorView | undefined, lastState: EditorState | undefined, props: any) { if (this._linkToRef.current?.getBoundingClientRect().width) { diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx index 1a1120b6c..ee2ae10a7 100644 --- a/src/client/views/pdf/AnchorMenu.tsx +++ b/src/client/views/pdf/AnchorMenu.tsx @@ -49,6 +49,7 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { public OnCrop: (e: PointerEvent) => void = unimplementedFunction; public OnClick: (e: PointerEvent) => void = unimplementedFunction; + public OnAudio: (e: PointerEvent) => void = unimplementedFunction; public StartDrag: (e: PointerEvent, ele: HTMLElement) => void = unimplementedFunction; public StartCropDrag: (e: PointerEvent, ele: HTMLElement) => void = unimplementedFunction; public Highlight: (color: string, isPushpin: boolean) => Opt<Doc> = (color: string, isPushpin: boolean) => undefined; @@ -92,6 +93,10 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { ); }; + audioDown = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, returnFalse, returnFalse, e => this.OnAudio?.(e)); + }; + cropDown = (e: React.PointerEvent) => { setupMoveUpEvents( this, @@ -196,6 +201,15 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { <FontAwesomeIcon icon="comment-alt" size="lg" /> </button> </Tooltip>, + AnchorMenu.Instance.OnAudio === unimplementedFunction ? ( + <></> + ) : ( + <Tooltip key="annoaudiotate" title={<div className="dash-tooltip">{'Click to Record Annotation'}</div>}> + <button className="antimodeMenu-button annotate" onPointerDown={this.audioDown} style={{ cursor: 'grab' }}> + <FontAwesomeIcon icon="microphone" size="lg" /> + </button> + </Tooltip> + ), //NOTE: link popup is currently in progress <Tooltip key="link" title={<div className="dash-tooltip">{'Find document to link to selected text'}</div>}> <button className="antimodeMenu-button link" onPointerDown={this.toggleLinkPopup} style={{}}> diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts index 0c7504913..8d56ebf8c 100644 --- a/src/fields/Doc.ts +++ b/src/fields/Doc.ts @@ -211,6 +211,7 @@ export class Doc extends RefField { } public static set ActivePage(val) { Doc.UserDoc().activePage = val; + DocServer.UPDATE_SERVER_CACHE(); } public static get ActiveDashboard() { return DocCast(Doc.UserDoc().activeDashboard); @@ -228,7 +229,7 @@ export class Doc extends RefField { return DocCast(Doc.UserDoc().activePresentation); } public static set ActivePresentation(val) { - Doc.UserDoc().activePresentation = val; + Doc.UserDoc().activePresentation = new PrefetchProxy(val); } constructor(id?: FieldId, forceSave?: boolean) { super(id); diff --git a/src/fields/List.ts b/src/fields/List.ts index b15548327..5cc4ca543 100644 --- a/src/fields/List.ts +++ b/src/fields/List.ts @@ -1,25 +1,25 @@ -import { action, observable } from "mobx"; -import { alias, list, serializable } from "serializr"; -import { DocServer } from "../client/DocServer"; -import { ScriptingGlobals } from "../client/util/ScriptingGlobals"; -import { afterDocDeserialize, autoObject, Deserializable } from "../client/util/SerializationHelper"; -import { Field } from "./Doc"; -import { Copy, OnUpdate, Parent, Self, SelfProxy, ToScriptString, ToString, Update } from "./FieldSymbols"; -import { ObjectField } from "./ObjectField"; -import { ProxyField } from "./Proxy"; -import { RefField } from "./RefField"; -import { listSpec } from "./Schema"; -import { Cast } from "./Types"; -import { deleteProperty, getter, setter, updateFunction } from "./util"; +import { action, observable } from 'mobx'; +import { alias, list, serializable } from 'serializr'; +import { DocServer } from '../client/DocServer'; +import { ScriptingGlobals } from '../client/util/ScriptingGlobals'; +import { afterDocDeserialize, autoObject, Deserializable } from '../client/util/SerializationHelper'; +import { Field } from './Doc'; +import { Copy, OnUpdate, Parent, Self, SelfProxy, ToScriptString, ToString, Update } from './FieldSymbols'; +import { ObjectField } from './ObjectField'; +import { ProxyField } from './Proxy'; +import { RefField } from './RefField'; +import { listSpec } from './Schema'; +import { Cast } from './Types'; +import { deleteProperty, getter, setter, updateFunction } from './util'; const listHandlers: any = { /// Mutator methods copyWithin() { - throw new Error("copyWithin not supported yet"); + throw new Error('copyWithin not supported yet'); }, fill(value: any, start?: number, end?: number) { if (value instanceof RefField) { - throw new Error("fill with RefFields not supported yet"); + throw new Error('fill with RefFields not supported yet'); } const res = this[Self].__fields.fill(value, start, end); this[Update](); @@ -44,7 +44,7 @@ const listHandlers: any = { } } const res = list.__fields.push(...items); - this[Update]({ op: "$addToSet", items, length: length + items.length }); + this[Update]({ op: '$addToSet', items, length: length + items.length }); return res; }), reverse() { @@ -78,8 +78,13 @@ const listHandlers: any = { } } const res = list.__fields.splice(start, deleteCount, ...items); - this[Update](items.length === 0 && deleteCount ? { op: "$remFromSet", items: removed, length: list.__fields.length } : - items.length && !deleteCount && start === list.__fields.length ? { op: "$addToSet", items, length: list.__fields.length } : undefined); + this[Update]( + items.length === 0 && deleteCount + ? { op: '$remFromSet', items: removed, length: list.__fields.length } + : items.length && !deleteCount && start === list.__fields.length + ? { op: '$addToSet', items, length: list.__fields.length } + : undefined + ); return res.map(toRealField); }), unshift(...items: any[]) { @@ -98,7 +103,6 @@ const listHandlers: any = { const res = this[Self].__fields.unshift(...items); this[Update](); return res; - }, /// Accessor methods concat: action(function (this: any, ...items: any[]) { @@ -198,7 +202,7 @@ const listHandlers: any = { }, [Symbol.iterator]() { return this[Self].__realFields().values(); - } + }, }; function toObjectField(field: Field) { @@ -217,14 +221,14 @@ function listGetter(target: any, prop: string | number | symbol, receiver: any): } interface ListSpliceUpdate<T> { - type: "splice"; + type: 'splice'; index: number; added: T[]; removedCount: number; } interface ListIndexUpdate<T> { - type: "update"; + type: 'update'; index: number; newValue: T; } @@ -233,7 +237,7 @@ type ListUpdate<T> = ListSpliceUpdate<T> | ListIndexUpdate<T>; type StoredType<T extends Field> = T extends RefField ? ProxyField<T> : T; -@Deserializable("list") +@Deserializable('list') class ListImpl<T extends Field> extends ObjectField { constructor(fields?: T[]) { super(); @@ -244,14 +248,16 @@ class ListImpl<T extends Field> extends ObjectField { getOwnPropertyDescriptor: (target, prop) => { if (prop in target.__fields) { return { - configurable: true,//TODO Should configurable be true? + configurable: true, //TODO Should configurable be true? enumerable: true, }; } return Reflect.getOwnPropertyDescriptor(target, prop); }, deleteProperty: deleteProperty, - defineProperty: () => { throw new Error("Currently properties can't be defined on documents using Object.defineProperty"); }, + defineProperty: () => { + throw new Error("Currently properties can't be defined on documents using Object.defineProperty"); + }, }); this[SelfProxy] = list; if (fields) { @@ -265,7 +271,7 @@ class ListImpl<T extends Field> extends ObjectField { // this requests all ProxyFields at the same time to avoid the overhead // of separate network requests and separate updates to the React dom. private __realFields() { - const promised = this.__fields.filter(f => f instanceof ProxyField && f.promisedValue()).map(f => ({ field: f as any, promisedFieldId: (f instanceof ProxyField) ? f.promisedValue() : "" })); + const promised = this.__fields.filter(f => f instanceof ProxyField && f.promisedValue()).map(f => ({ field: f as any, promisedFieldId: f instanceof ProxyField ? f.promisedValue() : '' })); // if we find any ProxyFields that don't have a current value, then // start the server request for all of them if (promised.length) { @@ -282,7 +288,7 @@ class ListImpl<T extends Field> extends ObjectField { return this.__fields.map(toRealField); } - @serializable(alias("fields", list(autoObject(), { afterDeserialize: afterDocDeserialize }))) + @serializable(alias('fields', list(autoObject(), { afterDeserialize: afterDocDeserialize }))) private get __fields() { return this.___fields; } @@ -299,7 +305,7 @@ class ListImpl<T extends Field> extends ObjectField { } [Copy]() { - const copiedData = this[Self].__fields.map(f => f instanceof ObjectField ? f[Copy]() : f); + const copiedData = this[Self].__fields.map(f => (f instanceof ObjectField ? f[Copy]() : f)); const deepCopy = new ListImpl<T>(copiedData as any); return deepCopy; } @@ -313,7 +319,7 @@ class ListImpl<T extends Field> extends ObjectField { const update = this[OnUpdate]; // update && update(diff); update?.(diff); - } + }; private [Self] = this; private [SelfProxy]: any; @@ -328,9 +334,9 @@ class ListImpl<T extends Field> extends ObjectField { export type List<T extends Field> = ListImpl<T> & (T | (T extends RefField ? Promise<T> : never))[]; export const List: { new <T extends Field>(fields?: T[]): List<T> } = ListImpl as any; -ScriptingGlobals.add("List", List); +ScriptingGlobals.add('List', List); ScriptingGlobals.add(function compareLists(l1: any, l2: any) { - const L1 = Cast(l1, listSpec("string"), []); - const L2 = Cast(l2, listSpec("string"), []); + const L1 = Cast(l1, listSpec('string'), []); + const L2 = Cast(l2, listSpec('string'), []); return !L1 && !L2 ? true : L1 && L2 && L1.length === L2.length && L2.reduce((p, v) => p && L1.includes(v), true); -}, "compare two lists");
\ No newline at end of file +}, 'compare two lists'); diff --git a/src/fields/documentSchemas.ts b/src/fields/documentSchemas.ts index be39e0709..24b5a359d 100644 --- a/src/fields/documentSchemas.ts +++ b/src/fields/documentSchemas.ts @@ -1,116 +1,114 @@ -import { makeInterface, createSchema, listSpec } from "./Schema"; -import { ScriptField } from "./ScriptField"; -import { Doc } from "./Doc"; -import { DateField } from "./DateField"; -import { SchemaHeaderField } from "./SchemaHeaderField"; +import { makeInterface, createSchema, listSpec } from './Schema'; +import { ScriptField } from './ScriptField'; +import { Doc } from './Doc'; +import { DateField } from './DateField'; +import { SchemaHeaderField } from './SchemaHeaderField'; export const documentSchema = createSchema({ // content properties - type: "string", // enumerated type of document -- should be template-specific (ie, start with an '_') - title: "string", // document title (can be on either data document or layout) - isTemplateForField: "string",// if specified, it indicates the document is a template that renders the specified field - creationDate: DateField, // when the document was created - links: listSpec(Doc), // computed (readonly) list of links associated with this document + type: 'string', // enumerated type of document -- should be template-specific (ie, start with an '_') + title: 'string', // document title (can be on either data document or layout) + isTemplateForField: 'string', // if specified, it indicates the document is a template that renders the specified field + creationDate: DateField, // when the document was created + links: listSpec(Doc), // computed (readonly) list of links associated with this document // "Location" properties in a very general sense - _curPage: "number", // current page of a page based document - _currentFrame: "number", // current frame of a frame based collection (e.g., a progressive slide) - lastFrame: "number", // last frame of a frame based collection (e.g., a progressive slide) - activeFrame: "number", // the active frame of a frame based animated document - _currentTimecode: "number", // current play back time of a temporal document (video / audio) - _timecodeToShow: "number", // the time that a document should be displayed (e.g., time an annotation should be displayed on a video) - _timecodeToHIde: "number", // the time that a document should be hidden - markers: listSpec(Doc), // list of markers for audio / video - x: "number", // x coordinate when in a freeform view - y: "number", // y coordinate when in a freeform view - z: "number", // z "coordinate" - non-zero specifies the overlay layer of a freeformview - zIndex: "number", // zIndex of a document in a freeform view - _scrollTop: "number", // scroll position of a scrollable document (pdf, text, web) - lat: "number", - lng: "number", + _curPage: 'number', // current page of a page based document + _currentFrame: 'number', // current frame of a frame based collection (e.g., a progressive slide) + lastFrame: 'number', // last frame of a frame based collection (e.g., a progressive slide) + activeFrame: 'number', // the active frame of a frame based animated document + _currentTimecode: 'number', // current play back time of a temporal document (video / audio) + _timecodeToShow: 'number', // the time that a document should be displayed (e.g., time an annotation should be displayed on a video) + _timecodeToHIde: 'number', // the time that a document should be hidden + markers: listSpec(Doc), // list of markers for audio / video + x: 'number', // x coordinate when in a freeform view + y: 'number', // y coordinate when in a freeform view + z: 'number', // z "coordinate" - non-zero specifies the overlay layer of a freeformview + zIndex: 'number', // zIndex of a document in a freeform view + _scrollTop: 'number', // scroll position of a scrollable document (pdf, text, web) + lat: 'number', + lng: 'number', // appearance properties on the layout - "_backgroundGrid-spacing": "number", // the size of the grid for collection views - _autoHeight: "boolean", // whether the height of the document should be computed automatically based on its contents - _nativeWidth: "number", // native width of document which determines how much document contents are scaled when the document's width is set - _nativeHeight: "number", // " - _width: "number", // width of document in its container's coordinate system - _height: "number", // " - _xPadding: "number", // pixels of padding on left/right of collectionfreeformview contents when fitContentsToBox is set - _yPadding: "number", // pixels of padding on top/bottom of collectionfreeformview contents when fitContentsToBox is set - _xMargin: "number", // margin added on left/right of most documents to add separation from their container - _yMargin: "number", // margin added on top/bottom of most documents to add separation from their container - _overflow: "string", // sets overflow behvavior for CollectionFreeForm views - _showCaption: "string", // whether editable caption text is overlayed at the bottom of the document - _showTitle: "string", // the fieldkey(s) whose contents should be displayed at the top of the document. separate multiple keys with ";". Use :hover suffix to indicate title should be shown on hover - _showAudio: "boolean", // whether to show the audio record icon on documents - _pivotField: "string", // specifies which field key should be used as the timeline/pivot axis - _columnsFill: "boolean", // whether documents in a stacking view column should be sized to fill the column - _columnsSort: "string", // how a document should be sorted "ascending", "descending", undefined (none) - _columnsHideIfEmpty: "boolean", // whether empty stacking view column headings should be hidden + '_backgroundGrid-spacing': 'number', // the size of the grid for collection views + _autoHeight: 'boolean', // whether the height of the document should be computed automatically based on its contents + _nativeWidth: 'number', // native width of document which determines how much document contents are scaled when the document's width is set + _nativeHeight: 'number', // " + _width: 'number', // width of document in its container's coordinate system + _height: 'number', // " + _xPadding: 'number', // pixels of padding on left/right of collectionfreeformview contents when fitContentsToBox is set + _yPadding: 'number', // pixels of padding on top/bottom of collectionfreeformview contents when fitContentsToBox is set + _xMargin: 'number', // margin added on left/right of most documents to add separation from their container + _yMargin: 'number', // margin added on top/bottom of most documents to add separation from their container + _overflow: 'string', // sets overflow behvavior for CollectionFreeForm views + _showCaption: 'string', // whether editable caption text is overlayed at the bottom of the document + _showTitle: 'string', // the fieldkey(s) whose contents should be displayed at the top of the document. separate multiple keys with ";". Use :hover suffix to indicate title should be shown on hover + _pivotField: 'string', // specifies which field key should be used as the timeline/pivot axis + _columnsFill: 'boolean', // whether documents in a stacking view column should be sized to fill the column + _columnsSort: 'string', // how a document should be sorted "ascending", "descending", undefined (none) + _columnsHideIfEmpty: 'boolean', // whether empty stacking view column headings should be hidden _columnHeaders: listSpec(SchemaHeaderField), // header descriptions for stacking/masonry _schemaHeaders: listSpec(SchemaHeaderField), // header descriptions for schema views - _fontSize: "string", - _fontFamily: "string", - _sidebarWidthPercent: "string", // percent of text window width taken up by sidebar + _fontSize: 'string', + _fontFamily: 'string', + _sidebarWidthPercent: 'string', // percent of text window width taken up by sidebar // appearance properties on the data document - backgroundColor: "string", // background color of document - borderRounding: "string", // border radius rounding of document - boxShadow: "string", // the amount of shadow around the perimeter of a document - color: "string", // foreground color of document - fitContentsToBox: "boolean",// whether freeform view contents should be zoomed/panned to fill the area of the document view box - fontSize: "string", - hidden: "boolean", // whether a document should not be displayed - isInkMask: "boolean", // is the document a mask (ie, sits on top of other documents, has an unbounded width/height that is dark, and content uses 'hard-light' mix-blend-mode to let other documents pop through) - layout: "string", // this is the native layout string for the document. templates can be added using other fields and setting layoutKey below - layoutKey: "string", // holds the field key for the field that actually holds the current lyoat - letterSpacing: "string", - opacity: "number", // opacity of document - strokeWidth: "number", - strokeBezier: "number", - strokeStartMarker: "string", - strokeEndMarker: "string", - strokeDash: "string", - textTransform: "string", - treeViewOpen: "boolean", // flag denoting whether the documents sub-tree (contents) is visible or hidden - treeViewExpandedView: "string", // name of field whose contents are being displayed as the document's subtree - treeViewExpandedViewLock: "boolean", // whether the expanded view can be changed - treeViewOpenIsTransient: "boolean", // ignores the treeViewOpen flag (for allowing a view to not be slaved to other views of the document) - treeViewType: "string", // whether tree view is an outline, file syste or (default) hierarchy. For outline, clicks edit document titles immediately since double-click opening is turned off + backgroundColor: 'string', // background color of document + borderRounding: 'string', // border radius rounding of document + boxShadow: 'string', // the amount of shadow around the perimeter of a document + color: 'string', // foreground color of document + fitContentsToBox: 'boolean', // whether freeform view contents should be zoomed/panned to fill the area of the document view box + fontSize: 'string', + hidden: 'boolean', // whether a document should not be displayed + isInkMask: 'boolean', // is the document a mask (ie, sits on top of other documents, has an unbounded width/height that is dark, and content uses 'hard-light' mix-blend-mode to let other documents pop through) + layout: 'string', // this is the native layout string for the document. templates can be added using other fields and setting layoutKey below + layoutKey: 'string', // holds the field key for the field that actually holds the current lyoat + letterSpacing: 'string', + opacity: 'number', // opacity of document + strokeWidth: 'number', + strokeBezier: 'number', + strokeStartMarker: 'string', + strokeEndMarker: 'string', + strokeDash: 'string', + textTransform: 'string', + treeViewOpen: 'boolean', // flag denoting whether the documents sub-tree (contents) is visible or hidden + treeViewExpandedView: 'string', // name of field whose contents are being displayed as the document's subtree + treeViewExpandedViewLock: 'boolean', // whether the expanded view can be changed + treeViewOpenIsTransient: 'boolean', // ignores the treeViewOpen flag (for allowing a view to not be slaved to other views of the document) + treeViewType: 'string', // whether tree view is an outline, file syste or (default) hierarchy. For outline, clicks edit document titles immediately since double-click opening is turned off // interaction and linking properties - ignoreClick: "boolean", // whether documents ignores input clicks (but does not ignore manipulation and other events) - onClick: ScriptField, // script to run when document is clicked (can be overriden by an onClick prop) + ignoreClick: 'boolean', // whether documents ignores input clicks (but does not ignore manipulation and other events) + onClick: ScriptField, // script to run when document is clicked (can be overriden by an onClick prop) onPointerDown: ScriptField, // script to run when document is clicked (can be overriden by an onClick prop) - onPointerUp: ScriptField, // script to run when document is clicked (can be overriden by an onClick prop) - onDragStart: ScriptField, // script to run when document is dragged (without being selected). the script should return the Doc to be dropped. - followLinkLocation: "string",// flag for where to place content when following a click interaction (e.g., add:right, inPlace, default, ) - hideLinkButton: "boolean", // whether the blue link counter button should be hidden - hideAllLinks: "boolean", // whether all individual blue anchor dots should be hidden - linkDisplay: "boolean", // whether a link connection should be shown between link anchor endpoints. - isInPlaceContainer: "boolean",// whether the marked object will display addDocTab() calls that target "inPlace" 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 - displayArrow: "boolean", // toggles directed arrows + onPointerUp: ScriptField, // script to run when document is clicked (can be overriden by an onClick prop) + onDragStart: ScriptField, // script to run when document is dragged (without being selected). the script should return the Doc to be dropped. + followLinkLocation: 'string', // flag for where to place content when following a click interaction (e.g., add:right, inPlace, default, ) + hideLinkButton: 'boolean', // whether the blue link counter button should be hidden + hideAllLinks: 'boolean', // whether all individual blue anchor dots should be hidden + linkDisplay: 'boolean', // whether a link connection should be shown between link anchor endpoints. + isInPlaceContainer: 'boolean', // whether the marked object will display addDocTab() calls that target "inPlace" 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 + displayArrow: 'boolean', // toggles directed arrows // drag drop properties - _stayInCollection: "boolean",// whether document can be dropped into a different collection - dragFactory: Doc, // the document that serves as the "template" for the onDragStart script. ie, to drag out copies of the dragFactory document. - dropAction: "string", // override specifying what should happen when this document is dropped (can be "alias", "copy", "move") - targetDropAction: "string", // allows the target of a drop event to specify the dropAction ("alias", "copy", "move") NOTE: if the document is dropped within the same collection, the dropAction is coerced to 'move' - childDropAction: "string", // specify the override for what should happen when the child of a collection is dragged from it and dropped (can be "alias" or "copy") - removeDropProperties: listSpec("string"), // properties that should be removed from the alias/copy/etc of this document when it is dropped + _stayInCollection: 'boolean', // whether document can be dropped into a different collection + dragFactory: Doc, // the document that serves as the "template" for the onDragStart script. ie, to drag out copies of the dragFactory document. + dropAction: 'string', // override specifying what should happen when this document is dropped (can be "alias", "copy", "move") + targetDropAction: 'string', // allows the target of a drop event to specify the dropAction ("alias", "copy", "move") NOTE: if the document is dropped within the same collection, the dropAction is coerced to 'move' + childDropAction: 'string', // specify the override for what should happen when the child of a collection is dragged from it and dropped (can be "alias" or "copy") + removeDropProperties: listSpec('string'), // properties that should be removed from the alias/copy/etc of this document when it is dropped }); - export const collectionSchema = createSchema({ childLayoutTemplate: Doc, // layout template to use to render children of a collecion - childLayoutString: "string", //layout string to use to render children of a collection + childLayoutString: 'string', //layout string to use to render children of a collection childClickedOpenTemplateView: Doc, // layout template to apply to a child when its clicked on in a collection and opened (requires onChildClick or other script to read this value and apply template) - childDontRegisterViews: "boolean", // whether views made of this document are registered so that they can be found when drawing links + childDontRegisterViews: 'boolean', // whether views made of this document are registered so that they can be found when drawing links onChildClick: ScriptField, // script to run for each child when its clicked onChildDoubleClick: ScriptField, // script to run for each child when its clicked onCheckedClick: ScriptField, // script to run when a checkbox is clicked next to a child in a tree view diff --git a/src/fields/util.ts b/src/fields/util.ts index 41e723119..d87bb6656 100644 --- a/src/fields/util.ts +++ b/src/fields/util.ts @@ -33,6 +33,7 @@ import { ObjectField } from './ObjectField'; import { PrefetchProxy, ProxyField } from './Proxy'; import { RefField } from './RefField'; import { RichTextField } from './RichTextField'; +import { SchemaHeaderField } from './SchemaHeaderField'; import { ComputedField } from './ScriptField'; import { ScriptCast, StrCast } from './Types'; @@ -455,7 +456,7 @@ export function updateFunction(target: any, prop: any, value: any, receiver: any ? { redo: action(() => { diff.items.forEach((item: any) => { - const ind = receiver[prop].indexOf(item.value ? item.value() : item); + const ind = item instanceof SchemaHeaderField ? receiver[prop].findIndex((ele: any) => ele instanceof SchemaHeaderField && ele.heading === item.heading) : receiver[prop].indexOf(item.value ? item.value() : item); ind !== -1 && receiver[prop].splice(ind, 1); }); lastValue = ObjectField.MakeCopy(receiver[prop]); @@ -463,8 +464,13 @@ export function updateFunction(target: any, prop: any, value: any, receiver: any undo: () => { // console.log("undo $rem: " + prop, diff.items) // bcz: uncomment to log undo diff.items.forEach((item: any) => { - const ind = (prevValue as List<any>).indexOf(item.value ? item.value() : item); - ind !== -1 && receiver[prop].indexOf(item.value ? item.value() : item) === -1 && receiver[prop].splice(ind, 0, item); + if (item instanceof SchemaHeaderField) { + const ind = (prevValue as List<any>).findIndex((ele: any) => ele instanceof SchemaHeaderField && ele.heading === item.heading); + ind !== -1 && receiver[prop].findIndex((ele: any) => ele instanceof SchemaHeaderField && ele.heading === item.heading) === -1 && receiver[prop].splice(ind, 0, item); + } else { + const ind = (prevValue as List<any>).indexOf(item.value ? item.value() : item); + ind !== -1 && receiver[prop].indexOf(item.value ? item.value() : item) === -1 && receiver[prop].splice(ind, 0, item); + } }); lastValue = ObjectField.MakeCopy(receiver[prop]); }, diff --git a/webpack.config.js b/webpack.config.js index 5a954db19..01625988c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,38 +1,38 @@ -var path = require('path'); -var webpack = require('webpack'); -const CopyWebpackPlugin = require("copy-webpack-plugin"); +const path = require('path'); +const webpack = require('webpack'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); -const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin"); +const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); const plugins = [ - new CopyWebpackPlugin([{ - from: "deploy", - to: path.join(__dirname, "build") - }]), + new CopyWebpackPlugin([ + { + from: 'deploy', + to: path.join(__dirname, 'build'), + }, + ]), new HtmlWebpackPlugin({ title: 'Caching', }), new ForkTsCheckerWebpackPlugin({ tslint: true, // memoryLimit: 4096, - useTypescriptIncrementalApi: true + useTypescriptIncrementalApi: true, }), - new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'], }), - new webpack.ProvidePlugin({ process: 'process/browser', }), + new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'] }), + new webpack.ProvidePlugin({ process: 'process/browser' }), new webpack.HotModuleReplacementPlugin(), ]; function transferEnvironmentVariables() { - const prefix = "_CLIENT_"; - const { - parsed - } = require('dotenv').config(); + const prefix = '_CLIENT_'; + const { parsed } = require('dotenv').config(); if (!parsed) { return; } const resolvedClientSide = Object.keys(parsed).reduce((mapping, envKey) => { if (envKey.startsWith(prefix)) { - mapping[`process.env.${envKey.replace(prefix, "")}`] = JSON.stringify(parsed[envKey]); + mapping[`process.env.${envKey.replace(prefix, '')}`] = JSON.stringify(parsed[envKey]); } return mapping; }, {}); @@ -44,18 +44,18 @@ transferEnvironmentVariables(); module.exports = { mode: 'development', entry: { - bundle: ["./src/client/views/Main.tsx", 'webpack-hot-middleware/client?reload=true'], - viewer: ["./src/debug/Viewer.tsx", 'webpack-hot-middleware/client?reload=true'], - repl: ["./src/debug/Repl.tsx", 'webpack-hot-middleware/client?reload=true'], - test: ["./src/debug/Test.tsx", 'webpack-hot-middleware/client?reload=true'], - inkControls: ["./src/mobile/InkControls.tsx", 'webpack-hot-middleware/client?reload=true'], - mobileInterface: ["./src/client/views/Main.tsx", 'webpack-hot-middleware/client?reload=true'], + bundle: ['./src/client/views/Main.tsx', 'webpack-hot-middleware/client?reload=true'], + viewer: ['./src/debug/Viewer.tsx', 'webpack-hot-middleware/client?reload=true'], + repl: ['./src/debug/Repl.tsx', 'webpack-hot-middleware/client?reload=true'], + test: ['./src/debug/Test.tsx', 'webpack-hot-middleware/client?reload=true'], + inkControls: ['./src/mobile/InkControls.tsx', 'webpack-hot-middleware/client?reload=true'], + mobileInterface: ['./src/client/views/Main.tsx', 'webpack-hot-middleware/client?reload=true'], }, - devtool: "source-map", + devtool: 'source-map', output: { - filename: "[name].js", - path: path.resolve(__dirname, "build"), - publicPath: "/", + filename: '[name].js', + path: path.resolve(__dirname, 'build'), + publicPath: '/', }, resolve: { extensions: ['.js', '.ts', '.tsx'], @@ -68,67 +68,73 @@ module.exports = { crypto: false, assert: false, os: false, - path: require.resolve("path-browserify"), - http: require.resolve("http-browserify"), - https: require.resolve("https-browserify"), - stream: require.resolve("stream-browserify"), - buffer: require.resolve("buffer") - } + path: require.resolve('path-browserify'), + http: require.resolve('http-browserify'), + https: require.resolve('https-browserify'), + stream: require.resolve('stream-browserify'), + buffer: require.resolve('buffer'), + }, }, module: { - rules: [{ - test: [/\.tsx?$/], - use: [{ - loader: 'ts-loader', - options: { - transpileOnly: true - } - }] - }, - { - test: /\.m?js/, - resolve: { - fullySpecified: false - } - }, - { - test: /\.(woff|woff2|ttf|eot|otf|svg)$/, - use: 'file-loader?name=fonts/[name].[ext]!static' - }, - { - test: /\.scss|css$/, - use: [{ - loader: "style-loader" + rules: [ + { + test: [/\.tsx?$/], + use: [ + { + loader: 'ts-loader', + options: { + transpileOnly: true, + }, + }, + ], }, { - loader: "css-loader" + test: /\.m?js/, + resolve: { + fullySpecified: false, + }, }, { - loader: "sass-loader" + test: /\.(woff|woff2|ttf|eot|otf|svg)$/, + use: 'file-loader?name=fonts/[name].[ext]!static', + }, + { + test: /\.scss|css$/, + use: [ + { + loader: 'style-loader', + }, + { + loader: 'css-loader', + }, + { + loader: 'sass-loader', + }, + ], + }, + { + test: /\.(jpg|png|pdf)$/, + use: [ + { + loader: 'file-loader', + }, + ], + }, + { + test: /\.(png|jpg|gif)$/i, + use: [ + { + loader: 'url-loader', + options: { + limit: 8192, + }, + }, + ], }, - ] - }, - { - test: /\.(jpg|png|pdf)$/, - use: [{ - loader: 'file-loader' - }] - }, - { - test: /\.(png|jpg|gif)$/i, - use: [{ - loader: 'url-loader', - options: { - limit: 8192 - } - }] - } ], noParse: [require.resolve('typescript/lib/typescript.js')], }, plugins, - externals: [ - 'child_process' - ], -};
\ No newline at end of file + externals: ['child_process'], +}; |