diff options
Diffstat (limited to 'src/client/util')
-rw-r--r-- | src/client/util/CurrentUserUtils.ts | 26 | ||||
-rw-r--r-- | src/client/util/DictationManager.ts | 435 | ||||
-rw-r--r-- | src/client/util/DocumentManager.ts | 17 | ||||
-rw-r--r-- | src/client/util/DragManager.ts | 14 | ||||
-rw-r--r-- | src/client/util/InteractionUtils.tsx | 240 | ||||
-rw-r--r-- | src/client/util/ReportManager.scss | 88 | ||||
-rw-r--r-- | src/client/util/ReportManager.tsx | 282 |
7 files changed, 751 insertions, 351 deletions
diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts index 7856c913b..17d58595c 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}; @@ -235,9 +235,9 @@ export class CurrentUserUtils { const header = Docs.Create.RTFDocument(new RichTextField(JSON.stringify(json), ""), { ...opts, title: "text", layout: "<HTMLdiv transformOrigin='top left' width='{100/scale}%' height='{100/scale}%' transform='scale({scale})'>" + - ` <FormattedTextBox {...props} dontScale='true' fieldKey={'text'} height='calc(100% - ${headerBtnHgt}px - {this._headerHeight||0}px)'/>` + - " <FormattedTextBox {...props} dontScale='true' fieldKey={'header'} dontSelectOnLoad='true' ignoreAutoHeight='true' fontSize='{this._headerFontSize||9}px' height='{(this._headerHeight||0)}px' background='{this._headerColor || MySharedDocs().userColor||`lightGray`}' />" + - ` <HTMLdiv fontSize='${headerBtnHgt - 1}px' height='${headerBtnHgt}px' background='yellow' onClick={‘(this._headerHeight=scale*Math.min(Math.max(0,this._height-30),this._headerHeight===0?50:0)) + (this._autoHeightMargins=this._headerHeight ? this._headerHeight+${headerBtnHgt}:0)’} >Metadata</HTMLdiv>` + + ` <FormattedTextBox {...props} dontScale='true' fieldKey={'text'} height='calc(100% - ${headerBtnHgt}px - {this._headerHeight||0}px)'/>` + + " <FormattedTextBox {...props} dontScale='true' fieldKey={'header'} dontSelectOnLoad='true' ignoreAutoHeight='true' fontSize='{this._headerFontSize||9}px' height='{(this._headerHeight||0)}px' backgroundColor='{this._headerColor || MySharedDocs().userColor||`lightGray`}' />" + + ` <HTMLdiv fontSize='${headerBtnHgt - 1}px' height='${headerBtnHgt}px' backgroundColor='yellow' onClick={‘(this._headerHeight=scale*Math.min(Math.max(0,this._height-30),this._headerHeight===0?50:0)) + (this._autoHeightMargins=this._headerHeight ? this._headerHeight+${headerBtnHgt}:0)’} >Metadata</HTMLdiv>` + "</HTMLdiv>" }, "header"); @@ -289,11 +289,12 @@ export class CurrentUserUtils { { toolTip: "Tap or drag to create a map", title: "Map", icon: "map-marker-alt", dragFactory: doc.emptyMap as Doc, }, { toolTip: "Tap or drag to create a screen grabber", title: "Grab", icon: "photo-video", dragFactory: doc.emptyScreengrab as Doc, scripts: { onClick: 'openInOverlay(copyDragFactory(this.dragFactory))', onDragStart: '{ return copyDragFactory(this.dragFactory);}'},funcs: { hidden: 'IsNoviceMode()'} }, { toolTip: "Tap or drag to create a WebCam recorder", title: "WebCam", icon: "photo-video", dragFactory: doc.emptyWebCam as Doc, scripts: { onClick: 'openInOverlay(copyDragFactory(this.dragFactory))', onDragStart: '{ return copyDragFactory(this.dragFactory);}'},funcs: { hidden: 'IsNoviceMode()'}}, - { toolTip: "Tap or drag to create a button", title: "Button", icon: "bolt", dragFactory: doc.emptyButton as Doc, funcs: { hidden: 'IsNoviceMode()'} }, - { toolTip: "Tap or drag to create a scripting box", title: "Script", icon: "terminal", dragFactory: doc.emptyScript as Doc, funcs: { hidden: 'IsNoviceMode()'}}, + { toolTip: "Tap or drag to create a button", title: "Button", icon: "bolt", dragFactory: doc.emptyButton as Doc, funcs: { hidden: 'IsNoviceMode()'} }, + { toolTip: "Tap or drag to create a scripting box", title: "Script", icon: "terminal", dragFactory: doc.emptyScript as Doc, funcs: { hidden: 'IsNoviceMode()'}}, { toolTip: "Tap or drag to create a data viz node", title: "DataViz", icon: "file", dragFactory: doc.emptyDataViz as Doc, }, - { toolTip: "Tap or drag to create a data note", title: "DataNote", icon: "window-maximize", dragFactory: doc.emptyHeader as Doc, scripts: {onClick: 'openOnRight(delegateDragFactory(this.dragFactory))', onDragStart: '{ return delegateDragFactory(this.dragFactory);}'}, }, - { toolTip: "Toggle a Calculator REPL", title: "repl", icon: "calculator", scripts: {onClick: 'addOverlayWindow("ScriptingRepl", { x: 300, y: 100, width: 200, height: 200, title: "Scripting REPL" })' } }, + { toolTip: "Tap or drag to create a bullet slide", title: "PPT Slide", icon: "file", dragFactory: doc.emptySlide as Doc, funcs: { hidden: 'IsNoviceMode()'}}, + { toolTip: "Tap or drag to create a data note", title: "DataNote", icon: "window-maximize", dragFactory: doc.emptyHeader as Doc,scripts: { onClick: 'openOnRight(delegateDragFactory(this.dragFactory))', onDragStart: '{ return delegateDragFactory(this.dragFactory);}'}, }, + { toolTip: "Toggle a Calculator REPL", title: "repl", icon: "calculator", scripts: { onClick: 'addOverlayWindow("ScriptingRepl", { x: 300, y: 100, width: 200, height: 200, title: "Scripting REPL" })' } }, ].map(tuple => ({scripts: {onClick: 'openOnRight(copyDragFactory(this.dragFactory))', onDragStart: '{ return copyDragFactory(this.dragFactory);}'}, ...tuple, })) } @@ -632,7 +633,8 @@ export class CurrentUserUtils { { title: "Left", toolTip: "Left align", btnType: ButtonType.ToggleButton, icon: "align-left", scripts: {onClick:'{ return setAlignment("left", _readOnly_);}' }}, { 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: "NoLink", toolTip: "Auto Link", btnType: ButtonType.ToggleButton, icon: "link", scripts: {onClick:'{ return toggleNoAutoLinkAnchor(_readOnly_);}'}, funcs: {hidden: '!SelectionManager_selectedDocType(undefined, "freeform") || IsNoviceMode()'}}, + { title: "Dictate",toolTip: "Dictate", btnType: ButtonType.ToggleButton, icon: "microphone", scripts: {onClick:'{ return toggleDictation(_readOnly_);}'}}, ]; } @@ -644,8 +646,9 @@ export class CurrentUserUtils { // { title: "Highlighter", toolTip: "Highlighter (Ctrl+H)", btnType: ButtonType.ToggleButton, icon: "highlighter", scripts:{onClick: 'setActiveTool("highlighter")'} }, { title: "Circle", toolTip: "Circle (Ctrl+Shift+C)", btnType: ButtonType.ToggleButton, icon: "circle", scripts: {onClick:'{ return setActiveTool("circle", _readOnly_);}'} }, // { title: "Square", toolTip: "Square (Ctrl+Shift+S)", btnType: ButtonType.ToggleButton, icon: "square", click: 'setActiveTool("square")' }, - { title: "Line", toolTip: "Line (Ctrl+Shift+L)", btnType: ButtonType.ToggleButton, icon: "minus", scripts: {onClick: '{ return setActiveTool("line", _readOnly_);}' }}, - { title: "Fill", toolTip: "Fill color", btnType: ButtonType.ColorButton, icon: "fill-drip",ignoreClick: true, scripts: {script: "{ return setFillColor(value, _readOnly_);}"} }, + { title: "Line", toolTip: "Line (Ctrl+Shift+L)", btnType: ButtonType.ToggleButton, icon: "minus", scripts: {onClick:'{ return setActiveTool("line", _readOnly_);}' }}, + { title: "Mask", toolTip: "Mask", btnType: ButtonType.ToggleButton, icon: "user-circle", scripts: {onClick:'{ return setIsInkMask(_readOnly_);}'} }, + { title: "Fill", toolTip: "Fill color", btnType: ButtonType.ColorButton, icon: "fill-drip",ignoreClick: true, scripts: {script: '{ return setFillColor(value, _readOnly_);}'} }, { title: "Width", toolTip: "Stroke width", btnType: ButtonType.NumberButton, ignoreClick: true, scripts: {script: '{ return setStrokeWidth(value, _readOnly_);}'}, numBtnType: NumButtonType.Slider, numBtnMin: 1}, { title: "Color", toolTip: "Stroke color", btnType: ButtonType.ColorButton, icon: "pen", ignoreClick: true, scripts: {script: '{ return setStrokeColor(value, _readOnly_);}'} }, ]; @@ -673,6 +676,7 @@ export class CurrentUserUtils { 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: "Num", icon: "", toolTip: "Frame Number", btnType: ButtonType.TextButton, funcs: {hidden: '!SelectionManager_selectedDocType(undefined, "freeform") || IsNoviceMode()', buttonText: 'selectedDocs()?.lastElement().currentFrame.toString()'}, width: 20, scripts: {}}, { 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_)'}}, { title: "Fill", icon: "fill-drip", toolTip: "Background Fill Color",btnType: ButtonType.ColorButton, funcs: {hidden: '!SelectionManager_selectedDocType()'}, ignoreClick: true, width: 20, scripts: { script: 'return setBackgroundColor(value, _readOnly_)'}}, // Only when a document is selected { title: "Header", icon: "heading", toolTip: "Header Color", btnType: ButtonType.ColorButton, funcs: {hidden: '!SelectionManager_selectedDocType()'}, ignoreClick: true, scripts: { script: 'return setHeaderColor(value, _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 eef5b9ce1..6386c87a0 100644 --- a/src/client/util/DragManager.ts +++ b/src/client/util/DragManager.ts @@ -377,14 +377,14 @@ export namespace DragManager { } const rect = ele.getBoundingClientRect(); const scaleX = rect.width / (ele.offsetWidth || rect.width); - const scaleY = ele.offsetHeight ? rect.height / (ele.offsetHeight || rect.height) : scaleX; + const scaleY = scaleX; //ele.offsetHeight ? rect.height / (ele.offsetHeight || rect.height) : scaleX; elesCont.left = Math.min(rect.left, elesCont.left); elesCont.top = Math.min(rect.top, elesCont.top); elesCont.right = Math.max(rect.right, elesCont.right); elesCont.bottom = Math.max(rect.bottom, elesCont.bottom); - xs.push(rect.left); - ys.push(rect.top); + xs.push(rect.left + (options?.offsetX || 0)); + ys.push(rect.top + (options?.offsetY || 0)); scaleXs.push(scaleX); scaleYs.push(scaleY); Object.assign(dragElement.style, { @@ -401,9 +401,9 @@ export namespace DragManager { transformOrigin: '0 0', width: `${rect.width / scaleX}px`, height: `${rect.height / scaleY}px`, - transform: `translate(${rect.left + (options?.offsetX || 0)}px, ${rect.top + (options?.offsetY || 0)}px) scale(${scaleX}, ${scaleY})`, + transform: `translate(${xs[0]}px, ${ys[0]}px) scale(${scaleX}, ${scaleY})`, }); - dragLabel.style.transform = `translate(${rect.left + (options?.offsetX || 0)}px, ${rect.top + (options?.offsetY || 0) - 20}px)`; + dragLabel.style.transform = `translate(${xs[0]}px, ${ys[0] - 20}px)`; if (docsToDrag.length) { const pdfBox = dragElement.getElementsByTagName('canvas'); @@ -542,8 +542,8 @@ export namespace DragManager { const moveVec = { x: x - lastPt.x, y: y - lastPt.y }; lastPt = { x, y }; - dragLabel.style.transform = `translate(${xs[0] + moveVec.x + (options?.offsetX || 0)}px, ${ys[0] + moveVec.y + (options?.offsetY || 0) - 20}px)`; - dragElements.map((dragElement, i) => (dragElement.style.transform = `translate(${(xs[i] += moveVec.x) + (options?.offsetX || 0)}px, ${(ys[i] += moveVec.y) + (options?.offsetY || 0)}px) scale(${scaleXs[i]}, ${scaleYs[i]})`)); + dragElements.map((dragElement, i) => (dragElement.style.transform = `translate(${(xs[i] += moveVec.x)}px, ${(ys[i] += moveVec.y)}px) scale(${scaleXs[i]}, ${scaleYs[i]})`)); + dragLabel.style.transform = `translate(${xs[0]}px, ${ys[0] - 20}px)`; }; const upHandler = (e: PointerEvent) => { clearTimeout(startWindowDragTimer); diff --git a/src/client/util/InteractionUtils.tsx b/src/client/util/InteractionUtils.tsx index 289c5bc51..4af51b9a0 100644 --- a/src/client/util/InteractionUtils.tsx +++ b/src/client/util/InteractionUtils.tsx @@ -1,12 +1,12 @@ -import React = require("react"); -import { Utils } from "../../Utils"; -import "./InteractionUtils.scss"; +import React = require('react'); +import { Utils } from '../../Utils'; +import './InteractionUtils.scss'; export namespace InteractionUtils { - export const MOUSETYPE = "mouse"; - export const TOUCHTYPE = "touch"; - export const PENTYPE = "pen"; - export const ERASERTYPE = "eraser"; + export const MOUSETYPE = 'mouse'; + export const TOUCHTYPE = 'touch'; + export const PENTYPE = 'pen'; + export const ERASERTYPE = 'eraser'; const POINTER_PEN_BUTTON = -1; const REACT_POINTER_PEN_BUTTON = 0; @@ -19,24 +19,23 @@ export namespace InteractionUtils { readonly touches: T extends React.TouchEvent ? React.Touch[] : Touch[], readonly changedTouches: T extends React.TouchEvent ? React.Touch[] : Touch[], readonly touchEvent: T extends React.TouchEvent ? React.TouchEvent : TouchEvent - ) { } + ) {} } - export interface MultiTouchEventDisposer { (): void; } + export interface MultiTouchEventDisposer { + (): void; + } /** * * @param element - element to turn into a touch target * @param startFunc - event handler, typically Touchable.onTouchStart (classes that inherit touchable can pass in this.onTouchStart) */ - export function MakeMultiTouchTarget( - element: HTMLElement, - startFunc: (e: Event, me: MultiTouchEvent<React.TouchEvent>) => void - ): MultiTouchEventDisposer { + export function MakeMultiTouchTarget(element: HTMLElement, startFunc: (e: Event, me: MultiTouchEvent<React.TouchEvent>) => void): MultiTouchEventDisposer { const onMultiTouchStartHandler = (e: Event) => startFunc(e, (e as CustomEvent<MultiTouchEvent<React.TouchEvent>>).detail); // const onMultiTouchMoveHandler = moveFunc ? (e: Event) => moveFunc(e, (e as CustomEvent<MultiTouchEvent<TouchEvent>>).detail) : undefined; // const onMultiTouchEndHandler = endFunc ? (e: Event) => endFunc(e, (e as CustomEvent<MultiTouchEvent<TouchEvent>>).detail) : undefined; - element.addEventListener("dashOnTouchStart", onMultiTouchStartHandler); + element.addEventListener('dashOnTouchStart', onMultiTouchStartHandler); // if (onMultiTouchMoveHandler) { // element.addEventListener("dashOnTouchMove", onMultiTouchMoveHandler); // } @@ -44,7 +43,7 @@ export namespace InteractionUtils { // element.addEventListener("dashOnTouchEnd", onMultiTouchEndHandler); // } return () => { - element.removeEventListener("dashOnTouchStart", onMultiTouchStartHandler); + element.removeEventListener('dashOnTouchStart', onMultiTouchStartHandler); // if (onMultiTouchMoveHandler) { // element.removeEventListener("dashOnTouchMove", onMultiTouchMoveHandler); // } @@ -59,14 +58,11 @@ export namespace InteractionUtils { * @param element - element to add events to * @param func - function to add to the event */ - export function MakeHoldTouchTarget( - element: HTMLElement, - func: (e: Event, me: MultiTouchEvent<React.TouchEvent>) => void - ): MultiTouchEventDisposer { + export function MakeHoldTouchTarget(element: HTMLElement, func: (e: Event, me: MultiTouchEvent<React.TouchEvent>) => void): MultiTouchEventDisposer { const handler = (e: Event) => func(e, (e as CustomEvent<MultiTouchEvent<React.TouchEvent>>).detail); - element.addEventListener("dashOnTouchHoldStart", handler); + element.addEventListener('dashOnTouchHoldStart', handler); return () => { - element.removeEventListener("dashOnTouchHoldStart", handler); + element.removeEventListener('dashOnTouchHoldStart', handler); }; } @@ -89,71 +85,108 @@ export namespace InteractionUtils { return myTouches; } - export function CreatePolyline(points: { X: number, Y: number }[], left: number, top: number, - color: string, width: number, strokeWidth: number, lineJoin: string, lineCap: string, bezier: string, fill: string, arrowStart: string, arrowEnd: string, - markerScale: number, dash: string | undefined, scalex: number, scaley: number, shape: string, pevents: string, opacity: number, nodefs: boolean, - downHdlr?: ((e: React.PointerEvent) => void)) { + export function CreatePolyline( + points: { X: number; Y: number }[], + left: number, + top: number, + color: string, + width: number, + strokeWidth: number, + lineJoin: string, + lineCap: string, + bezier: string, + fill: string, + arrowStart: string, + arrowEnd: string, + markerScale: number, + dash: string | undefined, + scalex: number, + scaley: number, + shape: string, + pevents: string, + opacity: number, + nodefs: boolean, + downHdlr?: (e: React.PointerEvent) => void + ) { const pts = shape ? makePolygon(shape, points) : points; if (isNaN(scalex)) scalex = 1; if (isNaN(scaley)) scaley = 1; - const toScr = (p: { X: number, Y: number }) => ` ${!p ? 0 : (p.X - left - width / 2) * scalex + width / 2}, ${!p ? 0 : (p.Y - top - width / 2) * scaley + width / 2} `; - const strpts = bezier ? - pts.reduce((acc: string, pt, i) => acc + (i % 4 !== 0 ? "" : (i === 0 ? "M" + toScr(pt) : "") + "C" + toScr(pts[i + 1]) + toScr(pts[i + 2]) + toScr(pts[i + 3])), "") : - pts.reduce((acc: string, pt) => acc + `${toScr(pt)} `, ""); + const toScr = (p: { X: number; Y: number }) => ` ${!p ? 0 : (p.X - left - width / 2) * scalex + width / 2}, ${!p ? 0 : (p.Y - top - width / 2) * scaley + width / 2} `; + const strpts = bezier + ? pts.reduce((acc: string, pt, i) => acc + (i % 4 !== 0 ? '' : (i === 0 ? 'M' + toScr(pt) : '') + 'C' + toScr(pts[i + 1]) + toScr(pts[i + 2]) + toScr(pts[i + 3])), '') + : pts.reduce((acc: string, pt) => acc + `${toScr(pt)} `, ''); const dashArray = dash && Number(dash) ? String(Number(width) * Number(dash)) : undefined; const defGuid = Utils.GenerateGuid(); - const Tag = (bezier ? "path" : "polyline") as keyof JSX.IntrinsicElements; + const Tag = (bezier ? 'path' : 'polyline') as keyof JSX.IntrinsicElements; const markerStrokeWidth = strokeWidth / 2; - const arrowWidthFactor = 3 * (markerScale || 0.5);// used to be 1.5 + const arrowWidthFactor = 3 * (markerScale || 0.5); // used to be 1.5 const arrowLengthFactor = 5 * (markerScale || 0.5); const arrowNotchFactor = 2 * (markerScale || 0.5); - return (<svg fill={color} onPointerDown={downHdlr}> {/* setting the svg fill sets the arrowStart fill */} - {nodefs ? (null) : <defs> - {arrowStart !== "dot" && arrowEnd !== "dot" ? (null) : - <marker id={`dot${defGuid}`} orient="auto" markerUnits="userSpaceOnUse" refX={0} refY="0" overflow="visible"> - <circle r={strokeWidth * arrowWidthFactor} fill="context-stroke" /> - </marker>} - {arrowStart !== "arrow" ? (null) : - <marker id={`arrowStart${defGuid}`} markerUnits="userSpaceOnUse" orient="auto" overflow="visible" refX={markerStrokeWidth * (arrowLengthFactor - arrowNotchFactor)} refY={0} markerWidth="10" markerHeight="7"> - <polygon style={{ stroke: color }} strokeLinejoin={lineJoin as any} strokeWidth={markerStrokeWidth * 2 / 3} - points={`${arrowLengthFactor * markerStrokeWidth} ${-markerStrokeWidth * arrowWidthFactor}, ${markerStrokeWidth * (arrowLengthFactor - arrowNotchFactor)} 0, ${arrowLengthFactor * markerStrokeWidth} ${markerStrokeWidth * arrowWidthFactor}, 0 0`} /> - </marker>} - {arrowEnd !== "arrow" ? (null) : - <marker id={`arrowEnd${defGuid}`} markerUnits="userSpaceOnUse" orient="auto" overflow="visible" refX={markerStrokeWidth * arrowNotchFactor} refY={0} markerWidth="10" markerHeight="7"> - <polygon style={{ stroke: color }} strokeLinejoin={lineJoin as any} strokeWidth={markerStrokeWidth * 2 / 3} - points={`0 ${-markerStrokeWidth * arrowWidthFactor}, ${markerStrokeWidth * arrowNotchFactor} 0, 0 ${markerStrokeWidth * arrowWidthFactor}, ${arrowLengthFactor * markerStrokeWidth} 0`} /> - </marker>} - </defs>} - - <Tag - d={bezier ? strpts : undefined} - points={bezier ? undefined : strpts} - style={{ - // filter: drawHalo ? "url(#inkSelectionHalo)" : undefined, - fill: fill && fill !== "transparent" ? fill : "none", - opacity: 1.0, - // opacity: strokeWidth !== width ? 0.5 : undefined, - pointerEvents: pevents as any, - stroke: color ?? "rgb(0, 0, 0)", - strokeWidth: strokeWidth, - strokeLinecap: lineCap as any, - strokeDasharray: dashArray - }} - markerStart={`url(#${arrowStart === "dot" ? arrowStart + defGuid : arrowStart + "Start" + defGuid})`} - markerEnd={`url(#${arrowEnd === "dot" ? arrowEnd + defGuid : arrowEnd + "End" + defGuid})`} - /> - - </svg>); + return ( + <svg fill={color} style={{ transition: 'inherit' }} onPointerDown={downHdlr}> + {' '} + {/* setting the svg fill sets the arrowStart fill */} + {nodefs ? null : ( + <defs> + {arrowStart !== 'dot' && arrowEnd !== 'dot' ? null : ( + <marker id={`dot${defGuid}`} orient="auto" markerUnits="userSpaceOnUse" refX={0} refY="0" overflow="visible"> + <circle r={strokeWidth * arrowWidthFactor} fill="context-stroke" /> + </marker> + )} + {arrowStart !== 'arrow' ? null : ( + <marker id={`arrowStart${defGuid}`} markerUnits="userSpaceOnUse" orient="auto" overflow="visible" refX={markerStrokeWidth * (arrowLengthFactor - arrowNotchFactor)} refY={0} markerWidth="10" markerHeight="7"> + <polygon + style={{ stroke: color }} + strokeLinejoin={lineJoin as any} + strokeWidth={(markerStrokeWidth * 2) / 3} + points={`${arrowLengthFactor * markerStrokeWidth} ${-markerStrokeWidth * arrowWidthFactor}, ${markerStrokeWidth * (arrowLengthFactor - arrowNotchFactor)} 0, ${arrowLengthFactor * markerStrokeWidth} ${ + markerStrokeWidth * arrowWidthFactor + }, 0 0`} + /> + </marker> + )} + {arrowEnd !== 'arrow' ? null : ( + <marker id={`arrowEnd${defGuid}`} markerUnits="userSpaceOnUse" orient="auto" overflow="visible" refX={markerStrokeWidth * arrowNotchFactor} refY={0} markerWidth="10" markerHeight="7"> + <polygon + style={{ stroke: color }} + strokeLinejoin={lineJoin as any} + strokeWidth={(markerStrokeWidth * 2) / 3} + points={`0 ${-markerStrokeWidth * arrowWidthFactor}, ${markerStrokeWidth * arrowNotchFactor} 0, 0 ${markerStrokeWidth * arrowWidthFactor}, ${arrowLengthFactor * markerStrokeWidth} 0`} + /> + </marker> + )} + </defs> + )} + <Tag + d={bezier ? strpts : undefined} + points={bezier ? undefined : strpts} + style={{ + // filter: drawHalo ? "url(#inkSelectionHalo)" : undefined, + fill: fill && fill !== 'transparent' ? fill : 'none', + opacity: 1.0, + // opacity: strokeWidth !== width ? 0.5 : undefined, + pointerEvents: pevents as any, + stroke: color ?? 'rgb(0, 0, 0)', + strokeWidth: strokeWidth, + strokeLinecap: lineCap as any, + strokeDasharray: dashArray, + transition: 'inherit', + }} + markerStart={`url(#${arrowStart === 'dot' ? arrowStart + defGuid : arrowStart + 'Start' + defGuid})`} + markerEnd={`url(#${arrowEnd === 'dot' ? arrowEnd + defGuid : arrowEnd + 'End' + defGuid})`} + /> + </svg> + ); } - export function makePolygon(shape: string, points: { X: number, Y: number }[]) { + export function makePolygon(shape: string, points: { X: number; Y: number }[]) { if (points.length > 1 && points[points.length - 1].X === points[0].X && points[points.length - 1].Y + 1 === points[0].Y) { //pointer is up (first and last points are the same) - if (shape === "arrow" || shape === "line" || shape === "circle") { + if (shape === 'arrow' || shape === 'line' || shape === 'circle') { //if arrow or line, the two end points should be the starting and the ending point var left = points[0].X; var top = points[0].Y; @@ -175,7 +208,7 @@ export namespace InteractionUtils { left = points[0].X; bottom = points[points.length - 1].Y; top = points[0].Y; - if (shape !== "arrow" && shape !== "line" && shape !== "circle") { + if (shape !== 'arrow' && shape !== 'line' && shape !== 'circle') { //switch left/right and top/bottom if needed if (left > right) { const temp = right; @@ -191,14 +224,13 @@ export namespace InteractionUtils { } points = []; switch (shape) { - case "rectangle": + case 'rectangle': points.push({ X: left, Y: top }); points.push({ X: right, Y: top }); points.push({ X: right, Y: bottom }); points.push({ X: left, Y: bottom }); points.push({ X: left, Y: top }); - return points; - case "triangle": + case 'triangle': // points.push({ X: left, Y: bottom }); // points.push({ X: right, Y: bottom }); // points.push({ X: (right + left) / 2, Y: top }); @@ -219,62 +251,39 @@ export namespace InteractionUtils { points.push({ X: left, Y: bottom }); points.push({ X: left, Y: bottom }); - - - return points; - case "circle": + case 'circle': const centerX = (Math.max(left, right) + Math.min(left, right)) / 2; const centerY = (Math.max(top, bottom) + Math.min(top, bottom)) / 2; const radius = Math.max(centerX - Math.min(left, right), centerY - Math.min(top, bottom)); if (centerX - Math.min(left, right) < centerY - Math.min(top, bottom)) { for (var y = Math.min(top, bottom); y < Math.max(top, bottom); y++) { - const x = Math.sqrt(Math.pow(radius, 2) - (Math.pow((y - centerY), 2))) + centerX; + const x = Math.sqrt(Math.pow(radius, 2) - Math.pow(y - centerY, 2)) + centerX; points.push({ X: x, Y: y }); } for (var y = Math.max(top, bottom); y > Math.min(top, bottom); y--) { - const x = Math.sqrt(Math.pow(radius, 2) - (Math.pow((y - centerY), 2))) + centerX; + const x = Math.sqrt(Math.pow(radius, 2) - Math.pow(y - centerY, 2)) + centerX; const newX = centerX - (x - centerX); points.push({ X: newX, Y: y }); } - points.push({ X: Math.sqrt(Math.pow(radius, 2) - (Math.pow((Math.min(top, bottom) - centerY), 2))) + centerX, Y: Math.min(top, bottom) }); + points.push({ X: Math.sqrt(Math.pow(radius, 2) - Math.pow(Math.min(top, bottom) - centerY, 2)) + centerX, Y: Math.min(top, bottom) }); } else { for (var x = Math.min(left, right); x < Math.max(left, right); x++) { - const y = Math.sqrt(Math.pow(radius, 2) - (Math.pow((x - centerX), 2))) + centerY; + const y = Math.sqrt(Math.pow(radius, 2) - Math.pow(x - centerX, 2)) + centerY; points.push({ X: x, Y: y }); } for (var x = Math.max(left, right); x > Math.min(left, right); x--) { - const y = Math.sqrt(Math.pow(radius, 2) - (Math.pow((x - centerX), 2))) + centerY; + const y = Math.sqrt(Math.pow(radius, 2) - Math.pow(x - centerX, 2)) + centerY; const newY = centerY - (y - centerY); points.push({ X: x, Y: newY }); } - points.push({ X: Math.min(left, right), Y: Math.sqrt(Math.pow(radius, 2) - (Math.pow((Math.min(left, right) - centerX), 2))) + centerY }); + points.push({ X: Math.min(left, right), Y: Math.sqrt(Math.pow(radius, 2) - Math.pow(Math.min(left, right) - centerX, 2)) + centerY }); } - return points; - // case "arrow": - // const x1 = left; - // const y1 = top; - // const x2 = right; - // const y2 = bottom; - // const L1 = Math.sqrt(Math.pow(Math.abs(x1 - x2), 2) + (Math.pow(Math.abs(y1 - y2), 2))); - // const L2 = L1 / 5; - // const angle = 0.785398; - // const x3 = x2 + (L2 / L1) * ((x1 - x2) * Math.cos(angle) + (y1 - y2) * Math.sin(angle)); - // const y3 = y2 + (L2 / L1) * ((y1 - y2) * Math.cos(angle) - (x1 - x2) * Math.sin(angle)); - // const x4 = x2 + (L2 / L1) * ((x1 - x2) * Math.cos(angle) - (y1 - y2) * Math.sin(angle)); - // const y4 = y2 + (L2 / L1) * ((y1 - y2) * Math.cos(angle) + (x1 - x2) * Math.sin(angle)); - // points.push({ X: x1, Y: y1 }); - // points.push({ X: x2, Y: y2 }); - // points.push({ X: x3, Y: y3 }); - // points.push({ X: x4, Y: y4 }); - // points.push({ X: x2, Y: y2 }); - // return points; - case "line": + case 'line': points.push({ X: left, Y: top }); points.push({ X: right, Y: bottom }); return points; - default: - return points; } + return points; } /** * Returns whether or not the pointer event passed in is of the type passed in @@ -284,11 +293,14 @@ export namespace InteractionUtils { export function IsType(e: PointerEvent | React.PointerEvent, type: string): boolean { switch (type) { // pen and eraser are both pointer type 'pen', but pen is button 0 and eraser is button 5. -syip2 - case PENTYPE: return e.pointerType === PENTYPE && (e.button === -1 || e.button === 0); - case ERASERTYPE: return e.pointerType === PENTYPE && e.button === (e instanceof PointerEvent ? ERASER_BUTTON : ERASER_BUTTON); + case PENTYPE: + return e.pointerType === PENTYPE && (e.button === -1 || e.button === 0); + case ERASERTYPE: + return e.pointerType === PENTYPE && e.button === (e instanceof PointerEvent ? ERASER_BUTTON : ERASER_BUTTON); case TOUCHTYPE: return e.pointerType === TOUCHTYPE; - default: return e.pointerType === type; + default: + return e.pointerType === type; } } @@ -305,7 +317,7 @@ export namespace InteractionUtils { * Returns the centroid of an n-arbitrary long list of points (takes the average the x and y components of each point) * @param pts - n-arbitrary long list of points */ - export function CenterPoint(pts: React.Touch[]): { X: number, Y: number } { + export function CenterPoint(pts: React.Touch[]): { X: number; Y: number } { const centerX = pts.map(pt => pt.clientX).reduce((a, b) => a + b, 0) / pts.length; const centerY = pts.map(pt => pt.clientY).reduce((a, b) => a + b, 0) / pts.length; return { X: centerX, Y: centerY }; @@ -324,9 +336,9 @@ export namespace InteractionUtils { const newDist = TwoPointEuclidist(pt1, pt2); /** if they have the same sign, then we are either pinching in or out. - * threshold it by 10 (it has to be pinching by at least threshold to be a valid pinch) - * so that it can still pan without freaking out - */ + * threshold it by 10 (it has to be pinching by at least threshold to be a valid pinch) + * so that it can still pan without freaking out + */ if (Math.sign(oldDist) === Math.sign(newDist) && Math.abs(oldDist - newDist) > threshold) { return Math.sign(oldDist - newDist); } @@ -372,8 +384,6 @@ export namespace InteractionUtils { // These might not be very useful anymore, but I'll leave them here for now -syip2 { - - /** * Returns the type of Touch Interaction from a list of points. * Also returns any data that is associated with a Touch Interaction diff --git a/src/client/util/ReportManager.scss b/src/client/util/ReportManager.scss new file mode 100644 index 000000000..5a2f2fcad --- /dev/null +++ b/src/client/util/ReportManager.scss @@ -0,0 +1,88 @@ +@import '../views/global/globalCssVariables'; + +.issue-list-wrapper { + position: relative; + min-width: 250px; + background-color: $light-blue; + overflow-y: scroll; +} + +.issue-list { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px; + margin: 5px; + border-radius: 5px; + border: 1px solid grey; + background-color: lightgoldenrodyellow; +} + +// issue should pop up when the user hover over the issue +.issue-list:hover { + box-shadow: 2px; + cursor: pointer; + border: 3px solid #252b33; +} + +.issue-content { + background-color: white; + padding: 10px; + flex: 1 1 auto; + overflow-y: scroll; +} + +.issue-title { + font-size: 20px; + font-weight: 600; + color: black; +} + +.issue-body { + padding: 0 10px; + width: 100%; + text-align: left; +} + +.issue-body > * { + margin-top: 5px; +} + +.issue-body img, +.issue-body video { + display: block; + max-width: 100%; +} + +.report-issue-fab { + position: fixed; + bottom: 20px; + right: 20px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.loading-center { + margin: auto 0; +} + +.settings-content label { + margin-top: 10px; +} + +.report-disclaimer { + font-size: 8px; + color: grey; + padding-right: 50px; + font-style: italic; + text-align: left; +} + +.flex-select { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; +} diff --git a/src/client/util/ReportManager.tsx b/src/client/util/ReportManager.tsx new file mode 100644 index 000000000..55c5ca87f --- /dev/null +++ b/src/client/util/ReportManager.tsx @@ -0,0 +1,282 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { action, computed, observable, runInAction } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { ColorState, SketchPicker } from 'react-color'; +import { Doc } from '../../fields/Doc'; +import { Id } from '../../fields/FieldSymbols'; +import { BoolCast, Cast, StrCast } from '../../fields/Types'; +import { addStyleSheet, addStyleSheetRule, Utils } from '../../Utils'; +import { GoogleAuthenticationManager } from '../apis/GoogleAuthenticationManager'; +import { DocServer } from '../DocServer'; +import { Networking } from '../Network'; +import { MainViewModal } from '../views/MainViewModal'; +import { FontIconBox } from '../views/nodes/button/FontIconBox'; +import { DragManager } from './DragManager'; +import { GroupManager } from './GroupManager'; +import './SettingsManager.scss'; +import './ReportManager.scss'; +import { undoBatch } from './UndoManager'; +import { Octokit } from "@octokit/core"; +import { CheckBox } from '../views/search/CheckBox'; +import ReactLoading from 'react-loading'; +import ReactMarkdown from 'react-markdown'; +import rehypeRaw from 'rehype-raw'; +import remarkGfm from 'remark-gfm'; +const higflyout = require('@hig/flyout'); +export const { anchorPoints } = higflyout; +export const Flyout = higflyout.default; + +@observer +export class ReportManager extends React.Component<{}> { + public static Instance: ReportManager; + @observable private isOpen = false; + + private octokit: Octokit; + + @observable public issues: any[] = []; + @action setIssues = action((issues: any[]) => { this.issues = issues; }); + + // undefined is the default - null is if the user is making an issue + @observable public selectedIssue: any = undefined; + @action setSelectedIssue = action((issue: any) => { this.selectedIssue = issue; }); + + // only get the open issues + @observable public shownIssues = this.issues.filter(issue => issue.state === 'open'); + + public updateIssueSearch = action((query: string = '') => { + if (query === '') { + this.shownIssues = this.issues.filter(issue => issue.state === 'open'); + return; + } + this.shownIssues = this.issues.filter(issue => issue.title.toLowerCase().includes(query.toLowerCase())); + }); + + constructor(props: {}) { + super(props); + ReportManager.Instance = this; + + this.octokit = new Octokit({ + auth: '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' + }); + } + + public close = action(() => (this.isOpen = false)); + public open = action(() => { + if (this.issues.length === 0) { + // load in the issues if not already loaded + this.getAllIssues() + .then(issues => { + this.setIssues(issues); + this.updateIssueSearch(); + }) + .catch(err => console.log(err)); + } + (this.isOpen = true) + }); + + @observable private bugTitle = ''; + @action setBugTitle = action((title: string) => { this.bugTitle = title; }); + @observable private bugDescription = ''; + @action setBugDescription = action((description: string) => { this.bugDescription = description; }); + @observable private bugType = ''; + @action setBugType = action((type: string) => { this.bugType = type; }); + @observable private bugPriority = ''; + @action setBugPriority = action((priortiy: string) => { this.bugPriority = priortiy; }); + + // private toGithub = false; + // will always be set to true - no alterntive option yet + private toGithub = true; + + private formatTitle = (title: string, userEmail: string) => `${title} - ${userEmail.replace('@brown.edu', '')}`; + + public async getAllIssues() : Promise<any[]> { + const res = await this.octokit.request('GET /repos/{owner}/{repo}/issues', { + owner: 'brown-dash', + repo: 'Dash-Web', + }); + + // 200 status means success + if (res.status === 200) { + return res.data; + } else { + throw new Error('Error getting issues'); + } + } + + public async reportIssue() { + if (this.bugTitle === '' || this.bugDescription === '' + || this.bugType === '' || this.bugPriority === '') { + alert('Please fill out all required fields to report an issue.'); + return; + } + + + if (this.toGithub) { + + const req = await this.octokit.request('POST /repos/{owner}/{repo}/issues', { + owner: 'brown-dash', + repo: 'Dash-Web', + title: this.formatTitle(this.bugTitle, Doc.CurrentUserEmail), + body: `${this.bugDescription} \n\nfiles:\n${(this.fileLinks ?? []).join('\n')}`, + labels: [ + 'from-dash-app', + this.bugType, + this.bugPriority + ] + }); + + // 201 status means success + if (req.status !== 201) { + alert('Error creating issue on github.'); + // on error, don't close the modal + return; + } + } + else { + // if not going to github issues, not sure what to do yet... + } + + // if we're down here, then we're good to go. reset the fields. + this.setBugTitle(''); + this.setBugDescription(''); + this.toGithub = false; + this.setFileLinks([]); + this.setBugType(''); + this.setBugPriority(''); + this.close(); + } + + @observable public fileLinks: any = []; + @action setFileLinks = action((links: any) => { this.fileLinks = links; }); + + private getServerPath = (link: any) => { return link.result.accessPaths.agnostic.server } + + private uploadFiles = (input: any) => { + // keep null while uploading + this.setFileLinks(null); + // upload the files to the server + if (input.files && input.files.length !== 0) { + const fileArray: File[] = Array.from(input.files); + (Networking.UploadFilesToServer(fileArray)).then(links => { + console.log('finshed uploading', links.map(this.getServerPath)); + this.setFileLinks((links ?? []).map(this.getServerPath)); + }) + } + + } + + + private renderIssue = (issue: any) => { + + const isReportingIssue = issue === null; + + return isReportingIssue ? + // report issue + (<div className="settings-content"> + <h3 style={{ 'textDecoration': 'underline'}}>Report an Issue</h3> + <label>Please leave a title for the bug.</label><br /> + <input type="text" placeholder='title' onChange={(e) => this.bugTitle = e.target.value} required/> + <br /> + <label>Please leave a description for the bug and how it can be recreated.</label> + <textarea placeholder='description' onChange={(e) => this.bugDescription = e.target.value} required/> + <br /> + {/* {<label>Send to github issues? </label> + <input type="checkbox" onChange={(e) => this.toGithub = e.target.checked} /> + <br /> } */} + + <label>Please label the issue</label> + <div className='flex-select'> + <select name="bugType"> + <option value="" disabled selected>Type</option> + <option value="bug">Bug</option> + <option value="cosmetic">Poor Design or Cosmetic</option> + <option value="documentation">Poor Documentation</option> + </select> + + <select name="bigPriority"> + <option value="" disabled selected>Priority</option> + <option value="priority-low">Low</option> + <option value="priority-medium">Medium</option> + <option value="priority-high">High</option> + </select> + </div> + + + <div> + <label>Upload media that shows the bug (optional)</label> + <input type="file" name="file" multiple accept='audio/*, video/*' onChange={e => this.uploadFiles(e.target)}/> + </div> + <br /> + + <button onClick={() => this.reportIssue()} disabled={this.fileLinks === null} style={{ backgroundColor: this.fileLinks === null ? 'grey' : '' }}>{this.fileLinks === null ? 'Uploading...' : 'Submit'}</button> + </div>) + : + // view issue + ( + <div className='issue-container'> + <h5 style={{'textAlign': "left"}}><a href={issue.html_url} target="_blank">Issue #{issue.number}</a></h5> + <div className='issue-title'> + {issue.title} + </div> + <ReactMarkdown children={issue.body} className='issue-body' linkTarget={"_blank"} remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} /> + </div> + ); + } + + private showReportIssueScreen = () => { + this.setSelectedIssue(null); + } + + private closeReportIssueScreen = () => { + this.setSelectedIssue(undefined); + } + + private get reportInterface() { + + const isReportingIssue = this.selectedIssue === null; + + return ( + <div className="settings-interface"> + <div className='issue-list-wrapper'> + <h3>Current Issues</h3> + <input type="text" placeholder='search issues' onChange={(e => this.updateIssueSearch(e.target.value))}></input><br /> + {this.issues.length === 0 ? <ReactLoading className='loading-center'/> : this.shownIssues.map(issue => <div className='issue-list' key={issue.number} onClick={() => this.setSelectedIssue(issue)}>{issue.title}</div>)} + + {/* <div className="settings-user"> + <button onClick={() => this.getAllIssues().then(issues => this.issues = issues)}>Poll Issues</button> + </div> */} + </div> + + <div className="close-button" onClick={this.close}> + <FontAwesomeIcon icon={'times'} color="black" size={'lg'} /> + </div> + + <div className="issue-content" style={{'paddingTop' : this.selectedIssue === undefined ? '50px' : 'inherit'}}> + {this.selectedIssue === undefined ? "no issue selected" : this.renderIssue(this.selectedIssue)} + </div> + + <div className='report-issue-fab'> + <span className='report-disclaimer' hidden={!isReportingIssue}>Note: issue reporting is not anonymous.</span> + <button + onClick={() => isReportingIssue ? this.closeReportIssueScreen() : this.showReportIssueScreen()} + >{isReportingIssue ? 'Cancel' : 'Report New Issue'}</button> + </div> + + + </div> + ); + } + + render() { + return ( + <MainViewModal + contents={this.reportInterface} + isDisplayed={this.isOpen} + interactive={true} + closeOnExternalClick={this.close} + dialogueBoxStyle={{ width: 'auto', height: '500px', background: Cast(Doc.SharingDoc().userColor, 'string', null) }} + /> + ); + } +} |