diff options
author | kimdahey <claire_kim1@brown.edu> | 2020-01-16 11:31:41 -0500 |
---|---|---|
committer | kimdahey <claire_kim1@brown.edu> | 2020-01-16 11:31:41 -0500 |
commit | 6be0e19ed0bd13f3796f542affa5a2e52674650c (patch) | |
tree | 1be222ea9341ecd8020fad3149035fa650a8a07f /src | |
parent | 5cde81d8c6b4dcd8d0796f8669b668763957f395 (diff) | |
parent | e410cde0e430553002d4e1a2f64364b57b65fdbc (diff) |
merged w master
Diffstat (limited to 'src')
144 files changed, 5806 insertions, 2976 deletions
diff --git a/src/Utils.ts b/src/Utils.ts index 7401ef981..7bf05a6fc 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -4,7 +4,6 @@ import { Socket } from 'socket.io'; import { Message } from './server/Message'; export namespace Utils { - export const DRAG_THRESHOLD = 4; export function GenerateGuid(): string { @@ -329,8 +328,8 @@ export function timenow() { return now.toLocaleDateString() + ' ' + h + ':' + m + ' ' + ampm; } -export function aggregateBounds(boundsList: { x: number, y: number, width: number, height: number }[]) { - return boundsList.reduce((bounds, b) => { +export function aggregateBounds(boundsList: { x: number, y: number, width: number, height: number }[], xpad: number, ypad: number) { + const bounds = boundsList.reduce((bounds, b) => { const [sptX, sptY] = [b.x, b.y]; const [bptX, bptY] = [sptX + b.width, sptY + b.height]; return { @@ -338,6 +337,7 @@ export function aggregateBounds(boundsList: { x: number, y: number, width: numbe r: Math.max(bptX, bounds.r), b: Math.max(bptY, bounds.b) }; }, { x: Number.MAX_VALUE, y: Number.MAX_VALUE, r: -Number.MAX_VALUE, b: -Number.MAX_VALUE }); + return { x: bounds.x !== Number.MAX_VALUE ? bounds.x - xpad : bounds.x, y: bounds.y !== Number.MAX_VALUE ? bounds.y - ypad : bounds.y, r: bounds.r !== -Number.MAX_VALUE ? bounds.r + xpad : bounds.r, b: bounds.b !== -Number.MAX_VALUE ? bounds.b + ypad : bounds.b }; } export function intersectRect(r1: { left: number, top: number, width: number, height: number }, r2: { left: number, top: number, width: number, height: number }) { @@ -362,6 +362,8 @@ export function returnZero() { return 0; } export function returnEmptyString() { return ""; } +export let emptyPath = []; + export function emptyFunction() { } export function unimplementedFunction() { throw new Error("This function is not implemented, but should be."); } diff --git a/src/client/DocServer.ts b/src/client/DocServer.ts index e4b183715..d793b56af 100644 --- a/src/client/DocServer.ts +++ b/src/client/DocServer.ts @@ -64,6 +64,24 @@ export namespace DocServer { } } + const instructions = "This page will automatically refresh after this alert is closed. Expect to reconnect after about 30 seconds."; + function alertUser(connectionTerminationReason: string) { + switch (connectionTerminationReason) { + case "crash": + alert(`Dash has temporarily crashed. Administrators have been notified and the server is restarting itself. ${instructions}`); + break; + case "temporary": + alert(`An administrator has chosen to restart the server. ${instructions}`); + break; + case "exit": + alert("An administrator has chosen to kill the server. Do not expect to reconnect until administrators start the server."); + break; + default: + console.log(`Received an unknown ConnectionTerminated message: ${connectionTerminationReason}`); + } + window.location.reload(); + } + export function init(protocol: string, hostname: string, port: number, identifier: string) { _cache = {}; GUID = identifier; @@ -82,6 +100,7 @@ export namespace DocServer { Utils.AddServerHandler(_socket, MessageStore.UpdateField, respondToUpdate); Utils.AddServerHandler(_socket, MessageStore.DeleteField, respondToDelete); Utils.AddServerHandler(_socket, MessageStore.DeleteFields, respondToDelete); + Utils.AddServerHandler(_socket, MessageStore.ConnectionTerminated, alertUser); } function errorFunc(): never { @@ -253,7 +272,7 @@ export namespace DocServer { const fieldMap: { [id: string]: RefField } = {}; const proms: Promise<void>[] = []; for (const field of fields) { - if (field !== undefined) { + if (field !== undefined && field !== null) { // deserialize const prom = SerializationHelper.Deserialize(field).then(deserialized => { fieldMap[field.id] = deserialized; @@ -420,4 +439,4 @@ export namespace DocServer { function respondToDelete(ids: string | string[]) { _respondToDelete(ids); } -}
\ No newline at end of file +} diff --git a/src/client/cognitive_services/CognitiveServices.ts b/src/client/cognitive_services/CognitiveServices.ts index 02eff3b25..57296c961 100644 --- a/src/client/cognitive_services/CognitiveServices.ts +++ b/src/client/cognitive_services/CognitiveServices.ts @@ -137,7 +137,7 @@ export namespace CognitiveServices { let id = 0; const strokes: AzureStrokeData[] = inkData.map(points => ({ id: id++, - points: points.map(({ x, y }) => `${x},${y}`).join(","), + points: points.map(({ X: x, Y: y }) => `${x},${y}`).join(","), language: "en-US" })); return JSON.stringify({ @@ -153,7 +153,7 @@ export namespace CognitiveServices { const serverAddress = "https://api.cognitive.microsoft.com"; const endpoint = serverAddress + "/inkrecognizer/v1.0-preview/recognize"; - const promisified = (resolve: any, reject: any) => { + return new Promise<string>((resolve, reject) => { xhttp.onreadystatechange = function () { if (this.readyState === 4) { const result = xhttp.responseText; @@ -171,11 +171,8 @@ export namespace CognitiveServices { xhttp.setRequestHeader('Ocp-Apim-Subscription-Key', apiKey); xhttp.setRequestHeader('Content-Type', 'application/json'); xhttp.send(body); - }; - - return new Promise<any>(promisified); + }); }, - }; export namespace Appliers { diff --git a/src/client/documents/DocumentTypes.ts b/src/client/documents/DocumentTypes.ts index f6dd0c346..8f96b2fa6 100644 --- a/src/client/documents/DocumentTypes.ts +++ b/src/client/documents/DocumentTypes.ts @@ -25,5 +25,6 @@ export enum DocumentType { COLOR = "color", DOCULINK = "doculink", PDFANNO = "pdfanno", - INK = "ink" + INK = "ink", + DOCUMENT = "document" }
\ No newline at end of file diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index e0f2858ba..be678d765 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -48,6 +48,7 @@ import { PresElementBox } from "../views/presentationview/PresElementBox"; import { QueryBox } from "../views/nodes/QueryBox"; import { ColorBox } from "../views/nodes/ColorBox"; import { DocuLinkBox } from "../views/nodes/DocuLinkBox"; +import { DocumentBox } from "../views/nodes/DocumentBox"; import { InkingStroke } from "../views/InkingStroke"; import { InkField } from "../../new_fields/InkField"; const requestImageSize = require('../util/request-image-size'); @@ -96,6 +97,7 @@ export interface DocumentOptions { schemaColumns?: List<SchemaHeaderField>; dockingConfig?: string; autoHeight?: boolean; + annotationOn?: Doc; removeDropProperties?: List<string>; // list of properties that should be removed from a document when it is dropped. e.g., a creator button may be forceActive to allow it be dragged, but the forceActive property can be removed from the dropped document dbDoc?: Doc; ischecked?: ScriptField; // returns whether a font icon box is checked @@ -112,6 +114,7 @@ export interface DocumentOptions { dropConverter?: ScriptField; // script to run when documents are dropped on this Document. strokeWidth?: number; color?: string; + limitHeight?: number; // maximum height for newly created (eg, from pasting) text documents // [key: string]: Opt<Field>; } @@ -170,6 +173,10 @@ export namespace Docs { layout: { view: KeyValueBox, dataField: data }, options: { height: 150 } }], + [DocumentType.DOCUMENT, { + layout: { view: DocumentBox, dataField: data }, + options: { height: 250 } + }], [DocumentType.VID, { layout: { view: VideoBox, dataField: data }, options: { currentTimecode: 0 }, @@ -180,7 +187,7 @@ export namespace Docs { }], [DocumentType.PDF, { layout: { view: PDFBox, dataField: data }, - options: { nativeWidth: 1200, curPage: 1 } + options: { curPage: 1 } }], [DocumentType.ICON, { layout: { view: IconBox, dataField: data }, @@ -310,7 +317,7 @@ export namespace Docs { */ export namespace Create { - const delegateKeys = ["x", "y", "width", "height", "panX", "panY", "nativeWidth", "nativeHeight", "dropAction", "forceActive", "fitWidth"]; + const delegateKeys = ["x", "y", "width", "height", "panX", "panY", "nativeWidth", "nativeHeight", "dropAction", "annotationOn", "forceActive", "fitWidth"]; /** * This function receives the relevant document prototype and uses @@ -348,7 +355,7 @@ export namespace Docs { AudioBox.ActiveRecordings.map(d => DocUtils.MakeLink({ doc: viewDoc }, { doc: d }, "audio link", "link to audio: " + d.title)); - return Doc.assign(viewDoc, delegateProps); + return Doc.assign(viewDoc, delegateProps, true); } /** @@ -422,7 +429,7 @@ export namespace Docs { return InstanceFromProto(Prototypes.get(DocumentType.TEXT), "", options); } - export function InkDocument(color: string, tool: number, strokeWidth: number, points: { x: number, y: number }[], options: DocumentOptions = {}) { + export function InkDocument(color: string, tool: number, strokeWidth: number, points: { X: number, Y: number }[], options: DocumentOptions = {}) { const doc = InstanceFromProto(Prototypes.get(DocumentType.INK), new InkField(points), options); doc.color = color; doc.strokeWidth = strokeWidth; @@ -481,6 +488,10 @@ export namespace Docs { return InstanceFromProto(Prototypes.get(DocumentType.KVP), document, { title: document.title + ".kvp", ...options }); } + export function DocumentDocument(document?: Doc, options: DocumentOptions = {}) { + return InstanceFromProto(Prototypes.get(DocumentType.DOCUMENT), document, { title: document ? document.title + "" : "container", ...options }); + } + export function FreeformDocument(documents: Array<Doc>, options: DocumentOptions, id?: string) { return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { chromeStatus: "collapsed", schemaColumns: new List([new SchemaHeaderField("title", "#f1efeb")]), ...options, viewType: CollectionViewType.Freeform }, id); } @@ -534,7 +545,8 @@ export namespace Docs { export type DocConfig = { doc: Doc, - initialWidth?: number + initialWidth?: number, + path?: Doc[] }; export function StandardCollectionDockingDocument(configs: Array<DocConfig>, options: DocumentOptions, id?: string, type: string = "row") { @@ -543,7 +555,7 @@ export namespace Docs { { type: type, content: [ - ...configs.map(config => CollectionDockingView.makeDocumentConfig(config.doc, undefined, config.initialWidth)) + ...configs.map(config => CollectionDockingView.makeDocumentConfig(config.doc, undefined, config.initialWidth, config.path)) ] } ] @@ -642,17 +654,20 @@ export namespace Docs { let ctor: ((path: string, options: DocumentOptions) => (Doc | Promise<Doc | undefined>)) | undefined = undefined; if (type.indexOf("image") !== -1) { ctor = Docs.Create.ImageDocument; + if (!options.width) options.width = 300; } if (type.indexOf("video") !== -1) { ctor = Docs.Create.VideoDocument; + if (!options.width) options.width = 600; + if (!options.height) options.height = options.width * 2 / 3; } if (type.indexOf("audio") !== -1) { ctor = Docs.Create.AudioDocument; } if (type.indexOf("pdf") !== -1) { ctor = Docs.Create.PdfDocument; - options.nativeWidth = 927; - options.nativeHeight = 1200; + if (!options.width) options.width = 400; + if (!options.height) options.height = options.width * 1200 / 927; } if (type.indexOf("excel") !== -1) { ctor = Docs.Create.DBDocument; diff --git a/src/client/northstar/dash-nodes/HistogramBinPrimitiveCollection.ts b/src/client/northstar/dash-nodes/HistogramBinPrimitiveCollection.ts index 3e9145a1b..6b36ffc9e 100644 --- a/src/client/northstar/dash-nodes/HistogramBinPrimitiveCollection.ts +++ b/src/client/northstar/dash-nodes/HistogramBinPrimitiveCollection.ts @@ -3,7 +3,7 @@ import { AttributeTransformationModel } from "../../northstar/core/attribute/Att import { ChartType } from '../../northstar/model/binRanges/VisualBinRange'; import { AggregateFunction, Bin, Brush, DoubleValueAggregateResult, HistogramResult, MarginAggregateParameters, MarginAggregateResult } from "../../northstar/model/idea/idea"; import { ModelHelpers } from "../../northstar/model/ModelHelpers"; -import { LABColor } from '../../northstar/utils/LABcolor'; +import { LABColor } from '../../northstar/utils/LABColor'; import { PIXIRectangle } from "../../northstar/utils/MathUtil"; import { StyleConstants } from "../../northstar/utils/StyleContants"; import { HistogramBox } from "./HistogramBox"; @@ -237,4 +237,4 @@ export class HistogramBinPrimitiveCollection { // } return StyleConstants.HIGHLIGHT_COLOR; } -}
\ No newline at end of file +} diff --git a/src/client/northstar/dash-nodes/HistogramBox.tsx b/src/client/northstar/dash-nodes/HistogramBox.tsx index 854135648..8fee53fb9 100644 --- a/src/client/northstar/dash-nodes/HistogramBox.tsx +++ b/src/client/northstar/dash-nodes/HistogramBox.tsx @@ -46,8 +46,8 @@ export class HistogramBox extends React.Component<FieldViewProps> { @action dropX = (e: Event, de: DragManager.DropEvent) => { - if (de.data instanceof DragManager.DocumentDragData) { - let h = Cast(de.data.draggedDocuments[0].data, HistogramField); + if (de.complete.docDragData) { + let h = Cast(de.complete.docDragData.draggedDocuments[0].data, HistogramField); if (h) { this.HistoOp.X = h.HistoOp.X; } @@ -57,8 +57,8 @@ export class HistogramBox extends React.Component<FieldViewProps> { } @action dropY = (e: Event, de: DragManager.DropEvent) => { - if (de.data instanceof DragManager.DocumentDragData) { - let h = Cast(de.data.draggedDocuments[0].data, HistogramField); + if (de.complete.docDragData) { + let h = Cast(de.complete.docDragData.draggedDocuments[0].data, HistogramField); if (h) { this.HistoOp.Y = h.HistoOp.X; } @@ -78,10 +78,10 @@ export class HistogramBox extends React.Component<FieldViewProps> { componentDidMount() { if (this._dropXRef.current) { - this._dropXDisposer = DragManager.MakeDropTarget(this._dropXRef.current, { handlers: { drop: this.dropX.bind(this) } }); + this._dropXDisposer = DragManager.MakeDropTarget(this._dropXRef.current, this.dropX.bind(this)); } if (this._dropYRef.current) { - this._dropYDisposer = DragManager.MakeDropTarget(this._dropYRef.current, { handlers: { drop: this.dropY.bind(this) } }); + this._dropYDisposer = DragManager.MakeDropTarget(this._dropYRef.current, this.dropY.bind(this)); } reaction(() => CurrentUserUtils.NorthstarDBCatalog, (catalog?: Catalog) => this.activateHistogramOperation(catalog), { fireImmediately: true }); reaction(() => [this.VisualBinRanges && this.VisualBinRanges.slice()], () => this.SizeConverter.SetVisualBinRanges(this.VisualBinRanges)); diff --git a/src/client/northstar/dash-nodes/HistogramBoxPrimitives.tsx b/src/client/northstar/dash-nodes/HistogramBoxPrimitives.tsx index 5a16b3782..66d91cc1d 100644 --- a/src/client/northstar/dash-nodes/HistogramBoxPrimitives.tsx +++ b/src/client/northstar/dash-nodes/HistogramBoxPrimitives.tsx @@ -5,7 +5,7 @@ import { Utils as DashUtils, emptyFunction } from '../../../Utils'; import { FilterModel } from "../../northstar/core/filter/FilterModel"; import { ModelHelpers } from "../../northstar/model/ModelHelpers"; import { ArrayUtil } from "../../northstar/utils/ArrayUtil"; -import { LABColor } from '../../northstar/utils/LABcolor'; +import { LABColor } from '../../northstar/utils/LABColor'; import { PIXIRectangle } from "../../northstar/utils/MathUtil"; import { StyleConstants } from "../../northstar/utils/StyleContants"; import { HistogramBinPrimitiveCollection, HistogramBinPrimitive } from "./HistogramBinPrimitiveCollection"; @@ -119,4 +119,4 @@ export class HistogramBoxPrimitives extends React.Component<HistogramPrimitivesP </svg> </div>; } -}
\ No newline at end of file +} diff --git a/src/client/util/ClientDiagnostics.ts b/src/client/util/ClientDiagnostics.ts deleted file mode 100644 index 0a213aa1c..000000000 --- a/src/client/util/ClientDiagnostics.ts +++ /dev/null @@ -1,34 +0,0 @@ -export namespace ClientDiagnostics { - - export async function start() { - - let serverPolls = 0; - const serverHandle = setInterval(async () => { - if (++serverPolls === 20) { - alert("Your connection to the server has been terminated."); - clearInterval(serverHandle); - } - await fetch("/serverHeartbeat"); - serverPolls--; - }, 1000 * 15); - - let executed = false; - let solrHandle: NodeJS.Timeout | undefined; - const handler = async () => { - const response = await fetch("/solrHeartbeat"); - if (!(await response.json()).running) { - if (!executed) { - alert("Looks like SOLR is not running on your machine."); - executed = true; - solrHandle && clearInterval(solrHandle); - } - } - }; - await handler(); - if (!executed) { - solrHandle = setInterval(handler, 1000 * 15); - } - - } - -}
\ No newline at end of file diff --git a/src/client/util/DocumentManager.ts b/src/client/util/DocumentManager.ts index d491cd1b1..fb4c2155a 100644 --- a/src/client/util/DocumentManager.ts +++ b/src/client/util/DocumentManager.ts @@ -93,33 +93,36 @@ export class DocumentManager { const toReturn: DocumentView[] = []; DocumentManager.Instance.DocumentViews.map(view => - Doc.AreProtosEqual(view.props.Document, toFind) && toReturn.push(view)); + view.props.Document === toFind && toReturn.push(view)); + DocumentManager.Instance.DocumentViews.map(view => + view.props.Document !== toFind && Doc.AreProtosEqual(view.props.Document, toFind) && toReturn.push(view)); return toReturn; } @computed public get LinkedDocumentViews() { - const pairs = DocumentManager.Instance.DocumentViews.filter(dv => - (dv.isSelected() || Doc.IsBrushed(dv.props.Document)) // draw links from DocumentViews that are selected or brushed OR - || DocumentManager.Instance.DocumentViews.some(dv2 => { // Documentviews which - const rest = DocListCast(dv2.props.Document.links).some(l => Doc.AreProtosEqual(l, dv.props.Document));// are link doc anchors - const init = (dv2.isSelected() || Doc.IsBrushed(dv2.props.Document)) && dv2.Document.type !== DocumentType.AUDIO; // on a view that is selected or brushed - return init && rest; - }) - ).reduce((pairs, dv) => { - const linksList = LinkManager.Instance.getAllRelatedLinks(dv.props.Document); - pairs.push(...linksList.reduce((pairs, link) => { - const linkToDoc = link && LinkManager.Instance.getOppositeAnchor(link, dv.props.Document); - linkToDoc && DocumentManager.Instance.getDocumentViews(linkToDoc).map(docView1 => { - if (dv.props.Document.type !== DocumentType.LINK || dv.props.layoutKey !== docView1.props.layoutKey) { - pairs.push({ a: dv, b: docView1, l: link }); - } - }); + const pairs = DocumentManager.Instance.DocumentViews + //.filter(dv => (dv.isSelected() || Doc.IsBrushed(dv.props.Document))) // draw links from DocumentViews that are selected or brushed OR + // || DocumentManager.Instance.DocumentViews.some(dv2 => { // Documentviews which + // const rest = DocListCast(dv2.props.Document.links).some(l => Doc.AreProtosEqual(l, dv.props.Document));// are link doc anchors + // const init = (dv2.isSelected() || Doc.IsBrushed(dv2.props.Document)) && dv2.Document.type !== DocumentType.AUDIO; // on a view that is selected or brushed + // return init && rest; + // } + // ) + .reduce((pairs, dv) => { + const linksList = LinkManager.Instance.getAllRelatedLinks(dv.props.Document); + pairs.push(...linksList.reduce((pairs, link) => { + const linkToDoc = link && LinkManager.Instance.getOppositeAnchor(link, dv.props.Document); + linkToDoc && DocumentManager.Instance.getDocumentViews(linkToDoc).map(docView1 => { + if (dv.props.Document.type !== DocumentType.LINK || dv.props.layoutKey !== docView1.props.layoutKey) { + pairs.push({ a: dv, b: docView1, l: link }); + } + }); + return pairs; + }, [] as { a: DocumentView, b: DocumentView, l: Doc }[])); return pairs; - }, [] as { a: DocumentView, b: DocumentView, l: Doc }[])); - return pairs; - }, [] as { a: DocumentView, b: DocumentView, l: Doc }[]); + }, [] as { a: DocumentView, b: DocumentView, l: Doc }[]); return pairs; } @@ -131,10 +134,13 @@ export class DocumentManager { finalDocView && Doc.linkFollowHighlight(finalDocView.props.Document); }; const docView = DocumentManager.Instance.getFirstDocumentView(targetDoc); - const annotatedDoc = await Cast(targetDoc.annotationOn, Doc); + let annotatedDoc = await Cast(docView?.props.Document.annotationOn, Doc); + if (annotatedDoc) { + const first = DocumentManager.Instance.getFirstDocumentView(annotatedDoc); + if (first) annotatedDoc = first.props.Document; + } if (docView) { // we have a docView already and aren't forced to create a new one ... just focus on the document. TODO move into view if necessary otherwise just highlight? - annotatedDoc && docView.props.focus(annotatedDoc, false); - docView.props.focus(docView.props.Document, willZoom); + docView.props.focus(docView.props.Document, false); highlight(); } else { const contextDocs = docContext ? await DocListCastAsync(docContext.data) : undefined; @@ -176,7 +182,7 @@ export class DocumentManager { } public async FollowLink(link: Doc | undefined, doc: Doc, focus: (doc: Doc, maxLocation: string) => void, zoom: boolean = false, reverse: boolean = false, currentContext?: Doc) { - const linkDocs = link ? [link] : LinkManager.Instance.getAllRelatedLinks(doc); + const linkDocs = link ? [link] : DocListCast(doc.links); SelectionManager.DeselectAll(); const firstDocs = linkDocs.filter(linkDoc => Doc.AreProtosEqual(linkDoc.anchor1 as Doc, doc)); const secondDocs = linkDocs.filter(linkDoc => Doc.AreProtosEqual(linkDoc.anchor2 as Doc, doc)); @@ -194,6 +200,8 @@ export class DocumentManager { const target = linkFollowDocs[reverse ? 1 : 0]; target.currentTimecode !== undefined && (target.currentTimecode = linkFollowTimecodes[reverse ? 1 : 0]); DocumentManager.Instance.jumpToDocument(linkFollowDocs[reverse ? 1 : 0], zoom, (doc: Doc) => focus(doc, maxLocation), targetContext, linkDoc[Id]); + } else if (link) { + DocumentManager.Instance.jumpToDocument(link, zoom, (doc: Doc) => focus(doc, "onRight"), undefined, undefined); } } diff --git a/src/client/util/DragManager.ts b/src/client/util/DragManager.ts index b681387d1..df2f5fe3c 100644 --- a/src/client/util/DragManager.ts +++ b/src/client/util/DragManager.ts @@ -1,5 +1,4 @@ -import { action, runInAction } from "mobx"; -import { Doc, Field } from "../../new_fields/Doc"; +import { Doc, Field, DocListCast } from "../../new_fields/Doc"; import { Cast, ScriptCast } from "../../new_fields/Types"; import { emptyFunction } from "../../Utils"; import { CollectionDockingView } from "../views/collections/CollectionDockingView"; @@ -19,10 +18,10 @@ import { convertDropDataToButtons } from "./DropConverter"; export type dropActionType = "alias" | "copy" | undefined; export function SetupDrag( _reference: React.RefObject<HTMLElement>, - docFunc: () => Doc | Promise<Doc>, + docFunc: () => Doc | Promise<Doc> | undefined, moveFunc?: DragManager.MoveFunction, dropAction?: dropActionType, - options?: any, + treeViewId?: string, dontHideOnDrop?: boolean, dragStarted?: () => void ) { @@ -33,13 +32,15 @@ export function SetupDrag( document.removeEventListener("pointermove", onRowMove); document.removeEventListener('pointerup', onRowUp); const doc = await docFunc(); - const dragData = new DragManager.DocumentDragData([doc]); - dragData.dropAction = dropAction; - dragData.moveDocument = moveFunc; - dragData.options = options; - dragData.dontHideOnDrop = dontHideOnDrop; - DragManager.StartDocumentDrag([_reference.current!], dragData, e.x, e.y); - dragStarted && dragStarted(); + if (doc) { + const dragData = new DragManager.DocumentDragData([doc]); + dragData.dropAction = dropAction; + dragData.moveDocument = moveFunc; + dragData.treeViewId = treeViewId; + dragData.dontHideOnDrop = dontHideOnDrop; + DragManager.StartDocumentDrag([_reference.current!], dragData, e.x, e.y); + dragStarted && dragStarted(); + } }; const onRowUp = (): void => { document.removeEventListener("pointermove", onRowMove); @@ -50,12 +51,13 @@ export function SetupDrag( e.stopPropagation(); if (e.shiftKey && CollectionDockingView.Instance) { e.persist(); - CollectionDockingView.Instance.StartOtherDrag({ + const dragDoc = await docFunc(); + dragDoc && CollectionDockingView.Instance.StartOtherDrag({ pageX: e.pageX, pageY: e.pageY, preventDefault: emptyFunction, button: 0 - }, [await docFunc()]); + }, [dragDoc]); } else { document.addEventListener("pointermove", onRowMove); document.addEventListener("pointerup", onRowUp); @@ -65,62 +67,9 @@ export function SetupDrag( return onItemDown; } -function moveLinkedDocument(doc: Doc, targetCollection: Doc, addDocument: (doc: Doc) => boolean): boolean { - const document = SelectionManager.SelectedDocuments()[0]; - document && document.props.removeDocument && document.props.removeDocument(doc); - addDocument(doc); - return true; -} - -export async function DragLinkAsDocument(dragEle: HTMLElement, x: number, y: number, linkDoc: Doc, sourceDoc: Doc) { - const draggeddoc = LinkManager.Instance.getOppositeAnchor(linkDoc, sourceDoc); - if (draggeddoc) { - const moddrag = await Cast(draggeddoc.annotationOn, Doc); - const dragdocs = moddrag ? [moddrag] : [draggeddoc]; - const dragData = new DragManager.DocumentDragData(dragdocs); - dragData.moveDocument = moveLinkedDocument; - DragManager.StartLinkedDocumentDrag([dragEle], dragData, x, y, { - handlers: { - dragComplete: action(emptyFunction), - }, - hideSource: false - }); - } -} - -export async function DragLinksAsDocuments(dragEle: HTMLElement, x: number, y: number, sourceDoc: Doc, singleLink?: Doc) { - const srcTarg = sourceDoc.proto; - let draggedDocs: Doc[] = []; - - if (srcTarg) { - const linkDocs = singleLink ? [singleLink] : LinkManager.Instance.getAllRelatedLinks(srcTarg); - if (linkDocs) { - draggedDocs = linkDocs.map(link => { - const opp = LinkManager.Instance.getOppositeAnchor(link, sourceDoc); - if (opp) return opp; - }) as Doc[]; - } - } - if (draggedDocs.length) { - const moddrag: Doc[] = []; - for (const draggedDoc of draggedDocs) { - const doc = await Cast(draggedDoc.annotationOn, Doc); - if (doc) moddrag.push(doc); - } - const dragdocs = moddrag.length ? moddrag : draggedDocs; - const dragData = new DragManager.DocumentDragData(dragdocs); - dragData.moveDocument = moveLinkedDocument; - DragManager.StartLinkedDocumentDrag([dragEle], dragData, x, y, { - handlers: { - dragComplete: action(emptyFunction), - }, - hideSource: false - }); - } -} - - export namespace DragManager { + let dragDiv: HTMLDivElement; + export function Root() { const root = document.getElementById("root"); if (!root) { @@ -128,79 +77,45 @@ export namespace DragManager { } return root; } + export let AbortDrag: () => void = emptyFunction; + export type MoveFunction = (document: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean) => boolean; - let dragDiv: HTMLDivElement; - - export enum DragButtons { - Left = 1, - Right = 2, - Both = Left | Right - } - - interface DragOptions { - handlers: DragHandlers; - - hideSource: boolean | (() => boolean); - - dragHasStarted?: () => void; - - withoutShiftDrag?: boolean; - - finishDrag?: (dropData: { [id: string]: any }) => void; - - offsetX?: number; - + export interface DragDropDisposer { (): void; } + export interface DragOptions { + dragComplete?: (e: DragCompleteEvent) => void; // function to invoke when drag has completed + hideSource?: boolean; // hide source document during drag + offsetX?: number; // offset of top left of source drag visual from cursor offsetY?: number; } - export interface DragDropDisposer { - (): void; - } - - export class DragCompleteEvent { } - - export interface DragHandlers { - dragComplete: (e: DragCompleteEvent) => void; - } - - export interface DropOptions { - handlers: DropHandlers; - } + // event called when the drag operation results in a drop action export class DropEvent { constructor( readonly x: number, readonly y: number, - readonly data: { [id: string]: any }, - readonly mods: string + readonly complete: DragCompleteEvent, + readonly altKey: boolean, + readonly metaKey: boolean, + readonly ctrlKey: boolean ) { } } - export interface DropHandlers { - drop: (e: Event, de: DropEvent) => void; - } - - export function MakeDropTarget( - element: HTMLElement, - options: DropOptions - ): DragDropDisposer { - if ("canDrop" in element.dataset) { - throw new Error( - "Element is already droppable, can't make it droppable again" - ); + // event called when the drag operation has completed (aborted or completed a drop) -- this will be after any drop event has been generated + export class DragCompleteEvent { + constructor(aborted: boolean, dragData: { [id: string]: any }) { + this.aborted = aborted; + this.docDragData = dragData instanceof DocumentDragData ? dragData : undefined; + this.annoDragData = dragData instanceof PdfAnnoDragData ? dragData : undefined; + this.linkDragData = dragData instanceof LinkDragData ? dragData : undefined; + this.columnDragData = dragData instanceof ColumnDragData ? dragData : undefined; } - element.dataset.canDrop = "true"; - const handler = (e: Event) => { - const ce = e as CustomEvent<DropEvent>; - options.handlers.drop(e, ce.detail); - }; - element.addEventListener("dashOnDrop", handler); - return () => { - element.removeEventListener("dashOnDrop", handler); - delete element.dataset.canDrop; - }; + aborted: boolean; + docDragData?: DocumentDragData; + annoDragData?: PdfAnnoDragData; + linkDragData?: LinkDragData; + columnDragData?: ColumnDragData; } - export type MoveFunction = (document: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean) => boolean; export class DocumentDragData { constructor(dragDoc: Doc[]) { this.draggedDocuments = dragDoc; @@ -209,6 +124,9 @@ export namespace DragManager { } draggedDocuments: Doc[]; droppedDocuments: Doc[]; + dragDivName?: string; + treeViewId?: string; + dontHideOnDrop?: boolean; offset: number[]; dropAction: dropActionType; userDropAction: dropActionType; @@ -216,16 +134,32 @@ export namespace DragManager { moveDocument?: MoveFunction; isSelectionMove?: boolean; // indicates that an explicitly selected Document is being dragged. this will suppress onDragStart scripts applyAsTemplate?: boolean; - [id: string]: any; } - - export class AnnotationDragData { + export class LinkDragData { + constructor(linkSourceDoc: Doc) { + this.linkSourceDocument = linkSourceDoc; + } + droppedDocuments: Doc[] = []; + linkSourceDocument: Doc; + dontClearTextBox?: boolean; + linkDocument?: Doc; + } + export class ColumnDragData { + constructor(colKey: SchemaHeaderField) { + this.colKey = colKey; + } + colKey: SchemaHeaderField; + } + // used by PDFs to conditionally (if the drop completes) create a text annotation when dragging from the PDF toolbar when a text region has been selected. + // this is pretty clunky and should be rethought out using linkDrag or DocumentDrag + export class PdfAnnoDragData { constructor(dragDoc: Doc, annotationDoc: Doc, dropDoc: Doc) { this.dragDocument = dragDoc; this.dropDocument = dropDoc; this.annotationDocument = annotationDoc; this.offset = [0, 0]; } + linkedToDoc?: boolean; targetContext: Doc | undefined; dragDocument: Doc; annotationDocument: Doc; @@ -235,98 +169,103 @@ export namespace DragManager { userDropAction: dropActionType; } - export let StartDragFunctions: (() => void)[] = []; + export function MakeDropTarget( + element: HTMLElement, + dropFunc: (e: Event, de: DropEvent) => void + ): DragDropDisposer { + if ("canDrop" in element.dataset) { + throw new Error( + "Element is already droppable, can't make it droppable again" + ); + } + element.dataset.canDrop = "true"; + const handler = (e: Event) => dropFunc(e, (e as CustomEvent<DropEvent>).detail); + element.addEventListener("dashOnDrop", handler); + return () => { + element.removeEventListener("dashOnDrop", handler); + delete element.dataset.canDrop; + }; + } + // drag a document and drop it (or make an alias/copy on drop) export function StartDocumentDrag(eles: HTMLElement[], dragData: DocumentDragData, downX: number, downY: number, options?: DragOptions) { - runInAction(() => StartDragFunctions.map(func => func())); + const finishDrag = (e: DragCompleteEvent) => { + e.docDragData && (e.docDragData.droppedDocuments = + dragData.draggedDocuments.map(d => !dragData.isSelectionMove && !dragData.userDropAction && ScriptCast(d.onDragStart) ? ScriptCast(d.onDragStart).script.run({ this: d }).result : + dragData.userDropAction === "alias" || (!dragData.userDropAction && dragData.dropAction === "alias") ? Doc.MakeAlias(d) : + dragData.userDropAction === "copy" || (!dragData.userDropAction && dragData.dropAction === "copy") ? Doc.MakeCopy(d, true) : d) + ); + e.docDragData?.droppedDocuments.forEach((drop: Doc, i: number) => + Cast(dragData.draggedDocuments[i].removeDropProperties, listSpec("string"), []).map(prop => drop[prop] = undefined)); + }; dragData.draggedDocuments.map(d => d.dragFactory); // does this help? trying to make sure the dragFactory Doc is loaded - StartDrag(eles, dragData, downX, downY, options, options && options.finishDrag ? options.finishDrag : - (dropData: { [id: string]: any }) => { - (dropData.droppedDocuments = - dragData.draggedDocuments.map(d => !dragData.isSelectionMove && !dragData.userDropAction && ScriptCast(d.onDragStart) ? ScriptCast(d.onDragStart).script.run({ this: d }).result : - dragData.userDropAction === "alias" || (!dragData.userDropAction && dragData.dropAction === "alias") ? Doc.MakeAlias(d) : - dragData.userDropAction === "copy" || (!dragData.userDropAction && dragData.dropAction === "copy") ? Doc.MakeCopy(d, true) : d) - ); - dropData.droppedDocuments.forEach((drop: Doc, i: number) => - Cast(dragData.draggedDocuments[i].removeDropProperties, listSpec("string"), []).map(prop => drop[prop] = undefined)); - }); + StartDrag(eles, dragData, downX, downY, options, finishDrag); } + // drag a button template and drop a new button export function StartButtonDrag(eles: HTMLElement[], script: string, title: string, vars: { [name: string]: Field }, params: string[], initialize: (button: Doc) => void, downX: number, downY: number, options?: DragOptions) { - const dragData = new DragManager.DocumentDragData([]); - runInAction(() => StartDragFunctions.map(func => func())); - StartDrag(eles, dragData, downX, downY, options, options && options.finishDrag ? options.finishDrag : - (dropData: { [id: string]: any }) => { - const bd = Docs.Create.ButtonDocument({ width: 150, height: 50, title: title }); - bd.onClick = ScriptField.MakeScript(script); - params.map(p => Object.keys(vars).indexOf(p) !== -1 && (Doc.GetProto(bd)[p] = new PrefetchProxy(vars[p] as Doc))); - initialize && initialize(bd); - bd.buttonParams = new List<string>(params); - dropData.droppedDocuments = [bd]; - }); + const finishDrag = (e: DragCompleteEvent) => { + const bd = Docs.Create.ButtonDocument({ width: 150, height: 50, title: title }); + bd.onClick = ScriptField.MakeScript(script); + params.map(p => Object.keys(vars).indexOf(p) !== -1 && (Doc.GetProto(bd)[p] = new PrefetchProxy(vars[p] as Doc))); + initialize && initialize(bd); + bd.buttonParams = new List<string>(params); + e.docDragData && (e.docDragData.droppedDocuments = [bd]); + }; + StartDrag(eles, new DragManager.DocumentDragData([]), downX, downY, options, finishDrag); } - export function StartLinkedDocumentDrag(eles: HTMLElement[], dragData: DocumentDragData, downX: number, downY: number, options?: DragOptions) { - dragData.moveDocument = moveLinkedDocument; + // drag links and drop link targets (aliasing them if needed) + export async function StartLinkTargetsDrag(dragEle: HTMLElement, downX: number, downY: number, sourceDoc: Doc, specificLinks?: Doc[]) { + const draggedDocs = (specificLinks ? specificLinks : DocListCast(sourceDoc.links)).map(link => LinkManager.Instance.getOppositeAnchor(link, sourceDoc)).filter(l => l) as Doc[]; + + if (draggedDocs.length) { + const moddrag: Doc[] = []; + for (const draggedDoc of draggedDocs) { + const doc = await Cast(draggedDoc.annotationOn, Doc); + if (doc) moddrag.push(doc); + } - runInAction(() => StartDragFunctions.map(func => func())); - StartDrag(eles, dragData, downX, downY, options, - (dropData: { [id: string]: any }) => { - const droppedDocuments: Doc[] = dragData.draggedDocuments.reduce((droppedDocs: Doc[], d) => { - const dvs = DocumentManager.Instance.getDocumentViews(d); - if (dvs.length) { - const containingView = SelectionManager.SelectedDocuments()[0] ? SelectionManager.SelectedDocuments()[0].props.ContainingCollectionView : undefined; - const inContext = dvs.filter(dv => dv.props.ContainingCollectionView === containingView); - if (inContext.length) { - inContext.forEach(dv => droppedDocs.push(dv.props.Document)); + const dragData = new DragManager.DocumentDragData(moddrag.length ? moddrag : draggedDocs); + dragData.moveDocument = (doc: Doc, targetCollection: Doc | undefined, addDocument: (doc: Doc) => boolean): boolean => { + const document = SelectionManager.SelectedDocuments()[0]; + document && document.props.removeDocument && document.props.removeDocument(doc); + addDocument(doc); + return true; + }; + const containingView = SelectionManager.SelectedDocuments()[0] ? SelectionManager.SelectedDocuments()[0].props.ContainingCollectionView : undefined; + const finishDrag = (e: DragCompleteEvent) => + e.docDragData && (e.docDragData.droppedDocuments = + dragData.draggedDocuments.reduce((droppedDocs, d) => { + const dvs = DocumentManager.Instance.getDocumentViews(d).filter(dv => dv.props.ContainingCollectionView === containingView); + if (dvs.length) { + dvs.forEach(dv => droppedDocs.push(dv.props.Document)); } else { droppedDocs.push(Doc.MakeAlias(d)); } - } else { - droppedDocs.push(Doc.MakeAlias(d)); - } - return droppedDocs; - }, []); - dropData.droppedDocuments = droppedDocuments; - }); - } + return droppedDocs; + }, [] as Doc[])); - export function StartAnnotationDrag(eles: HTMLElement[], dragData: AnnotationDragData, downX: number, downY: number, options?: DragOptions) { - StartDrag(eles, dragData, downX, downY, options); - } - - export class LinkDragData { - constructor(linkSourceDoc: Doc, blacklist: Doc[] = []) { - this.linkSourceDocument = linkSourceDoc; - this.blacklist = blacklist; + StartDrag([dragEle], dragData, downX, downY, undefined, finishDrag); } - droppedDocuments: Doc[] = []; - linkSourceDocument: Doc; - blacklist: Doc[]; - dontClearTextBox?: boolean; - [id: string]: any; } - // for column dragging in schema view - export class ColumnDragData { - constructor(colKey: SchemaHeaderField) { - this.colKey = colKey; - } - colKey: SchemaHeaderField; - [id: string]: any; + // drag&drop the pdf annotation anchor which will create a text note on drop via a dropCompleted() DragOption + export function StartPdfAnnoDrag(eles: HTMLElement[], dragData: PdfAnnoDragData, downX: number, downY: number, options?: DragOptions) { + StartDrag(eles, dragData, downX, downY, options); } - export function StartLinkDrag(ele: HTMLElement, dragData: LinkDragData, downX: number, downY: number, options?: DragOptions) { - StartDrag([ele], dragData, downX, downY, options); + // drags a linker button and creates a link on drop + export function StartLinkDrag(ele: HTMLElement, sourceDoc: Doc, downX: number, downY: number, options?: DragOptions) { + StartDrag([ele], new DragManager.LinkDragData(sourceDoc), downX, downY, options); } + // drags a column from a schema view export function StartColumnDrag(ele: HTMLElement, dragData: ColumnDragData, downX: number, downY: number, options?: DragOptions) { StartDrag([ele], dragData, downX, downY, options); } - export let AbortDrag: () => void = emptyFunction; - - function StartDrag(eles: HTMLElement[], dragData: { [id: string]: any }, downX: number, downY: number, options?: DragOptions, finishDrag?: (dropData: { [id: string]: any }) => void) { + function StartDrag(eles: HTMLElement[], dragData: { [id: string]: any }, downX: number, downY: number, options?: DragOptions, finishDrag?: (dropData: DragCompleteEvent) => void) { eles = eles.filter(e => e); if (!dragDiv) { dragDiv = document.createElement("div"); @@ -340,33 +279,29 @@ export namespace DragManager { const xs: number[] = []; const ys: number[] = []; - const docs = dragData instanceof DocumentDragData ? dragData.draggedDocuments : - dragData instanceof AnnotationDragData ? [dragData.dragDocument] : []; + const docs = dragData instanceof DocumentDragData ? dragData.draggedDocuments : dragData instanceof PdfAnnoDragData ? [dragData.dragDocument] : []; const dragElements = eles.map(ele => { - const w = ele.offsetWidth, - h = ele.offsetHeight; + if (!ele.parentNode) dragDiv.appendChild(ele); + const dragElement = ele.parentNode === dragDiv ? ele : ele.cloneNode(true) as HTMLElement; const rect = ele.getBoundingClientRect(); - const scaleX = rect.width / w, - scaleY = rect.height / h; - const x = rect.left, - y = rect.top; - xs.push(x); - ys.push(y); + const scaleX = rect.width / ele.offsetWidth, + scaleY = rect.height / ele.offsetHeight; + xs.push(rect.left); + ys.push(rect.top); scaleXs.push(scaleX); scaleYs.push(scaleY); - const dragElement = ele.cloneNode(true) as HTMLElement; dragElement.style.opacity = "0.7"; - dragElement.style.borderRadius = getComputedStyle(ele).borderRadius; dragElement.style.position = "absolute"; dragElement.style.margin = "0"; dragElement.style.top = "0"; dragElement.style.bottom = ""; dragElement.style.left = "0"; - dragElement.style.transition = "none"; dragElement.style.color = "black"; + dragElement.style.transition = "none"; dragElement.style.transformOrigin = "0 0"; + dragElement.style.borderRadius = getComputedStyle(ele).borderRadius; dragElement.style.zIndex = globalCssVariables.contextMenuZindex;// "1000"; - dragElement.style.transform = `translate(${x}px, ${y}px) scale(${scaleX}, ${scaleY})`; + dragElement.style.transform = `translate(${rect.left + (options?.offsetX || 0)}px, ${rect.top + (options?.offsetY || 0)}px) scale(${scaleX}, ${scaleY})`; dragElement.style.width = `${rect.width / scaleX}px`; dragElement.style.height = `${rect.height / scaleY}px`; @@ -384,31 +319,19 @@ export namespace DragManager { Array.from(pdfView).map((v, i) => v.scrollTo({ top: tops[i] })); }, 0); } - const set = dragElement.getElementsByTagName('*'); if (dragElement.hasAttribute("style")) (dragElement as any).style.pointerEvents = "none"; + const set = dragElement.getElementsByTagName('*'); // tslint:disable-next-line: prefer-for-of for (let i = 0; i < set.length; i++) { - if (set[i].hasAttribute("style")) { - const s = set[i]; - (s as any).style.pointerEvents = "none"; - } + set[i].hasAttribute("style") && ((set[i] as any).style.pointerEvents = "none"); } - dragDiv.appendChild(dragElement); return dragElement; }); - let hideSource = false; - if (options) { - if (typeof options.hideSource === "boolean") { - hideSource = options.hideSource; - } else { - hideSource = options.hideSource(); - } - } - - eles.map(ele => ele.hidden = hideSource); + const hideSource = options?.hideSource ? true : false; + eles.map(ele => ele.parentElement && ele.parentElement?.className === dragData.dragDivName ? (ele.parentElement.hidden = hideSource) : (ele.hidden = hideSource)); let lastX = downX; let lastY = downY; @@ -417,9 +340,9 @@ export namespace DragManager { if (dragData instanceof DocumentDragData) { dragData.userDropAction = e.ctrlKey ? "alias" : undefined; } - if (((options && !options.withoutShiftDrag) || !options) && e.shiftKey && CollectionDockingView.Instance) { + if (e.shiftKey && CollectionDockingView.Instance) { AbortDrag(); - finishDrag && finishDrag(dragData); + finishDrag?.(new DragCompleteEvent(true, dragData)); CollectionDockingView.Instance.StartOtherDrag({ pageX: e.pageX, pageY: e.pageY, @@ -433,56 +356,51 @@ export namespace DragManager { lastX = e.pageX; lastY = e.pageY; dragElements.map((dragElement, i) => (dragElement.style.transform = - `translate(${(xs[i] += moveX) + (options ? (options.offsetX || 0) : 0)}px, ${(ys[i] += moveY) + (options ? (options.offsetY || 0) : 0)}px) scale(${scaleXs[i]}, ${scaleYs[i]})`) + `translate(${(xs[i] += moveX) + (options?.offsetX || 0)}px, ${(ys[i] += moveY) + (options?.offsetY || 0)}px) scale(${scaleXs[i]}, ${scaleYs[i]})`) ); }; const hideDragShowOriginalElements = () => { dragElements.map(dragElement => dragElement.parentNode === dragDiv && dragDiv.removeChild(dragElement)); - eles.map(ele => ele.hidden = false); + eles.map(ele => ele.parentElement && ele.parentElement?.className === dragData.dragDivName ? (ele.parentElement.hidden = false) : (ele.hidden = false)); }; const endDrag = () => { document.removeEventListener("pointermove", moveHandler, true); document.removeEventListener("pointerup", upHandler); - if (options) { - options.handlers.dragComplete({}); - } }; AbortDrag = () => { hideDragShowOriginalElements(); SelectionManager.SetIsDragging(false); + options?.dragComplete?.(new DragCompleteEvent(true, dragData)); endDrag(); }; const upHandler = (e: PointerEvent) => { hideDragShowOriginalElements(); dispatchDrag(eles, e, dragData, options, finishDrag); SelectionManager.SetIsDragging(false); + options?.dragComplete?.(new DragCompleteEvent(false, dragData)); endDrag(); }; document.addEventListener("pointermove", moveHandler, true); document.addEventListener("pointerup", upHandler); } - function dispatchDrag(dragEles: HTMLElement[], e: PointerEvent, dragData: { [index: string]: any }, options?: DragOptions, finishDrag?: (dragData: { [index: string]: any }) => void) { + function dispatchDrag(dragEles: HTMLElement[], e: PointerEvent, dragData: { [index: string]: any }, options?: DragOptions, finishDrag?: (e: DragCompleteEvent) => void) { const removed = dragData.dontHideOnDrop ? [] : dragEles.map(dragEle => { - // let parent = dragEle.parentElement; - // if (parent) parent.removeChild(dragEle); - const ret = [dragEle, dragEle.style.width, dragEle.style.height]; + const ret = { ele: dragEle, w: dragEle.style.width, h: dragEle.style.height }; dragEle.style.width = "0"; dragEle.style.height = "0"; return ret; }); const target = document.elementFromPoint(e.x, e.y); removed.map(r => { - const dragEle = r[0] as HTMLElement; - dragEle.style.width = r[1] as string; - dragEle.style.height = r[2] as string; - // let parent = r[1]; - // if (parent && dragEle) parent.appendChild(dragEle); + r.ele.style.width = r.w; + r.ele.style.height = r.h; }); if (target) { - finishDrag && finishDrag(dragData); + const complete = new DragCompleteEvent(false, dragData); + finishDrag?.(complete); target.dispatchEvent( new CustomEvent<DropEvent>("dashOnDrop", { @@ -490,8 +408,10 @@ export namespace DragManager { detail: { x: e.x, y: e.y, - data: dragData, - mods: e.altKey ? "AltKey" : e.ctrlKey ? "CtrlKey" : e.metaKey ? "MetaKey" : "" + complete: complete, + altKey: e.altKey, + metaKey: e.metaKey, + ctrlKey: e.ctrlKey } }) ); diff --git a/src/client/util/DropConverter.ts b/src/client/util/DropConverter.ts index b2c720d5d..da0ad7efe 100644 --- a/src/client/util/DropConverter.ts +++ b/src/client/util/DropConverter.ts @@ -10,17 +10,17 @@ import { ScriptField } from "../../new_fields/ScriptField"; function makeTemplate(doc: Doc): boolean { const layoutDoc = doc.layout instanceof Doc && doc.layout.isTemplateField ? doc.layout : doc; - const layout = StrCast(layoutDoc.layout).match(/fieldKey={"[^"]*"}/)![0]; - const fieldKey = layout.replace('fieldKey={"', "").replace(/"}$/, ""); + const layout = StrCast(layoutDoc.layout).match(/fieldKey={'[^']*'}/)![0]; + const fieldKey = layout.replace("fieldKey={'", "").replace(/'}$/, ""); const docs = DocListCast(layoutDoc[fieldKey]); let any = false; - docs.map(d => { + docs.forEach(d => { if (!StrCast(d.title).startsWith("-")) { any = true; - return Doc.MakeMetadataFieldTemplate(d, Doc.GetProto(layoutDoc)); + Doc.MakeMetadataFieldTemplate(d, Doc.GetProto(layoutDoc)); + } else if (d.type === DocumentType.COL) { + any = makeTemplate(d) || any; } - if (d.type === DocumentType.COL) return makeTemplate(d); - return false; }); return any; } diff --git a/src/client/util/Import & Export/DirectoryImportBox.tsx b/src/client/util/Import & Export/DirectoryImportBox.tsx index e6a215b2c..5b5bffd8c 100644 --- a/src/client/util/Import & Export/DirectoryImportBox.tsx +++ b/src/client/util/Import & Export/DirectoryImportBox.tsx @@ -122,12 +122,7 @@ export default class DirectoryImportBox extends React.Component<FieldViewProps> await Promise.all(uploads.map(async ({ name, type, clientAccessPath, exifData }) => { const path = Utils.prepend(clientAccessPath); - const options = { - nativeWidth: 300, - width: 300, - title: name - }; - const document = await Docs.Get.DocumentFromType(type, path, options); + const document = await Docs.Get.DocumentFromType(type, path, { width: 300, title: name }); const { data, error } = exifData; if (document) { Doc.GetProto(document).exif = error || Docs.Get.DocumentHierarchyFromJson(data); diff --git a/src/client/util/InteractionUtils.ts b/src/client/util/InteractionUtils.ts index 0c3de66ed..2e4e8c7ca 100644 --- a/src/client/util/InteractionUtils.ts +++ b/src/client/util/InteractionUtils.ts @@ -8,6 +8,17 @@ export namespace InteractionUtils { const REACT_POINTER_PEN_BUTTON = 0; const ERASER_BUTTON = 5; + export function GetMyTargetTouches(e: TouchEvent | React.TouchEvent, prevPoints: Map<number, React.Touch>): React.Touch[] { + const myTouches = new Array<React.Touch>(); + for (let i = 0; i < e.targetTouches.length; i++) { + const pt = e.targetTouches.item(i); + if (pt && prevPoints.has(pt.identifier)) { + myTouches.push(pt); + } + } + return myTouches; + } + 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 @@ -79,6 +90,20 @@ export namespace InteractionUtils { return 0; } + export function IsDragging(oldTouches: Map<number, React.Touch>, newTouches: React.Touch[], leniency: number): boolean { + for (const touch of newTouches) { + if (touch) { + const oldTouch = oldTouches.get(touch.identifier); + if (oldTouch) { + if (TwoPointEuclidist(touch, oldTouch) >= leniency) { + return true; + } + } + } + } + return false; + } + // These might not be very useful anymore, but I'll leave them here for now -syip2 { @@ -134,20 +159,5 @@ export namespace InteractionUtils { // return { type: undefined }; // } // } - - // export function IsDragging(oldTouches: Map<number, React.Touch>, newTouches: TouchList, leniency: number): boolean { - // for (let i = 0; i < newTouches.length; i++) { - // let touch = newTouches.item(i); - // if (touch) { - // let oldTouch = oldTouches.get(touch.identifier); - // if (oldTouch) { - // if (TwoPointEuclidist(touch, oldTouch) >= leniency) { - // return true; - // } - // } - // } - // } - // return false; - // } } }
\ No newline at end of file diff --git a/src/client/util/LinkManager.ts b/src/client/util/LinkManager.ts index fb6f27478..5f3667acc 100644 --- a/src/client/util/LinkManager.ts +++ b/src/client/util/LinkManager.ts @@ -70,7 +70,7 @@ export class LinkManager { } // finds all links that contain the given anchor - public getAllRelatedLinks(anchor: Doc): Doc[] {//List<Doc> { + public getAllRelatedLinks(anchor: Doc): Doc[] { const related = LinkManager.Instance.getAllLinks().filter(link => { const protomatch1 = Doc.AreProtosEqual(anchor, Cast(link.anchor1, Doc, null)); const protomatch2 = Doc.AreProtosEqual(anchor, Cast(link.anchor2, Doc, null)); @@ -244,7 +244,5 @@ export class LinkManager { if (Doc.AreProtosEqual(anchor, linkDoc)) return linkDoc; } } -Scripting.addGlobal(function links(doc: any) { - return new List(LinkManager.Instance.getAllRelatedLinks(doc)); -}); +Scripting.addGlobal(function links(doc: any) { return new List(LinkManager.Instance.getAllRelatedLinks(doc)); });
\ No newline at end of file diff --git a/src/client/util/ParagraphNodeSpec.ts b/src/client/util/ParagraphNodeSpec.ts index 3a993e1ff..0a3b68217 100644 --- a/src/client/util/ParagraphNodeSpec.ts +++ b/src/client/util/ParagraphNodeSpec.ts @@ -34,6 +34,7 @@ const ParagraphNodeSpec: NodeSpec = { color: { default: null }, id: { default: null }, indent: { default: null }, + inset: { default: null }, lineSpacing: { default: null }, // TODO: Add UI to let user edit / clear padding. paddingBottom: { default: null }, @@ -76,6 +77,7 @@ function toDOM(node: Node): DOMOutputSpec { const { align, indent, + inset, lineSpacing, paddingTop, paddingBottom, @@ -105,6 +107,14 @@ function toDOM(node: Node): DOMOutputSpec { style += `padding-bottom: ${paddingBottom};`; } + if (indent) { + style += `text-indent: ${indent}; padding-left: ${indent < 0 ? -indent : undefined};`; + } + + if (inset) { + style += `margin-left: ${inset}; margin-right: ${inset};`; + } + style && (attrs.style = style); if (indent) { diff --git a/src/client/util/ProseMirrorEditorView.tsx b/src/client/util/ProseMirrorEditorView.tsx new file mode 100644 index 000000000..b42adfbb4 --- /dev/null +++ b/src/client/util/ProseMirrorEditorView.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { EditorView } from "prosemirror-view"; +import { EditorState } from "prosemirror-state"; + +export interface ProseMirrorEditorViewProps { + /* EditorState instance to use. */ + editorState: EditorState; + /* Called when EditorView produces new EditorState. */ + onEditorState: (editorState: EditorState) => any; +} + +/** + * This wraps ProseMirror's EditorView into React component. + * This code was found on https://discuss.prosemirror.net/t/using-with-react/904 + */ +export class ProseMirrorEditorView extends React.Component<ProseMirrorEditorViewProps> { + + private _editorView?: EditorView; + + _createEditorView = (element: HTMLDivElement | null) => { + if (element !== null) { + this._editorView = new EditorView(element, { + state: this.props.editorState, + dispatchTransaction: this.dispatchTransaction, + }); + } + } + + dispatchTransaction = (tx: any) => { + // In case EditorView makes any modification to a state we funnel those + // modifications up to the parent and apply to the EditorView itself. + const editorState = this.props.editorState.apply(tx); + if (this._editorView) { + this._editorView.updateState(editorState); + } + this.props.onEditorState(editorState); + } + + focus() { + if (this._editorView) { + this._editorView.focus(); + } + } + + componentWillReceiveProps(nextProps: { editorState: EditorState<any>; }) { + // In case we receive new EditorState through props — we apply it to the + // EditorView instance. + if (this._editorView) { + if (nextProps.editorState !== this.props.editorState) { + this._editorView.updateState(nextProps.editorState); + } + } + } + + componentWillUnmount() { + if (this._editorView) { + this._editorView.destroy(); + } + } + + shouldComponentUpdate() { + // Note that EditorView manages its DOM itself so we'd ratrher don't mess + // with it. + return false; + } + + render() { + // Render just an empty div which is then used as a container for an + // EditorView instance. + return ( + <div ref={this._createEditorView} /> + ); + } +}
\ No newline at end of file diff --git a/src/client/util/ProsemirrorExampleTransfer.ts b/src/client/util/ProsemirrorExampleTransfer.ts index f1fa6f11d..c028dbf8b 100644 --- a/src/client/util/ProsemirrorExampleTransfer.ts +++ b/src/client/util/ProsemirrorExampleTransfer.ts @@ -6,6 +6,8 @@ import { liftListItem, sinkListItem } from "./prosemirrorPatches.js"; import { splitListItem, wrapInList, } from "prosemirror-schema-list"; import { EditorState, Transaction, TextSelection } from "prosemirror-state"; import { TooltipTextMenu } from "./TooltipTextMenu"; +import { SelectionManager } from "./SelectionManager"; +import { FormattedTextBox } from "../views/nodes/FormattedTextBox"; const mac = typeof navigator !== "undefined" ? /Mac/.test(navigator.platform) : false; @@ -46,7 +48,11 @@ export default function buildKeymap<S extends Schema<any>>(schema: S, mapKeys?: bind("Alt-ArrowUp", joinUp); bind("Alt-ArrowDown", joinDown); bind("Mod-BracketLeft", lift); - bind("Escape", selectParentNode); + bind("Escape", (state: EditorState<S>, dispatch: (tx: Transaction<S>) => void) => { + dispatch(state.tr.setSelection(TextSelection.create(state.doc, state.selection.from, state.selection.from))); + (document.activeElement as any).blur?.(); + SelectionManager.DeselectAll(); + }); bind("Mod-b", toggleMark(schema.marks.strong)); bind("Mod-B", toggleMark(schema.marks.strong)); @@ -105,8 +111,6 @@ export default function buildKeymap<S extends Schema<any>>(schema: S, mapKeys?: return true; }); - bind("Mod-s", TooltipTextMenu.insertStar); - bind("Tab", (state: EditorState<S>, dispatch: (tx: Transaction<S>) => void) => { const ref = state.selection; const range = ref.$from.blockRange(ref.$to); diff --git a/src/client/util/RichTextMenu.scss b/src/client/util/RichTextMenu.scss new file mode 100644 index 000000000..43cc23ecd --- /dev/null +++ b/src/client/util/RichTextMenu.scss @@ -0,0 +1,121 @@ +@import "../views/globalCssVariables"; + +.button-dropdown-wrapper { + position: relative; + + .dropdown-button { + width: 15px; + padding-left: 5px; + padding-right: 5px; + } + + .dropdown-button-combined { + width: 50px; + display: flex; + justify-content: space-between; + + svg:nth-child(2) { + margin-top: 2px; + } + } + + .dropdown { + position: absolute; + top: 35px; + left: 0; + background-color: #323232; + color: $light-color-secondary; + border: 1px solid #4d4d4d; + border-radius: 0 6px 6px 6px; + box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25); + min-width: 150px; + padding: 5px; + font-size: 12px; + z-index: 10001; + + button { + background-color: #323232; + border: 1px solid black; + border-radius: 1px; + padding: 6px; + margin: 5px 0; + font-size: 10px; + + &:hover { + background-color: black; + } + + &:last-child { + margin-bottom: 0; + } + } + } + + input { + color: black; + } +} + +.link-menu { + .divider { + background-color: white; + height: 1px; + width: 100%; + } +} + +.color-preview-button { + .color-preview { + width: 100%; + height: 3px; + margin-top: 3px; + } +} + +.color-wrapper { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + + button.color-button { + width: 20px; + height: 20px; + border-radius: 15px !important; + margin: 3px; + border: 2px solid transparent !important; + padding: 3px; + + &.active { + border: 2px solid white !important; + } + } +} + +select { + background-color: #323232; + color: white; + border: 1px solid black; + // border-top: none; + // border-bottom: none; + font-size: 12px; + height: 100%; + margin-right: 3px; + + &:focus, + &:hover { + background-color: black; + } + + &::-ms-expand { + color: white; + } +} + +.row-2 { + display: flex; + justify-content: space-between; + + >div { + display: flex; + } +}
\ No newline at end of file diff --git a/src/client/util/RichTextMenu.tsx b/src/client/util/RichTextMenu.tsx new file mode 100644 index 000000000..419d7caf9 --- /dev/null +++ b/src/client/util/RichTextMenu.tsx @@ -0,0 +1,855 @@ +import React = require("react"); +import AntimodeMenu from "../views/AntimodeMenu"; +import { observable, action, } from "mobx"; +import { observer } from "mobx-react"; +import { Mark, MarkType, Node as ProsNode, NodeType, ResolvedPos, Schema } from "prosemirror-model"; +import { schema } from "./RichTextSchema"; +import { EditorView } from "prosemirror-view"; +import { EditorState, NodeSelection, TextSelection } from "prosemirror-state"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { IconProp, library } from '@fortawesome/fontawesome-svg-core'; +import { faBold, faItalic, faUnderline, faStrikethrough, faSubscript, faSuperscript, faIndent, faEyeDropper, faCaretDown, faPalette, faHighlighter, faLink, faPaintRoller, faSleigh } from "@fortawesome/free-solid-svg-icons"; +import { MenuItem, Dropdown } from "prosemirror-menu"; +import { updateBullets } from "./ProsemirrorExampleTransfer"; +import { FieldViewProps } from "../views/nodes/FieldView"; +import { NumCast, Cast, StrCast } from "../../new_fields/Types"; +import { FormattedTextBoxProps } from "../views/nodes/FormattedTextBox"; +import { unimplementedFunction, Utils } from "../../Utils"; +import { wrapInList } from "prosemirror-schema-list"; +import { PastelSchemaPalette, DarkPastelSchemaPalette } from '../../new_fields/SchemaHeaderField'; +import "./RichTextMenu.scss"; +import { DocServer } from "../DocServer"; +import { Doc } from "../../new_fields/Doc"; +import { SelectionManager } from "./SelectionManager"; +import { LinkManager } from "./LinkManager"; +const { toggleMark, setBlockType } = require("prosemirror-commands"); + +library.add(faBold, faItalic, faUnderline, faStrikethrough, faSuperscript, faSubscript, faIndent, faEyeDropper, faCaretDown, faPalette, faHighlighter, faLink, faPaintRoller); + +@observer +export default class RichTextMenu extends AntimodeMenu { + static Instance: RichTextMenu; + public overMenu: boolean = false; // kind of hacky way to prevent selects not being selectable + + private view?: EditorView; + private editorProps: FieldViewProps & FormattedTextBoxProps | undefined; + + private fontSizeOptions: { mark: Mark | null, title: string, label: string, command: any, hidden?: boolean, style?: {} }[]; + private fontFamilyOptions: { mark: Mark | null, title: string, label: string, command: any, hidden?: boolean, style?: {} }[]; + private listTypeOptions: { node: NodeType | any | null, title: string, label: string, command: any, style?: {} }[]; + private fontColors: (string | undefined)[]; + private highlightColors: (string | undefined)[]; + + @observable private boldActive: boolean = false; + @observable private italicsActive: boolean = false; + @observable private underlineActive: boolean = false; + @observable private strikethroughActive: boolean = false; + @observable private subscriptActive: boolean = false; + @observable private superscriptActive: boolean = false; + + @observable private activeFontSize: string = ""; + @observable private activeFontFamily: string = ""; + @observable private activeListType: string = ""; + + @observable private brushIsEmpty: boolean = true; + @observable private brushMarks: Set<Mark> = new Set(); + @observable private showBrushDropdown: boolean = false; + + @observable private activeFontColor: string = "black"; + @observable private showColorDropdown: boolean = false; + + @observable private activeHighlightColor: string = "transparent"; + @observable private showHighlightDropdown: boolean = false; + + @observable private currentLink: string | undefined = ""; + @observable private showLinkDropdown: boolean = false; + + constructor(props: Readonly<{}>) { + super(props); + RichTextMenu.Instance = this; + this._canFade = false; + + this.fontSizeOptions = [ + { mark: schema.marks.pFontSize.create({ fontSize: 7 }), title: "Set font size", label: "7pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 8 }), title: "Set font size", label: "8pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 9 }), title: "Set font size", label: "8pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 10 }), title: "Set font size", label: "10pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 12 }), title: "Set font size", label: "12pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 14 }), title: "Set font size", label: "14pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 16 }), title: "Set font size", label: "16pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 18 }), title: "Set font size", label: "18pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 20 }), title: "Set font size", label: "20pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 24 }), title: "Set font size", label: "24pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 32 }), title: "Set font size", label: "32pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 48 }), title: "Set font size", label: "48pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 72 }), title: "Set font size", label: "72pt", command: this.changeFontSize }, + { mark: null, title: "", label: "various", command: unimplementedFunction, hidden: true }, + { mark: null, title: "", label: "13pt", command: unimplementedFunction, hidden: true }, // this is here because the default size is 13, but there is no actual 13pt option + ]; + + this.fontFamilyOptions = [ + { mark: schema.marks.pFontFamily.create({ family: "Times New Roman" }), title: "Set font family", label: "Times New Roman", command: this.changeFontFamily, style: { fontFamily: "Times New Roman" } }, + { mark: schema.marks.pFontFamily.create({ family: "Arial" }), title: "Set font family", label: "Arial", command: this.changeFontFamily, style: { fontFamily: "Arial" } }, + { mark: schema.marks.pFontFamily.create({ family: "Georgia" }), title: "Set font family", label: "Georgia", command: this.changeFontFamily, style: { fontFamily: "Georgia" } }, + { mark: schema.marks.pFontFamily.create({ family: "Comic Sans MS" }), title: "Set font family", label: "Comic Sans MS", command: this.changeFontFamily, style: { fontFamily: "Comic Sans MS" } }, + { mark: schema.marks.pFontFamily.create({ family: "Tahoma" }), title: "Set font family", label: "Tahoma", command: this.changeFontFamily, style: { fontFamily: "Tahoma" } }, + { mark: schema.marks.pFontFamily.create({ family: "Impact" }), title: "Set font family", label: "Impact", command: this.changeFontFamily, style: { fontFamily: "Impact" } }, + { mark: schema.marks.pFontFamily.create({ family: "Crimson Text" }), title: "Set font family", label: "Crimson Text", command: this.changeFontFamily, style: { fontFamily: "Crimson Text" } }, + { mark: null, title: "", label: "various", command: unimplementedFunction, hidden: true }, + // { mark: null, title: "", label: "default", command: unimplementedFunction, hidden: true }, + ]; + + this.listTypeOptions = [ + { node: schema.nodes.ordered_list.create({ mapStyle: "bullet" }), title: "Set list type", label: ":", command: this.changeListType }, + { node: schema.nodes.ordered_list.create({ mapStyle: "decimal" }), title: "Set list type", label: "1.1", command: this.changeListType }, + { node: schema.nodes.ordered_list.create({ mapStyle: "multi" }), title: "Set list type", label: "1.A", command: this.changeListType }, + { node: undefined, title: "Set list type", label: "Remove", command: this.changeListType }, + ]; + + this.fontColors = [ + DarkPastelSchemaPalette.get("pink2"), + DarkPastelSchemaPalette.get("purple4"), + DarkPastelSchemaPalette.get("bluegreen1"), + DarkPastelSchemaPalette.get("yellow4"), + DarkPastelSchemaPalette.get("red2"), + DarkPastelSchemaPalette.get("bluegreen7"), + DarkPastelSchemaPalette.get("bluegreen5"), + DarkPastelSchemaPalette.get("orange1"), + "#757472", + "#000" + ]; + + this.highlightColors = [ + PastelSchemaPalette.get("pink2"), + PastelSchemaPalette.get("purple4"), + PastelSchemaPalette.get("bluegreen1"), + PastelSchemaPalette.get("yellow4"), + PastelSchemaPalette.get("red2"), + PastelSchemaPalette.get("bluegreen7"), + PastelSchemaPalette.get("bluegreen5"), + PastelSchemaPalette.get("orange1"), + "white", + "transparent" + ]; + } + + @action + changeView(view: EditorView) { + this.view = view; + } + + update(view: EditorView, lastState: EditorState | undefined) { + this.updateFromDash(view, lastState, this.editorProps); + } + + @action + public async updateFromDash(view: EditorView, lastState: EditorState | undefined, props: any) { + if (!view) { + console.log("no editor? why?"); + return; + } + this.view = view; + const state = view.state; + props && (this.editorProps = props); + + // Don't do anything if the document/selection didn't change + if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) return; + + // update active marks + const activeMarks = this.getActiveMarksOnSelection(); + this.setActiveMarkButtons(activeMarks); + + // update active font family and size + const active = this.getActiveFontStylesOnSelection(); + const activeFamilies = active && active.get("families"); + const activeSizes = active && active.get("sizes"); + + this.activeFontFamily = !activeFamilies || activeFamilies.length === 0 ? "Arial" : activeFamilies.length === 1 ? String(activeFamilies[0]) : "various"; + this.activeFontSize = !activeSizes || activeSizes.length === 0 ? "13pt" : activeSizes.length === 1 ? String(activeSizes[0]) + "pt" : "various"; + + // update link in current selection + const targetTitle = await this.getTextLinkTargetTitle(); + this.setCurrentLink(targetTitle); + } + + setMark = (mark: Mark, state: EditorState<any>, dispatch: any) => { + if (mark) { + const node = (state.selection as NodeSelection).node; + if (node?.type === schema.nodes.ordered_list) { + let attrs = node.attrs; + if (mark.type === schema.marks.pFontFamily) attrs = { ...attrs, setFontFamily: mark.attrs.family }; + if (mark.type === schema.marks.pFontSize) attrs = { ...attrs, setFontSize: mark.attrs.fontSize }; + if (mark.type === schema.marks.pFontColor) attrs = { ...attrs, setFontColor: mark.attrs.color }; + const tr = updateBullets(state.tr.setNodeMarkup(state.selection.from, node.type, attrs), state.schema); + dispatch(tr.setSelection(new NodeSelection(tr.doc.resolve(state.selection.from)))); + } else { + toggleMark(mark.type, mark.attrs)(state, (tx: any) => { + const { from, $from, to, empty } = tx.selection; + if (!tx.doc.rangeHasMark(from, to, mark.type)) { + toggleMark(mark.type, mark.attrs)({ tr: tx, doc: tx.doc, selection: tx.selection, storedMarks: tx.storedMarks }, dispatch); + } else dispatch(tx); + }); + } + } + } + + // finds font sizes and families in selection + getActiveFontStylesOnSelection() { + if (!this.view) return; + + const activeFamilies: string[] = []; + const activeSizes: string[] = []; + const state = this.view.state; + const pos = this.view.state.selection.$from; + const ref_node = this.reference_node(pos); + if (ref_node && ref_node !== this.view.state.doc && ref_node.isText) { + ref_node.marks.forEach(m => { + m.type === state.schema.marks.pFontFamily && activeFamilies.push(m.attrs.family); + m.type === state.schema.marks.pFontSize && activeSizes.push(String(m.attrs.fontSize) + "pt"); + }); + } + + const styles = new Map<String, String[]>(); + styles.set("families", activeFamilies); + styles.set("sizes", activeSizes); + return styles; + } + + getMarksInSelection(state: EditorState<any>) { + const found = new Set<Mark>(); + const { from, to } = state.selection as TextSelection; + state.doc.nodesBetween(from, to, (node) => node.marks.forEach(m => found.add(m))); + return found; + } + + //finds all active marks on selection in given group + getActiveMarksOnSelection() { + if (!this.view) return; + + const markGroup = [schema.marks.strong, schema.marks.em, schema.marks.underline, schema.marks.strikethrough, schema.marks.superscript, schema.marks.subscript]; + if (this.view.state.storedMarks) return this.view.state.storedMarks.map(mark => mark.type); + //current selection + const { empty, ranges, $to } = this.view.state.selection as TextSelection; + const state = this.view.state; + let activeMarks: MarkType[] = []; + if (!empty) { + activeMarks = markGroup.filter(mark => { + const has = false; + for (let i = 0; !has && i < ranges.length; i++) { + return state.doc.rangeHasMark(ranges[i].$from.pos, ranges[i].$to.pos, mark); + } + return false; + }); + } + else { + const pos = this.view.state.selection.$from; + const ref_node: ProsNode | null = this.reference_node(pos); + if (ref_node !== null && ref_node !== this.view.state.doc) { + if (ref_node.isText) { + } + else { + return []; + } + activeMarks = markGroup.filter(mark_type => { + if (mark_type === state.schema.marks.pFontSize) { + return ref_node.marks.some(m => m.type.name === state.schema.marks.pFontSize.name); + } + const mark = state.schema.mark(mark_type); + return ref_node.marks.includes(mark); + }); + } + } + return activeMarks; + } + + destroy() { + } + + @action + setActiveMarkButtons(activeMarks: MarkType[] | undefined) { + if (!activeMarks) return; + + this.boldActive = false; + this.italicsActive = false; + this.underlineActive = false; + this.strikethroughActive = false; + this.subscriptActive = false; + this.superscriptActive = false; + + activeMarks.forEach(mark => { + switch (mark.name) { + case "strong": this.boldActive = true; break; + case "em": this.italicsActive = true; break; + case "underline": this.underlineActive = true; break; + case "strikethrough": this.strikethroughActive = true; break; + case "subscript": this.subscriptActive = true; break; + case "superscript": this.superscriptActive = true; break; + } + }); + } + + createButton(faIcon: string, title: string, isActive: boolean = false, command?: any, onclick?: any) { + const self = this; + function onClick(e: React.PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.view && command && command(self.view.state, self.view.dispatch, self.view); + self.view && onclick && onclick(self.view.state, self.view.dispatch, self.view); + self.setActiveMarkButtons(self.getActiveMarksOnSelection()); + } + + return ( + <button className={"antimodeMenu-button" + (isActive ? " active" : "")} title={title} onPointerDown={onClick}> + <FontAwesomeIcon icon={faIcon as IconProp} size="lg" /> + </button> + ); + } + + createMarksDropdown(activeOption: string, options: { mark: Mark | null, title: string, label: string, command: (mark: Mark, view: EditorView) => void, hidden?: boolean, style?: {} }[]): JSX.Element { + const items = options.map(({ title, label, hidden, style }) => { + if (hidden) { + return label === activeOption ? + <option value={label} title={title} style={style ? style : {}} selected hidden>{label}</option> : + <option value={label} title={title} style={style ? style : {}} hidden>{label}</option>; + } + return label === activeOption ? + <option value={label} title={title} style={style ? style : {}} selected>{label}</option> : + <option value={label} title={title} style={style ? style : {}}>{label}</option>; + }); + + const self = this; + function onChange(e: React.ChangeEvent<HTMLSelectElement>) { + e.stopPropagation(); + e.preventDefault(); + options.forEach(({ label, mark, command }) => { + if (e.target.value === label) { + self.view && mark && command(mark, self.view); + } + }); + } + return <select onChange={onChange}>{items}</select>; + } + + createNodesDropdown(activeOption: string, options: { node: NodeType | any | null, title: string, label: string, command: (node: NodeType | any) => void, hidden?: boolean, style?: {} }[]): JSX.Element { + const items = options.map(({ title, label, hidden, style }) => { + if (hidden) { + return label === activeOption ? + <option value={label} title={title} style={style ? style : {}} selected hidden>{label}</option> : + <option value={label} title={title} style={style ? style : {}} hidden>{label}</option>; + } + return label === activeOption ? + <option value={label} title={title} style={style ? style : {}} selected>{label}</option> : + <option value={label} title={title} style={style ? style : {}}>{label}</option>; + }); + + const self = this; + function onChange(val: string) { + options.forEach(({ label, node, command }) => { + if (val === label) { + self.view && node && command(node); + } + }); + } + return <select onChange={e => onChange(e.target.value)}>{items}</select>; + } + + changeFontSize = (mark: Mark, view: EditorView) => { + const size = mark.attrs.fontSize; + if (this.editorProps) { + const ruleProvider = this.editorProps.ruleProvider; + const heading = NumCast(this.editorProps.Document.heading); + if (ruleProvider && heading) { + ruleProvider["ruleSize_" + heading] = size; + } + } + this.setMark(view.state.schema.marks.pFontSize.create({ fontSize: size }), view.state, view.dispatch); + } + + changeFontFamily = (mark: Mark, view: EditorView) => { + const fontName = mark.attrs.family; + if (this.editorProps) { + const ruleProvider = this.editorProps.ruleProvider; + const heading = NumCast(this.editorProps.Document.heading); + if (ruleProvider && heading) { + ruleProvider["ruleFont_" + heading] = fontName; + } + } + this.setMark(view.state.schema.marks.pFontFamily.create({ family: fontName }), view.state, view.dispatch); + } + + // TODO: remove doesn't work + //remove all node type and apply the passed-in one to the selected text + changeListType = (nodeType: NodeType | undefined) => { + if (!this.view) return; + + if (nodeType === schema.nodes.bullet_list) { + wrapInList(nodeType)(this.view.state, this.view.dispatch); + } else { + const marks = this.view.state.storedMarks || (this.view.state.selection.$to.parentOffset && this.view.state.selection.$from.marks()); + if (!wrapInList(schema.nodes.ordered_list)(this.view.state, (tx2: any) => { + const tx3 = updateBullets(tx2, schema, nodeType && (nodeType as any).attrs.mapStyle); + marks && tx3.ensureMarks([...marks]); + marks && tx3.setStoredMarks([...marks]); + + this.view!.dispatch(tx2); + })) { + const tx2 = this.view.state.tr; + const tx3 = updateBullets(tx2, schema, nodeType && (nodeType as any).attrs.mapStyle); + marks && tx3.ensureMarks([...marks]); + marks && tx3.setStoredMarks([...marks]); + + this.view.dispatch(tx3); + } + } + } + + insertSummarizer(state: EditorState<any>, dispatch: any) { + if (state.selection.empty) return false; + const mark = state.schema.marks.summarize.create(); + const tr = state.tr; + tr.addMark(state.selection.from, state.selection.to, mark); + const content = tr.selection.content(); + const newNode = state.schema.nodes.summary.create({ visibility: false, text: content, textslice: content.toJSON() }); + dispatch && dispatch(tr.replaceSelectionWith(newNode).removeMark(tr.selection.from - 1, tr.selection.from, mark)); + return true; + } + + @action toggleBrushDropdown() { this.showBrushDropdown = !this.showBrushDropdown; } + + createBrushButton() { + const self = this; + function onBrushClick(e: React.PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.view && self.fillBrush(self.view.state, self.view.dispatch); + } + + let label = "Stored marks: "; + if (this.brushMarks && this.brushMarks.size > 0) { + this.brushMarks.forEach((mark: Mark) => { + const markType = mark.type; + label += markType.name; + label += ", "; + }); + label = label.substring(0, label.length - 2); + } else { + label = "No marks are currently stored"; + } + + const button = + <button className="antimodeMenu-button" title="" onPointerDown={onBrushClick} style={this.brushMarks && this.brushMarks.size > 0 ? { backgroundColor: "121212" } : {}}> + <FontAwesomeIcon icon="paint-roller" size="lg" style={{ transition: "transform 0.1s", transform: this.brushMarks && this.brushMarks.size > 0 ? "rotate(45deg)" : "" }} /> + </button>; + + const dropdownContent = + <div className="dropdown"> + <p>{label}</p> + <button onPointerDown={this.clearBrush}>Clear brush</button> + {/* <input placeholder="Enter URL"></input> */} + </div>; + + return ( + <ButtonDropdown view={this.view} button={button} dropdownContent={dropdownContent} /> + ); + } + + @action + clearBrush() { + RichTextMenu.Instance.brushIsEmpty = true; + RichTextMenu.Instance.brushMarks = new Set(); + } + + @action + fillBrush(state: EditorState<any>, dispatch: any) { + if (!this.view) return; + + if (this.brushIsEmpty) { + const selected_marks = this.getMarksInSelection(this.view.state); + if (selected_marks.size >= 0) { + this.brushMarks = selected_marks; + this.brushIsEmpty = !this.brushIsEmpty; + } + } + else { + const { from, to, $from } = this.view.state.selection; + if (!this.view.state.selection.empty && $from && $from.nodeAfter) { + if (this.brushMarks && to - from > 0) { + this.view.dispatch(this.view.state.tr.removeMark(from, to)); + Array.from(this.brushMarks).filter(m => m.type !== schema.marks.user_mark).forEach((mark: Mark) => { + this.setMark(mark, this.view!.state, this.view!.dispatch); + }); + } + } + else { + this.brushIsEmpty = !this.brushIsEmpty; + } + } + } + + @action toggleColorDropdown() { this.showColorDropdown = !this.showColorDropdown; } + @action setActiveColor(color: string) { this.activeFontColor = color; } + + createColorButton() { + const self = this; + function onColorClick(e: React.PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.view && self.insertColor(self.activeFontColor, self.view.state, self.view.dispatch); + } + function changeColor(e: React.PointerEvent, color: string) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.setActiveColor(color); + self.view && self.insertColor(self.activeFontColor, self.view.state, self.view.dispatch); + } + + const button = + <button className="antimodeMenu-button color-preview-button" title="" onPointerDown={onColorClick}> + <FontAwesomeIcon icon="palette" size="lg" /> + <div className="color-preview" style={{ backgroundColor: this.activeFontColor }}></div> + </button>; + + const dropdownContent = + <div className="dropdown"> + <p>Change font color:</p> + <div className="color-wrapper"> + {this.fontColors.map(color => { + if (color) { + return this.activeFontColor === color ? + <button className="color-button active" style={{ backgroundColor: color }} onPointerDown={e => changeColor(e, color)}></button> : + <button className="color-button" style={{ backgroundColor: color }} onPointerDown={e => changeColor(e, color)}></button>; + } + })} + </div> + </div>; + + return ( + <ButtonDropdown view={this.view} button={button} dropdownContent={dropdownContent} /> + ); + } + + public insertColor(color: String, state: EditorState<any>, dispatch: any) { + const colorMark = state.schema.mark(state.schema.marks.pFontColor, { color: color }); + if (state.selection.empty) { + dispatch(state.tr.addStoredMark(colorMark)); + return false; + } + this.setMark(colorMark, state, dispatch); + } + + @action toggleHighlightDropdown() { this.showHighlightDropdown = !this.showHighlightDropdown; } + @action setActiveHighlight(color: string) { this.activeHighlightColor = color; } + + createHighlighterButton() { + const self = this; + function onHighlightClick(e: React.PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.view && self.insertHighlight(self.activeHighlightColor, self.view.state, self.view.dispatch); + } + function changeHighlight(e: React.PointerEvent, color: string) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.setActiveHighlight(color); + self.view && self.insertHighlight(self.activeHighlightColor, self.view.state, self.view.dispatch); + } + + const button = + <button className="antimodeMenu-button color-preview-button" title="" onPointerDown={onHighlightClick}> + <FontAwesomeIcon icon="highlighter" size="lg" /> + <div className="color-preview" style={{ backgroundColor: this.activeHighlightColor }}></div> + </button>; + + const dropdownContent = + <div className="dropdown"> + <p>Change highlight color:</p> + <div className="color-wrapper"> + {this.highlightColors.map(color => { + if (color) { + return this.activeHighlightColor === color ? + <button className="color-button active" style={{ backgroundColor: color }} onPointerDown={e => changeHighlight(e, color)}>{color === "transparent" ? "X" : ""}</button> : + <button className="color-button" style={{ backgroundColor: color }} onPointerDown={e => changeHighlight(e, color)}>{color === "transparent" ? "X" : ""}</button>; + } + })} + </div> + </div>; + + return ( + <ButtonDropdown view={this.view} button={button} dropdownContent={dropdownContent} /> + ); + } + + insertHighlight(color: String, state: EditorState<any>, dispatch: any) { + if (state.selection.empty) return false; + toggleMark(state.schema.marks.marker, { highlight: color })(state, dispatch); + } + + @action toggleLinkDropdown() { this.showLinkDropdown = !this.showLinkDropdown; } + @action setCurrentLink(link: string) { this.currentLink = link; } + + createLinkButton() { + const self = this; + + function onLinkChange(e: React.ChangeEvent<HTMLInputElement>) { + self.setCurrentLink(e.target.value); + } + + const link = this.currentLink ? this.currentLink : ""; + + const button = <FontAwesomeIcon icon="link" size="lg" />; + + const dropdownContent = + <div className="dropdown link-menu"> + <p>Linked to:</p> + <input value={link} placeholder="Enter URL" onChange={onLinkChange} /> + <button className="make-button" onPointerDown={e => this.makeLinkToURL(link, "onRight")}>Apply hyperlink</button> + <div className="divider"></div> + <button className="remove-button" onPointerDown={e => this.deleteLink()}>Remove link</button> + </div>; + + return ( + <ButtonDropdown view={this.view} button={button} dropdownContent={dropdownContent} openDropdownOnButton={true} /> + ); + } + + async getTextLinkTargetTitle() { + if (!this.view) return; + + const node = this.view.state.selection.$from.nodeAfter; + const link = node && node.marks.find(m => m.type.name === "link"); + if (link) { + const href = link.attrs.href; + if (href) { + if (href.indexOf(Utils.prepend("/doc/")) === 0) { + const linkclicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0]; + if (linkclicked) { + const linkDoc = await DocServer.GetRefField(linkclicked); + if (linkDoc instanceof Doc) { + const anchor1 = await Cast(linkDoc.anchor1, Doc); + const anchor2 = await Cast(linkDoc.anchor2, Doc); + const currentDoc = SelectionManager.SelectedDocuments().length && SelectionManager.SelectedDocuments()[0].props.Document; + if (currentDoc && anchor1 && anchor2) { + if (Doc.AreProtosEqual(currentDoc, anchor1)) { + return StrCast(anchor2.title); + } + if (Doc.AreProtosEqual(currentDoc, anchor2)) { + return StrCast(anchor1.title); + } + } + } + } + } else { + return href; + } + } else { + return link.attrs.title; + } + } + } + + // TODO: should check for valid URL + makeLinkToURL = (target: String, lcoation: string) => { + if (!this.view) return; + + let node = this.view.state.selection.$from.nodeAfter; + let link = this.view.state.schema.mark(this.view.state.schema.marks.link, { href: target, location: location }); + this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link)); + this.view.dispatch(this.view.state.tr.addMark(this.view.state.selection.from, this.view.state.selection.to, link)); + node = this.view.state.selection.$from.nodeAfter; + link = node && node.marks.find(m => m.type.name === "link"); + } + + deleteLink = () => { + if (!this.view) return; + + const node = this.view.state.selection.$from.nodeAfter; + const link = node && node.marks.find(m => m.type === this.view!.state.schema.marks.link); + const href = link!.attrs.href; + if (href) { + if (href.indexOf(Utils.prepend("/doc/")) === 0) { + const linkclicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0]; + if (linkclicked) { + DocServer.GetRefField(linkclicked).then(async linkDoc => { + if (linkDoc instanceof Doc) { + LinkManager.Instance.deleteLink(linkDoc); + this.view!.dispatch(this.view!.state.tr.removeMark(this.view!.state.selection.from, this.view!.state.selection.to, this.view!.state.schema.marks.link)); + } + }); + } + } else { + if (node) { + const { tr, schema, selection } = this.view.state; + const extension = this.linkExtend(selection.$anchor, href); + this.view.dispatch(tr.removeMark(extension.from, extension.to, schema.marks.link)); + } + } + } + } + + linkExtend($start: ResolvedPos, href: string) { + const mark = this.view!.state.schema.marks.link; + + let startIndex = $start.index(); + let endIndex = $start.indexAfter(); + + while (startIndex > 0 && $start.parent.child(startIndex - 1).marks.filter(m => m.type === mark && m.attrs.href === href).length) startIndex--; + while (endIndex < $start.parent.childCount && $start.parent.child(endIndex).marks.filter(m => m.type === mark && m.attrs.href === href).length) endIndex++; + + let startPos = $start.start(); + let endPos = startPos; + for (let i = 0; i < endIndex; i++) { + const size = $start.parent.child(i).nodeSize; + if (i < startIndex) startPos += size; + endPos += size; + } + return { from: startPos, to: endPos }; + } + + reference_node(pos: ResolvedPos<any>): ProsNode | null { + if (!this.view) return null; + + let ref_node: ProsNode = this.view.state.doc; + if (pos.nodeBefore !== null && pos.nodeBefore !== undefined) { + ref_node = pos.nodeBefore; + } + else if (pos.nodeAfter !== null && pos.nodeAfter !== undefined) { + ref_node = pos.nodeAfter; + } + else if (pos.pos > 0) { + let skip = false; + for (let i: number = pos.pos - 1; i > 0; i--) { + this.view.state.doc.nodesBetween(i, pos.pos, (node: ProsNode) => { + if (node.isLeaf && !skip) { + ref_node = node; + skip = true; + } + + }); + } + } + if (!ref_node.isLeaf && ref_node.childCount > 0) { + ref_node = ref_node.child(0); + } + return ref_node; + } + + @action onPointerEnter(e: React.PointerEvent) { RichTextMenu.Instance.overMenu = true; } + @action onPointerLeave(e: React.PointerEvent) { RichTextMenu.Instance.overMenu = false; } + + @action + toggleMenuPin = (e: React.MouseEvent) => { + this.Pinned = !this.Pinned; + if (!this.Pinned) { + this.fadeOut(true); + } + } + + render() { + + const row1 = <div className="antimodeMenu-row">{[ + this.createButton("bold", "Bold", this.boldActive, toggleMark(schema.marks.strong)), + this.createButton("italic", "Italic", this.italicsActive, toggleMark(schema.marks.em)), + this.createButton("underline", "Underline", this.underlineActive, toggleMark(schema.marks.underline)), + this.createButton("strikethrough", "Strikethrough", this.strikethroughActive, toggleMark(schema.marks.strikethrough)), + this.createButton("superscript", "Superscript", this.superscriptActive, toggleMark(schema.marks.superscript)), + this.createButton("subscript", "Subscript", this.subscriptActive, toggleMark(schema.marks.subscript)), + this.createColorButton(), + this.createHighlighterButton(), + this.createLinkButton(), + this.createBrushButton(), + this.createButton("indent", "Summarize", undefined, this.insertSummarizer), + ]}</div>; + + const row2 = <div className="antimodeMenu-row row-2"> + <div> + {[this.createMarksDropdown(this.activeFontSize, this.fontSizeOptions), + this.createMarksDropdown(this.activeFontFamily, this.fontFamilyOptions), + this.createNodesDropdown(this.activeListType, this.listTypeOptions)]} + </div> + <div> + <button className="antimodeMenu-button" title="Pin menu" onClick={this.toggleMenuPin} style={this.Pinned ? { backgroundColor: "#121212" } : {}}> + <FontAwesomeIcon icon="thumbtack" size="lg" style={{ transition: "transform 0.1s", transform: this.Pinned ? "rotate(45deg)" : "" }} /> + </button> + {this.getDragger()} + </div> + </div>; + + return ( + <div className="richTextMenu" onPointerEnter={this.onPointerEnter} onPointerLeave={this.onPointerLeave}> + {this.getElementWithRows([row1, row2], 2, false)} + </div> + ); + } +} + +interface ButtonDropdownProps { + view?: EditorView; + button: JSX.Element; + dropdownContent: JSX.Element; + openDropdownOnButton?: boolean; +} + +@observer +class ButtonDropdown extends React.Component<ButtonDropdownProps> { + + @observable private showDropdown: boolean = false; + private ref: HTMLDivElement | null = null; + + componentDidMount() { + document.addEventListener("pointerdown", this.onBlur); + } + + componentWillUnmount() { + document.removeEventListener("pointerdown", this.onBlur); + } + + @action + setShowDropdown(show: boolean) { + this.showDropdown = show; + } + @action + toggleDropdown() { + this.showDropdown = !this.showDropdown; + } + + onDropdownClick = (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + this.props.view && this.props.view.focus(); + this.toggleDropdown(); + } + + onBlur = (e: PointerEvent) => { + setTimeout(() => { + if (this.ref !== null && !this.ref.contains(e.target as Node)) { + this.setShowDropdown(false); + } + }, 0); + } + + render() { + return ( + <div className="button-dropdown-wrapper" ref={node => this.ref = node}> + {this.props.openDropdownOnButton ? + <button className="antimodeMenu-button dropdown-button-combined" onPointerDown={this.onDropdownClick}> + {this.props.button} + <FontAwesomeIcon icon="caret-down" size="sm" /> + </button> : + <> + {this.props.button} + <button className="dropdown-button antimodeMenu-button" onPointerDown={this.onDropdownClick}> + <FontAwesomeIcon icon="caret-down" size="sm" /> + </button> + </>} + + {this.showDropdown ? this.props.dropdownContent : <></>} + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/util/RichTextRules.ts b/src/client/util/RichTextRules.ts index bf365579a..29b378299 100644 --- a/src/client/util/RichTextRules.ts +++ b/src/client/util/RichTextRules.ts @@ -5,8 +5,11 @@ import { NodeSelection, TextSelection } from "prosemirror-state"; import { NumCast, Cast } from "../../new_fields/Types"; import { Doc } from "../../new_fields/Doc"; import { FormattedTextBox } from "../views/nodes/FormattedTextBox"; -import { Docs } from "../documents/Documents"; +import { TooltipTextMenuManager } from "../util/TooltipTextMenu"; +import { Docs, DocUtils } from "../documents/Documents"; import { Id } from "../../new_fields/FieldSymbols"; +import { DocServer } from "../DocServer"; +import { returnFalse, Utils } from "../../Utils"; export const inpRules = { rules: [ @@ -59,53 +62,121 @@ export const inpRules = { } ), + // set the font size using #<font-size> new InputRule( - new RegExp(/^#([0-9]+)\s$/), + new RegExp(/^%([0-9]+)\s$/), (state, match, start, end) => { const size = Number(match[1]); - const ruleProvider = FormattedTextBox.InputBoxOverlay!.props.ruleProvider; - const heading = NumCast(FormattedTextBox.InputBoxOverlay!.props.Document.heading); + const ruleProvider = FormattedTextBox.FocusedBox!.props.ruleProvider; + const heading = NumCast(FormattedTextBox.FocusedBox!.props.Document.heading); if (ruleProvider && heading) { - (Cast(FormattedTextBox.InputBoxOverlay!.props.Document, Doc) as Doc).heading = size; + (Cast(FormattedTextBox.FocusedBox!.props.Document, Doc) as Doc).heading = size; return state.tr.deleteRange(start, end); } return state.tr.deleteRange(start, end).addStoredMark(schema.marks.pFontSize.create({ fontSize: size })); }), + + // make current selection a hyperlink portal (assumes % was used to initiate an EnteringStyle mode) new InputRule( - new RegExp(/t/), + new RegExp(/@$/), (state, match, start, end) => { - if (state.selection.to === state.selection.from) return null; - const node = (state.doc.resolve(start) as any).nodeAfter; - return node ? state.tr.addMark(start, end, schema.marks.user_tag.create({ userid: Doc.CurrentUserEmail, tag: "todo", modified: Math.round(Date.now() / 1000 / 60) })) : state.tr; + if (state.selection.to === state.selection.from || !(schema as any).EnteringStyle) return null; + + const value = state.doc.textBetween(start, end); + if (value) { + DocServer.GetRefField(value).then(docx => { + const target = ((docx instanceof Doc) && docx) || Docs.Create.FreeformDocument([], { title: value, width: 500, height: 500, }, value); + DocUtils.Publish(target, value, returnFalse, returnFalse); + DocUtils.MakeLink({ doc: (schema as any).Document }, { doc: target }, "portal link", ""); + }); + const link = state.schema.marks.link.create({ href: Utils.prepend("/doc/" + value), location: "onRight", title: value, targetId: value }); + return state.tr.addMark(start, end, link); + } + return state.tr; }), + // stop using active style new InputRule( - new RegExp(/i/), + new RegExp(/%%$/), (state, match, start, end) => { - if (state.selection.to === state.selection.from) return null; - const node = (state.doc.resolve(start) as any).nodeAfter; - return node ? state.tr.addMark(start, end, schema.marks.user_tag.create({ userid: Doc.CurrentUserEmail, tag: "ignore", modified: Math.round(Date.now() / 1000 / 60) })) : state.tr; + const tr = state.tr.deleteRange(start, end); + const marks = state.tr.selection.$anchor.nodeBefore?.marks; + return marks ? Array.from(marks).filter(m => m !== state.schema.marks.user_mark).reduce((tr, m) => tr.removeStoredMark(m), tr) : tr; }), + + // set the Todo user-tag on the current selection (assumes % was used to initiate an EnteringStyle mode) new InputRule( - new RegExp(/\!/), + new RegExp(/[ti!x]$/), (state, match, start, end) => { - if (state.selection.to === state.selection.from) return null; + if (state.selection.to === state.selection.from || !(schema as any).EnteringStyle) return null; + const tag = match[0] === "t" ? "todo" : match[0] === "i" ? "ignore" : match[0] === "x" ? "disagree" : match[0] === "!" ? "important" : "??"; const node = (state.doc.resolve(start) as any).nodeAfter; - return node ? state.tr.addMark(start, end, schema.marks.user_tag.create({ userid: Doc.CurrentUserEmail, tag: "important", modified: Math.round(Date.now() / 1000 / 60) })) : state.tr; + if (node?.marks.findIndex((m: any) => m.type === schema.marks.user_tag) !== -1) return state.tr.removeMark(start, end, schema.marks.user_tag); + return node ? state.tr.addMark(start, end, schema.marks.user_tag.create({ userid: Doc.CurrentUserEmail, tag: tag, modified: Math.round(Date.now() / 1000 / 60) })) : state.tr; + }), + + // set the First-line indent node type for the selection's paragraph (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/(%d|d)$/), + (state, match, start, end) => { + if (!match[0].startsWith("%") && !(schema as any).EnteringStyle) return null; + const pos = (state.doc.resolve(start) as any); + for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const node = pos.node(depth); + if (node.type === schema.nodes.paragraph) { + const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, indent: node.attrs.indent === 25 ? undefined : 25 }); + const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); + return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + } + } + return null; }), + + // set the Hanging indent node type for the current selection's paragraph (assumes % was used to initiate an EnteringStyle mode) new InputRule( - new RegExp(/\x/), + new RegExp(/(%h|h)$/), (state, match, start, end) => { - if (state.selection.to === state.selection.from) return null; - const node = (state.doc.resolve(start) as any).nodeAfter; - return node ? state.tr.addMark(start, end, schema.marks.user_tag.create({ userid: Doc.CurrentUserEmail, tag: "disagree", modified: Math.round(Date.now() / 1000 / 60) })) : state.tr; + if (!match[0].startsWith("%") && !(schema as any).EnteringStyle) return null; + const pos = (state.doc.resolve(start) as any); + for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const node = pos.node(depth); + if (node.type === schema.nodes.paragraph) { + const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, indent: node.attrs.indent === -25 ? undefined : -25 }); + const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); + return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + } + } + return null; }), + // set the Quoted indent node type for the current selection's paragraph (assumes % was used to initiate an EnteringStyle mode) new InputRule( - new RegExp(/^\^\^\s$/), + new RegExp(/(%q|q)$/), + (state, match, start, end) => { + if (!match[0].startsWith("%") && !(schema as any).EnteringStyle) return null; + const pos = (state.doc.resolve(start) as any); + if (state.selection instanceof NodeSelection && state.selection.node.type === schema.nodes.ordered_list) { + const node = state.selection.node; + return state.tr.setNodeMarkup(pos.pos, node.type, { ...node.attrs, indent: node.attrs.indent === 30 ? undefined : 30 }); + } + for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const node = pos.node(depth); + if (node.type === schema.nodes.paragraph) { + const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, inset: node.attrs.inset === 30 ? undefined : 30 }); + const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); + return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + } + } + return null; + }), + + + // center justify text + new InputRule( + new RegExp(/%\^$/), (state, match, start, end) => { const node = (state.doc.resolve(start) as any).nodeAfter; const sm = state.storedMarks || undefined; - const ruleProvider = FormattedTextBox.InputBoxOverlay!.props.ruleProvider; - const heading = NumCast(FormattedTextBox.InputBoxOverlay!.props.Document.heading); + const ruleProvider = FormattedTextBox.FocusedBox!.props.ruleProvider; + const heading = NumCast(FormattedTextBox.FocusedBox!.props.Document.heading); if (ruleProvider && heading) { ruleProvider["ruleAlign_" + heading] = "center"; return node ? state.tr.deleteRange(start, end).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : state.tr; @@ -114,13 +185,14 @@ export const inpRules = { state.tr; return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); }), + // left justify text new InputRule( - new RegExp(/^\[\[\s$/), + new RegExp(/%\[$/), (state, match, start, end) => { const node = (state.doc.resolve(start) as any).nodeAfter; const sm = state.storedMarks || undefined; - const ruleProvider = FormattedTextBox.InputBoxOverlay!.props.ruleProvider; - const heading = NumCast(FormattedTextBox.InputBoxOverlay!.props.Document.heading); + const ruleProvider = FormattedTextBox.FocusedBox!.props.ruleProvider; + const heading = NumCast(FormattedTextBox.FocusedBox!.props.Document.heading); if (ruleProvider && heading) { ruleProvider["ruleAlign_" + heading] = "left"; return node ? state.tr.deleteRange(start, end).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : state.tr; @@ -129,13 +201,14 @@ export const inpRules = { state.tr; return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); }), + // right justify text new InputRule( - new RegExp(/^\]\]\s$/), + new RegExp(/%\]$/), (state, match, start, end) => { const node = (state.doc.resolve(start) as any).nodeAfter; const sm = state.storedMarks || undefined; - const ruleProvider = FormattedTextBox.InputBoxOverlay!.props.ruleProvider; - const heading = NumCast(FormattedTextBox.InputBoxOverlay!.props.Document.heading); + const ruleProvider = FormattedTextBox.FocusedBox!.props.ruleProvider; + const heading = NumCast(FormattedTextBox.FocusedBox!.props.Document.heading); if (ruleProvider && heading) { ruleProvider["ruleAlign_" + heading] = "right"; return node ? state.tr.deleteRange(start, end).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : state.tr; @@ -145,38 +218,38 @@ export const inpRules = { return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); }), new InputRule( - new RegExp(/##\s$/), + new RegExp(/%#$/), (state, match, start, end) => { - const target = Docs.Create.TextDocument({ width: 75, height: 35, backgroundColor: "yellow", autoHeight: true, fontSize: 9, title: "inline comment" }); + const target = Docs.Create.TextDocument({ width: 75, height: 35, backgroundColor: "yellow", annotationOn: FormattedTextBox.FocusedBox!.dataDoc, autoHeight: true, fontSize: 9, title: "inline comment" }); const node = (state.doc.resolve(start) as any).nodeAfter; const newNode = schema.nodes.dashComment.create({ docid: target[Id] }); const dashDoc = schema.nodes.dashDoc.create({ width: 75, height: 35, title: "dashDoc", docid: target[Id], float: "right" }); const sm = state.storedMarks || undefined; - const replaced = node ? state.tr.insert(start, newNode).replaceRangeWith(start + 1, end + 1, dashDoc).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + const replaced = node ? state.tr.insert(start, newNode).replaceRangeWith(start + 1, end + 1, dashDoc).insertText(" ", start + 2).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : state.tr; return replaced;//.setSelection(new NodeSelection(replaced.doc.resolve(end))); }), new InputRule( - new RegExp(/\(\(/), + new RegExp(/%\(/), (state, match, start, end) => { const node = (state.doc.resolve(start) as any).nodeAfter; - const sm = state.storedMarks || undefined; - const mark = state.schema.marks.highlight.create(); + const sm = state.storedMarks || []; + const mark = state.schema.marks.summarizeInclusive.create(); + sm.push(mark); const selected = state.tr.setSelection(new TextSelection(state.doc.resolve(start), state.doc.resolve(end))).addMark(start, end, mark); const content = selected.selection.content(); - const replaced = node ? selected.replaceRangeWith(start, start, - schema.nodes.star.create({ visibility: true, text: content, textslice: content.toJSON() })).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + const replaced = node ? selected.replaceRangeWith(start, end, + schema.nodes.summary.create({ visibility: true, text: content, textslice: content.toJSON() })) : state.tr; - return replaced.setSelection(new TextSelection(replaced.doc.resolve(end + 1))); + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end + 1))).setStoredMarks([...node.marks, ...sm]); }), new InputRule( - new RegExp(/\)\)/), + new RegExp(/%\)/), (state, match, start, end) => { - const mark = state.schema.marks.highlight.create(); - return state.tr.removeStoredMark(mark); + return state.tr.deleteRange(start, end).removeStoredMark(state.schema.marks.summarizeInclusive.create()); }), new InputRule( - new RegExp(/\^f\s$/), + new RegExp(/%f$/), (state, match, start, end) => { const newNode = schema.nodes.footnote.create({}); const tr = state.tr; @@ -185,9 +258,26 @@ export const inpRules = { tr.doc.resolve( // get the location of the footnote node by subtracting the nodesize of the footnote from the current insertion point anchor (which will be immediately after the footnote node) tr.selection.anchor - tr.selection.$anchor.nodeBefore!.nodeSize))); }), - // let newNode = schema.nodes.footnote.create({}); - // if (dispatch && state.selection.from === state.selection.to) { - // return true; - // } + + // activate a style by name using prefix '%' + new InputRule( + new RegExp(/%[a-z]+$/), + (state, match, start, end) => { + const color = match[0].substring(1, match[0].length); + const marks = TooltipTextMenuManager.Instance._brushMap.get(color); + if (marks) { + const tr = state.tr.deleteRange(start, end); + return marks ? Array.from(marks).reduce((tr, m) => tr.addStoredMark(m), tr) : tr; + } + const isValidColor = (strColor: string) => { + const s = new Option().style; + s.color = strColor; + return s.color === strColor.toLowerCase(); // 'false' if color wasn't assigned + }; + if (isValidColor(color)) { + return state.tr.deleteRange(start, end).addStoredMark(schema.marks.pFontColor.create({ color: color })); + } + return null; + }), ] }; diff --git a/src/client/util/RichTextSchema.tsx b/src/client/util/RichTextSchema.tsx index 3b786e61d..ef90a7294 100644 --- a/src/client/util/RichTextSchema.tsx +++ b/src/client/util/RichTextSchema.tsx @@ -16,11 +16,10 @@ import { DocumentManager } from "./DocumentManager"; import ParagraphNodeSpec from "./ParagraphNodeSpec"; import { Transform } from "./Transform"; import React = require("react"); -import { BoolCast, NumCast } from "../../new_fields/Types"; +import { BoolCast, NumCast, Cast } from "../../new_fields/Types"; import { FormattedTextBox } from "../views/nodes/FormattedTextBox"; -import { any } from "bluebird"; -const pDOM: DOMOutputSpecArray = ["p", 0], blockquoteDOM: DOMOutputSpecArray = ["blockquote", 0], hrDOM: DOMOutputSpecArray = ["hr"], +const blockquoteDOM: DOMOutputSpecArray = ["blockquote", 0], hrDOM: DOMOutputSpecArray = ["hr"], preDOM: DOMOutputSpecArray = ["pre", ["code", 0]], brDOM: DOMOutputSpecArray = ["br"], ulDOM: DOMOutputSpecArray = ["ul", 0]; // :: Object @@ -31,7 +30,6 @@ export const nodes: { [index: string]: NodeSpec } = { content: "block+" }, - footnote: { group: "inline", content: "inline*", @@ -46,15 +44,6 @@ export const nodes: { [index: string]: NodeSpec } = { parseDOM: [{ tag: "footnote" }] }, - // // :: NodeSpec A plain paragraph textblock. Represented in the DOM - // // as a `<p>` element. - // paragraph: { - // content: "inline*", - // group: "block", - // parseDOM: [{ tag: "p" }], - // toDOM() { return pDOM; } - // }, - paragraph: ParagraphNodeSpec, // :: NodeSpec A blockquote (`<blockquote>`) wrapping one or more blocks. @@ -120,7 +109,7 @@ export const nodes: { [index: string]: NodeSpec } = { }, }, - star: { + summary: { inline: true, attrs: { visibility: { default: false }, @@ -132,15 +121,6 @@ export const nodes: { [index: string]: NodeSpec } = { const attrs = { style: `width: 40px` }; return ["span", { ...node.attrs, ...attrs }]; }, - // parseDOM: [{ - // tag: "star", getAttrs(dom: any) { - // return { - // visibility: dom.getAttribute("visibility"), - // oldtext: dom.getAttribute("oldtext"), - // oldtextlen: dom.getAttribute("oldtextlen"), - // } - // } - // }] }, // :: NodeSpec An inline image (`<img>`) node. Supports `src`, @@ -188,18 +168,7 @@ export const nodes: { [index: string]: NodeSpec } = { docid: { default: "" }, }, group: "inline", - draggable: true, - // parseDOM: [{ - // tag: "img[src]", getAttrs(dom: any) { - // return { - // src: dom.getAttribute("src"), - // title: dom.getAttribute("title"), - // alt: dom.getAttribute("alt"), - // width: Math.min(100, Number(dom.getAttribute("width"))), - // }; - // } - // }], - // TODO if we don't define toDom, dragging the image crashes. Why? + draggable: false, toDOM(node) { const attrs = { style: `width: ${node.attrs.width}, height: ${node.attrs.height}` }; return ["div", { ...node.attrs, ...attrs }]; @@ -249,20 +218,21 @@ export const nodes: { [index: string]: NodeSpec } = { bulletStyle: { default: 0 }, mapStyle: { default: "decimal" }, setFontSize: { default: undefined }, - setFontFamily: { default: undefined }, + setFontFamily: { default: "inherit" }, + setFontColor: { default: "inherit" }, inheritedFontSize: { default: undefined }, - visibility: { default: true } + visibility: { default: true }, + indent: { default: undefined } }, toDOM(node: Node<any>) { - const bs = node.attrs.bulletStyle; if (node.attrs.mapStyle === "bullet") return ['ul', 0]; - const decMap = bs ? "decimal" + bs : ""; - const multiMap = bs === 1 ? "decimal1" : bs === 2 ? "upper-alpha" : bs === 3 ? "lower-roman" : bs === 4 ? "lower-alpha" : ""; - const map = node.attrs.mapStyle === "decimal" ? decMap : multiMap; + const map = node.attrs.bulletStyle ? node.attrs.mapStyle + node.attrs.bulletStyle : ""; const fsize = node.attrs.setFontSize ? node.attrs.setFontSize : node.attrs.inheritedFontSize; const ffam = node.attrs.setFontFamily; - return node.attrs.visibility ? ['ol', { class: `${map}-ol`, style: `list-style: none; font-size: ${fsize}; font-family: ${ffam}` }, 0] : - ['ol', { class: `${map}-ol`, style: `list-style: none; font-size: ${fsize}; font-family: ${ffam}` }]; + const color = node.attrs.setFontColor; + return node.attrs.visibility ? + ['ol', { class: `${map}-ol`, style: `list-style: none; font-size: ${fsize}; font-family: ${ffam}; color:${color}; margin-left: ${node.attrs.indent}` }, 0] : + ['ol', { class: `${map}-ol`, style: `list-style: none;` }]; } }, @@ -285,10 +255,7 @@ export const nodes: { [index: string]: NodeSpec } = { ...listItem, content: 'paragraph block*', toDOM(node: any) { - const bs = node.attrs.bulletStyle; - const decMap = bs ? "decimal" + bs : ""; - const multiMap = bs === 1 ? "decimal1" : bs === 2 ? "upper-alpha" : bs === 3 ? "lower-roman" : bs === 4 ? "lower-alpha" : ""; - const map = node.attrs.mapStyle === "decimal" ? decMap : node.attrs.mapStyle === "multi" ? multiMap : ""; + const map = node.attrs.bulletStyle ? node.attrs.mapStyle + node.attrs.bulletStyle : ""; return node.attrs.visibility ? ["li", { class: `${map}` }, 0] : ["li", { class: `${map}` }, "..."]; //return ["li", { class: `${map}` }, 0]; } @@ -307,6 +274,8 @@ export const marks: { [index: string]: MarkSpec } = { link: { attrs: { href: {}, + targetId: { default: "" }, + showPreview: { default: true }, location: { default: null }, title: { default: null }, docref: { default: false } // flags whether the linked text comes from a document within Dash. If so, an attribution label is appended after the text @@ -314,22 +283,23 @@ export const marks: { [index: string]: MarkSpec } = { inclusive: false, parseDOM: [{ tag: "a[href]", getAttrs(dom: any) { - return { href: dom.getAttribute("href"), location: dom.getAttribute("location"), title: dom.getAttribute("title") }; + return { href: dom.getAttribute("href"), location: dom.getAttribute("location"), title: dom.getAttribute("title"), targetId: dom.getAttribute("id") }; } }], toDOM(node: any) { return node.attrs.docref && node.attrs.title ? ["div", ["span", `"`], ["span", 0], ["span", `"`], ["br"], ["a", { ...node.attrs, class: "prosemirror-attribution", title: `${node.attrs.title}` }, node.attrs.title], ["br"]] : - ["a", { ...node.attrs, title: `${node.attrs.title}` }, 0]; + ["a", { ...node.attrs, id: node.attrs.targetId, title: `${node.attrs.title}` }, 0]; } }, + // :: MarkSpec Coloring on text. Has `color` attribute that defined the color of the marked text. - color: { + pFontColor: { attrs: { color: { default: "#000" } }, - inclusive: false, + inclusive: true, parseDOM: [{ tag: "span", getAttrs(dom: any) { return { color: dom.getAttribute("color") }; @@ -344,7 +314,7 @@ export const marks: { [index: string]: MarkSpec } = { attrs: { highlight: { default: "transparent" } }, - inclusive: false, + inclusive: true, parseDOM: [{ tag: "span", getAttrs(dom: any) { return { highlight: dom.getAttribute("backgroundColor") }; @@ -427,7 +397,7 @@ export const marks: { [index: string]: MarkSpec } = { } }, - highlight: { + summarizeInclusive: { parseDOM: [ { tag: "span", @@ -436,7 +406,7 @@ export const marks: { [index: string]: MarkSpec } = { const style = getComputedStyle(p); if (style.textDecoration === "underline") return null; if (p.parentElement.outerHTML.indexOf("text-decoration: underline") !== -1 && - p.parentElement.outerHTML.indexOf("text-decoration-style: dotted") !== -1) { + p.parentElement.outerHTML.indexOf("text-decoration-style: solid") !== -1) { return null; } } @@ -447,6 +417,31 @@ export const marks: { [index: string]: MarkSpec } = { inclusive: true, toDOM() { return ['span', { + style: 'text-decoration: underline; text-decoration-style: solid; text-decoration-color: rgba(204, 206, 210, 0.92)' + }]; + } + }, + + summarize: { + inclusive: false, + parseDOM: [ + { + tag: "span", + getAttrs: (p: any) => { + if (typeof (p) !== "string") { + const style = getComputedStyle(p); + if (style.textDecoration === "underline") return null; + if (p.parentElement.outerHTML.indexOf("text-decoration: underline") !== -1 && + p.parentElement.outerHTML.indexOf("text-decoration-style: dotted") !== -1) { + return null; + } + } + return false; + } + }, + ], + toDOM() { + return ['span', { style: 'text-decoration: underline; text-decoration-style: dotted; text-decoration-color: rgba(204, 206, 210, 0.92)' }]; } @@ -489,7 +484,6 @@ export const marks: { [index: string]: MarkSpec } = { user_mark: { attrs: { userid: { default: "" }, - opened: { default: true }, modified: { default: "when?" }, // 5 second intervals since 1970 }, group: "inline", @@ -499,25 +493,21 @@ export const marks: { [index: string]: MarkSpec } = { const hr = Math.round(min / 60); const day = Math.round(hr / 60 / 24); const remote = node.attrs.userid !== Doc.CurrentUserEmail ? " userMark-remote" : ""; - return node.attrs.opened ? - ['span', { class: "userMark-" + uid + remote + " userMark-min-" + min + " userMark-hr-" + hr + " userMark-day-" + day }, 0] : - ['span', { class: "userMark-" + uid + remote + " userMark-min-" + min + " userMark-hr-" + hr + " userMark-day-" + day }, ['span', 0]]; + return ['span', { class: "userMark-" + uid + remote + " userMark-min-" + min + " userMark-hr-" + hr + " userMark-day-" + day }, 0]; } }, // the id of the user who entered the text user_tag: { attrs: { userid: { default: "" }, - opened: { default: true }, modified: { default: "when?" }, // 5 second intervals since 1970 tag: { default: "" } }, group: "inline", + inclusive: false, toDOM(node: any) { const uid = node.attrs.userid.replace(".", "").replace("@", ""); - return node.attrs.opened ? - ['span', { class: "userTag-" + uid + " userTag-" + node.attrs.tag }, 0] : - ['span', { class: "userTag-" + uid + " userTag-" + node.attrs.tag }, ['span', 0]]; + return ['span', { class: "userTag-" + uid + " userTag-" + node.attrs.tag }, 0]; } }, @@ -551,18 +541,6 @@ export const marks: { [index: string]: MarkSpec } = { }] }, - pFontColor: { - attrs: { - color: { default: "yellow" } - }, - parseDOM: [{ style: 'background: #d9dbdd' }], - toDOM: (node) => { - return ['span', { - style: `color: ${node.attrs.color}` - }]; - } - }, - /** FONT SIZES */ pFontSize: { attrs: { @@ -671,22 +649,33 @@ export class DashDocCommentView { this._collapsed.className = "formattedTextBox-inlineComment"; this._collapsed.id = "DashDocCommentView-" + node.attrs.docid; this._view = view; - let targetNode = () => { - for (let i = getPos() + 1; i < view.state.doc.nodeSize; i++) { - let m = view.state.doc.nodeAt(i); + const targetNode = () => { // search forward in the prosemirror doc for the attached dashDocNode that is the target of the comment anchor + for (let i = getPos() + 1; i < view.state.doc.content.size; i++) { + const m = view.state.doc.nodeAt(i); if (m && m.type === view.state.schema.nodes.dashDoc && m.attrs.docid === node.attrs.docid) { - return { node: m, pos: i } as { node: any, pos: number }; + return { node: m, pos: i, hidden: m.attrs.hidden } as { node: any, pos: number, hidden: boolean }; } } + const dashDoc = view.state.schema.nodes.dashDoc.create({ width: 75, height: 35, title: "dashDoc", docid: node.attrs.docid, float: "right" }); + view.dispatch(view.state.tr.insert(getPos() + 1, dashDoc)); + setTimeout(() => { try { view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.tr.doc, getPos() + 2))); } catch (e) { } }, 0); return undefined; - } + }; this._collapsed.onpointerdown = (e: any) => { + e.stopPropagation(); + }; + this._collapsed.onpointerup = (e: any) => { const target = targetNode(); if (target) { - view.dispatch(view.state.tr. - setNodeMarkup(target.pos, undefined, { ...target.node.attrs, hidden: target.node.attrs.hidden ? false : true })); // update the attrs - setTimeout(() => node.attrs.hidden && DocServer.GetRefField(node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc)), 100); + const expand = target.hidden; + const tr = view.state.tr.setNodeMarkup(target.pos, undefined, { ...target.node.attrs, hidden: target.node.attrs.hidden ? false : true }); + view.dispatch(tr.setSelection(TextSelection.create(tr.doc, getPos() + (expand ? 2 : 1)))); // update the attrs + setTimeout(() => { + expand && DocServer.GetRefField(node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc)); + try { view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.tr.doc, getPos() + (expand ? 2 : 1)))); } catch (e) { } + }, 0); } + e.stopPropagation(); }; this._collapsed.onpointerenter = (e: any) => { DocServer.GetRefField(node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc)); @@ -715,11 +704,13 @@ export class DashDocView { return new Transform(-translateX, -translateY, 1).scale(1 / this.contentScaling() / scale); } contentScaling = () => NumCast(this._dashDoc!.nativeWidth) > 0 && !this._dashDoc!.ignoreAspect ? this._dashDoc![WidthSym]() / NumCast(this._dashDoc!.nativeWidth) : 1; + outerFocus = (target: Doc) => this._textBox.props.focus(this._textBox.props.Document); // ideally, this would scroll to show the focus target constructor(node: any, view: any, getPos: any, tbox: FormattedTextBox) { this._textBox = tbox; this._dashSpan = document.createElement("div"); this._outer = document.createElement("span"); this._outer.style.position = "relative"; + this._outer.style.textIndent = "0"; this._outer.style.width = node.attrs.width; this._outer.style.height = node.attrs.height; this._outer.style.display = node.attrs.hidden ? "none" : "inline-block"; @@ -753,7 +744,11 @@ export class DashDocView { self._dashDoc = dashDoc; dashDoc.hideSidebar = true; if (node.attrs.width !== dashDoc.width + "px" || node.attrs.height !== dashDoc.height + "px") { - view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, { ...node.attrs, width: dashDoc.width + "px", height: dashDoc.height + "px" })); + try { // bcz: an exception will be thrown if two aliases are open at the same time when a doc view comment is made + view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, { ...node.attrs, width: dashDoc.width + "px", height: dashDoc.height + "px" })); + } catch (e) { + console.log(e); + } } this._reactionDisposer && this._reactionDisposer(); this._reactionDisposer = reaction(() => dashDoc[HeightSym]() + dashDoc[WidthSym](), () => { @@ -761,8 +756,9 @@ export class DashDocView { this._dashSpan.style.width = this._outer.style.width = dashDoc[WidthSym]() + "px"; }); ReactDOM.render(<DocumentView - fitToBox={BoolCast(dashDoc.fitToBox)} Document={dashDoc} + LibraryPath={tbox.props.LibraryPath} + fitToBox={BoolCast(dashDoc.fitToBox)} addDocument={returnFalse} removeDocument={removeDoc} ruleProvider={undefined} @@ -772,14 +768,14 @@ export class DashDocView { renderDepth={1} PanelWidth={self._dashDoc[WidthSym]} PanelHeight={self._dashDoc[HeightSym]} - focus={emptyFunction} + focus={self.outerFocus} backgroundColor={returnEmptyString} parentActive={returnFalse} whenActiveChanged={returnFalse} bringToFront={emptyFunction} zoomToScale={emptyFunction} getScale={returnOne} - dontRegisterView={true} + dontRegisterView={false} ContainingCollectionView={undefined} ContainingCollectionDoc={undefined} ContentScaling={this.contentScaling} @@ -787,7 +783,12 @@ export class DashDocView { } }); const self = this; - this._dashSpan.onkeydown = function (e: any) { e.stopPropagation(); }; + this._dashSpan.onkeydown = function (e: any) { + e.stopPropagation(); + if (e.key === "Tab" || e.key === "Enter") { + e.preventDefault(); + } + }; this._dashSpan.onkeypress = function (e: any) { e.stopPropagation(); }; this._dashSpan.onwheel = function (e: any) { e.preventDefault(); }; this._dashSpan.onkeyup = function (e: any) { e.stopPropagation(); }; @@ -938,7 +939,7 @@ export class FootnoteView { ignoreMutation() { return true; } } -export class SummarizedView { +export class SummaryView { _collapsed: HTMLElement; _view: any; constructor(node: any, view: any, getPos: any) { @@ -976,7 +977,8 @@ export class SummarizedView { className = (visible: boolean) => "formattedTextBox-summarizer" + (visible ? "" : "-collapsed"); updateSummarizedText(start?: any) { - const mark = this._view.state.schema.marks.highlight.create(); + const mtype = this._view.state.schema.marks.summarize; + const mtypeInc = this._view.state.schema.marks.summarizeInclusive; let endPos = start; const visited = new Set(); @@ -984,7 +986,7 @@ export class SummarizedView { let skip = false; this._view.state.doc.nodesBetween(start, i, (node: Node, pos: number, parent: Node, index: number) => { if (node.isLeaf && !visited.has(node) && !skip) { - if (node.marks.find((m: any) => m.type === mark.type)) { + if (node.marks.find((m: any) => m.type === mtype || m.type === mtypeInc)) { visited.add(node); endPos = i + node.nodeSize - 1; } @@ -1009,7 +1011,7 @@ const fromJson = schema.nodeFromJSON; schema.nodeFromJSON = (json: any) => { const node = fromJson(json); - if (json.type === "star") { + if (json.type === schema.marks.summarize.name) { node.attrs.text = Slice.fromJSON(schema, node.attrs.textslice); } return node; diff --git a/src/client/util/SearchUtil.ts b/src/client/util/SearchUtil.ts index 7a9176bec..8ff54d052 100644 --- a/src/client/util/SearchUtil.ts +++ b/src/client/util/SearchUtil.ts @@ -34,9 +34,9 @@ export namespace SearchUtil { export function Search(query: string, returnDocs: false, options?: SearchParams): Promise<IdSearchResult>; export async function Search(query: string, returnDocs: boolean, options: SearchParams = {}) { query = query || "*"; //If we just have a filter query, search for * as the query - const result: IdSearchResult = JSON.parse(await rp.get(Utils.prepend("/search"), { - qs: { ...options, q: query }, - })); + const rpquery = Utils.prepend("/search"); + const gotten = await rp.get(rpquery, { qs: { ...options, q: query } }); + const result: IdSearchResult = gotten.startsWith("<") ? { ids: [], docs: [], numFound: 0, lines: [] } : JSON.parse(gotten); if (!returnDocs) { return result; } @@ -64,7 +64,7 @@ export namespace SearchUtil { const textDocs = newIds.map((id: string) => textDocMap[id]).map(doc => doc as Doc); for (let i = 0; i < textDocs.length; i++) { const testDoc = textDocs[i]; - if (testDoc instanceof Doc && testDoc.type !== DocumentType.KVP && theDocs.findIndex(d => Doc.AreProtosEqual(d, testDoc)) === -1) { + if (testDoc instanceof Doc && testDoc.type !== DocumentType.KVP && testDoc.type !== DocumentType.EXTENSION && theDocs.findIndex(d => Doc.AreProtosEqual(d, testDoc)) === -1) { theDocs.push(Doc.GetProto(testDoc)); theLines.push(newLines[i].map(line => line.replace(query, query.toUpperCase()))); } @@ -74,7 +74,7 @@ export namespace SearchUtil { const docs = ids.map((id: string) => docMap[id]).map(doc => doc as Doc); for (let i = 0; i < ids.length; i++) { const testDoc = docs[i]; - if (testDoc instanceof Doc && testDoc.type !== DocumentType.KVP && (options.allowAliases || theDocs.findIndex(d => Doc.AreProtosEqual(d, testDoc)) === -1)) { + if (testDoc instanceof Doc && testDoc.type !== DocumentType.KVP && testDoc.type !== DocumentType.EXTENSION && (options.allowAliases || theDocs.findIndex(d => Doc.AreProtosEqual(d, testDoc)) === -1)) { theDocs.push(testDoc); theLines.push([]); } diff --git a/src/client/util/SelectionManager.ts b/src/client/util/SelectionManager.ts index e01216e0f..86a7a620e 100644 --- a/src/client/util/SelectionManager.ts +++ b/src/client/util/SelectionManager.ts @@ -2,6 +2,9 @@ import { observable, action, runInAction, ObservableMap } from "mobx"; import { Doc } from "../../new_fields/Doc"; import { DocumentView } from "../views/nodes/DocumentView"; import { computedFn } from "mobx-utils"; +import { List } from "../../new_fields/List"; +import { DocumentDecorations } from "../views/DocumentDecorations"; +import RichTextMenu from "./RichTextMenu"; export namespace SelectionManager { @@ -27,18 +30,21 @@ export namespace SelectionManager { manager.SelectedDocuments.clear(); manager.SelectedDocuments.set(docView, true); } + Doc.UserDoc().SelectedDocs = new List(SelectionManager.SelectedDocuments().map(dv => dv.props.Document)); } @action DeselectDoc(docView: DocumentView): void { if (manager.SelectedDocuments.get(docView)) { manager.SelectedDocuments.delete(docView); docView.props.whenActiveChanged(false); + Doc.UserDoc().SelectedDocs = new List(SelectionManager.SelectedDocuments().map(dv => dv.props.Document)); } } @action DeselectAll(): void { Array.from(manager.SelectedDocuments.keys()).map(dv => dv.props.whenActiveChanged(false)); manager.SelectedDocuments.clear(); + Doc.UserDoc().SelectedDocs = new List<Doc>([]); } } @@ -78,3 +84,4 @@ export namespace SelectionManager { return Array.from(manager.SelectedDocuments.keys()); } } + diff --git a/src/client/util/TooltipTextMenu.scss b/src/client/util/TooltipTextMenu.scss index 8310a0da6..2a38fe118 100644 --- a/src/client/util/TooltipTextMenu.scss +++ b/src/client/util/TooltipTextMenu.scss @@ -149,7 +149,7 @@ } svg { - fill: white; + fill: inherit; width: 18px; height: 18px; } @@ -181,7 +181,7 @@ } } -#colorPicker { +.colorPicker { position: relative; svg { diff --git a/src/client/util/TooltipTextMenu.tsx b/src/client/util/TooltipTextMenu.tsx index 35a099c48..1c15dca7f 100644 --- a/src/client/util/TooltipTextMenu.tsx +++ b/src/client/util/TooltipTextMenu.tsx @@ -1,17 +1,13 @@ -import { action } from "mobx"; import { Dropdown, icons, MenuItem } from "prosemirror-menu"; //no import css import { Mark, MarkType, Node as ProsNode, NodeType, ResolvedPos, Schema } from "prosemirror-model"; import { wrapInList } from 'prosemirror-schema-list'; import { EditorState, NodeSelection, TextSelection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { Doc, Field, Opt } from "../../new_fields/Doc"; -import { Id } from "../../new_fields/FieldSymbols"; import { Utils } from "../../Utils"; import { DocServer } from "../DocServer"; import { FieldViewProps } from "../views/nodes/FieldView"; import { FormattedTextBoxProps } from "../views/nodes/FormattedTextBox"; -import { DocumentManager } from "./DocumentManager"; -import { DragManager } from "./DragManager"; import { LinkManager } from "./LinkManager"; import { schema } from "./RichTextSchema"; import "./TooltipTextMenu.scss"; @@ -20,12 +16,10 @@ import { updateBullets } from './ProsemirrorExampleTransfer'; import { DocumentDecorations } from '../views/DocumentDecorations'; import { SelectionManager } from './SelectionManager'; import { PastelSchemaPalette, DarkPastelSchemaPalette } from '../../new_fields/SchemaHeaderField'; -const { toggleMark, setBlockType } = require("prosemirror-commands"); -const { openPrompt, TextField } = require("./ProsemirrorCopy/prompt.js"); +const { toggleMark } = require("prosemirror-commands"); //appears above a selection of text in a RichTextBox to give user options such as Bold, Italics, etc. export class TooltipTextMenu { - public static Toolbar: HTMLDivElement | undefined; // editor state properties @@ -34,10 +28,7 @@ export class TooltipTextMenu { private fontStyles: Mark[] = []; private fontSizes: Mark[] = []; - private listTypes: (NodeType | any)[] = []; - private listTypeToIcon: Map<NodeType | any, string> = new Map(); - private _activeMarks: Mark[] = []; - private _marksToDoms: Map<Mark, HTMLSpanElement> = new Map(); + private _marksToDoms: Map<MarkType, HTMLSpanElement> = new Map(); private _collapsed: boolean = false; // editor doms @@ -47,20 +38,18 @@ export class TooltipTextMenu { // editor button doms private colorDom?: Node; private colorDropdownDom?: Node; - private highlightDom?: Node; - private highlightDropdownDom?: Node; - private linkEditor?: HTMLDivElement; - private linkText?: HTMLDivElement; - private linkDrag?: HTMLImageElement; - private _linkDropdownDom?: Node; + private linkDom?: Node; + private highighterDom?: Node; + private highlighterDropdownDom?: Node; + private linkDropdownDom?: Node; private _brushdom?: Node; private _brushDropdownDom?: Node; private fontSizeDom?: Node; private fontStyleDom?: Node; - private listTypeBtnDom?: Node; private basicTools?: HTMLElement; - + static createDiv(className: string) { const div = document.createElement("div"); div.className = className; return div; } + static createSpan(className: string) { const div = document.createElement("span"); div.className = className; return div; } constructor(view: EditorView) { this.view = view; @@ -68,74 +57,64 @@ export class TooltipTextMenu { this.initTooltip(view); // initialize the wrapper - this.wrapper = document.createElement("div"); - this.wrapper.className = "wrapper"; + this.wrapper = TooltipTextMenu.createDiv("wrapper"); this.wrapper.appendChild(this.tooltip); - // initialize the dragger -- appends it to the wrapper - this.createDragger(); - TooltipTextMenu.Toolbar = this.wrapper; } private async initTooltip(view: EditorView) { - // initialize tooltip dom - this.tooltip = document.createElement("div"); - this.tooltip.className = "tooltipMenu"; - this.basicTools = document.createElement("div"); - this.basicTools.className = "basic-tools"; - - // init buttons to the tooltip -- paths to svgs are obtained from fontawesome - const items = [ - { command: toggleMark(schema.marks.strong), dom: this.svgIcon("strong", "Bold", "M333.49 238a122 122 0 0 0 27-65.21C367.87 96.49 308 32 233.42 32H34a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h31.87v288H34a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h209.32c70.8 0 134.14-51.75 141-122.4 4.74-48.45-16.39-92.06-50.83-119.6zM145.66 112h87.76a48 48 0 0 1 0 96h-87.76zm87.76 288h-87.76V288h87.76a56 56 0 0 1 0 112z") }, - { command: toggleMark(schema.marks.em), dom: this.svgIcon("em", "Italic", "M320 48v32a16 16 0 0 1-16 16h-62.76l-80 320H208a16 16 0 0 1 16 16v32a16 16 0 0 1-16 16H16a16 16 0 0 1-16-16v-32a16 16 0 0 1 16-16h62.76l80-320H112a16 16 0 0 1-16-16V48a16 16 0 0 1 16-16h192a16 16 0 0 1 16 16z") }, - { command: toggleMark(schema.marks.underline), dom: this.svgIcon("underline", "Underline", "M32 64h32v160c0 88.22 71.78 160 160 160s160-71.78 160-160V64h32a16 16 0 0 0 16-16V16a16 16 0 0 0-16-16H272a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h32v160a80 80 0 0 1-160 0V64h32a16 16 0 0 0 16-16V16a16 16 0 0 0-16-16H32a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16zm400 384H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16z") }, - { command: toggleMark(schema.marks.strikethrough), dom: this.svgIcon("strikethrough", "Strikethrough", "M496 224H293.9l-87.17-26.83A43.55 43.55 0 0 1 219.55 112h66.79A49.89 49.89 0 0 1 331 139.58a16 16 0 0 0 21.46 7.15l42.94-21.47a16 16 0 0 0 7.16-21.46l-.53-1A128 128 0 0 0 287.51 32h-68a123.68 123.68 0 0 0-123 135.64c2 20.89 10.1 39.83 21.78 56.36H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h480a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm-180.24 96A43 43 0 0 1 336 356.45 43.59 43.59 0 0 1 292.45 400h-66.79A49.89 49.89 0 0 1 181 372.42a16 16 0 0 0-21.46-7.15l-42.94 21.47a16 16 0 0 0-7.16 21.46l.53 1A128 128 0 0 0 224.49 480h68a123.68 123.68 0 0 0 123-135.64 114.25 114.25 0 0 0-5.34-24.36z") }, - { command: toggleMark(schema.marks.superscript), dom: this.svgIcon("superscript", "Superscript", "M496 160h-16V16a16 16 0 0 0-16-16h-48a16 16 0 0 0-14.29 8.83l-16 32A16 16 0 0 0 400 64h16v96h-16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h96a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zM336 64h-67a16 16 0 0 0-13.14 6.87l-79.9 115-79.9-115A16 16 0 0 0 83 64H16A16 16 0 0 0 0 80v48a16 16 0 0 0 16 16h33.48l77.81 112-77.81 112H16a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h67a16 16 0 0 0 13.14-6.87l79.9-115 79.9 115A16 16 0 0 0 269 448h67a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16h-33.48l-77.81-112 77.81-112H336a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16z") }, - { command: toggleMark(schema.marks.subscript), dom: this.svgIcon("subscript", "Subscript", "M496 448h-16V304a16 16 0 0 0-16-16h-48a16 16 0 0 0-14.29 8.83l-16 32A16 16 0 0 0 400 352h16v96h-16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h96a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zM336 64h-67a16 16 0 0 0-13.14 6.87l-79.9 115-79.9-115A16 16 0 0 0 83 64H16A16 16 0 0 0 0 80v48a16 16 0 0 0 16 16h33.48l77.81 112-77.81 112H16a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h67a16 16 0 0 0 13.14-6.87l79.9-115 79.9 115A16 16 0 0 0 269 448h67a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16h-33.48l-77.81-112 77.81-112H336a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16z") }, - // { command: toggleMark(schema.marks.highlight), dom: this.icon("H", 'blue', 'Blue') } + const self = this; + this.tooltip = TooltipTextMenu.createDiv("tooltipMenu"); + this.basicTools = TooltipTextMenu.createDiv("basic-tools"); + + const svgIcon = (name: string, title: string = name, dpath: string) => { + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + svg.setAttribute("viewBox", "-100 -100 650 650"); + const path = document.createElementNS('http://www.w3.org/2000/svg', "path"); + path.setAttributeNS(null, "d", dpath); + svg.appendChild(path); + + const span = TooltipTextMenu.createSpan(name + " menuicon"); + span.title = title; + span.appendChild(svg); + + return span; + }; + + const basicItems = [ // init basicItems in minimized toolbar -- paths to svgs are obtained from fontawesome + { mark: schema.marks.strong, dom: svgIcon("strong", "Bold", "M333.49 238a122 122 0 0 0 27-65.21C367.87 96.49 308 32 233.42 32H34a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h31.87v288H34a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h209.32c70.8 0 134.14-51.75 141-122.4 4.74-48.45-16.39-92.06-50.83-119.6zM145.66 112h87.76a48 48 0 0 1 0 96h-87.76zm87.76 288h-87.76V288h87.76a56 56 0 0 1 0 112z") }, + { mark: schema.marks.em, dom: svgIcon("em", "Italic", "M320 48v32a16 16 0 0 1-16 16h-62.76l-80 320H208a16 16 0 0 1 16 16v32a16 16 0 0 1-16 16H16a16 16 0 0 1-16-16v-32a16 16 0 0 1 16-16h62.76l80-320H112a16 16 0 0 1-16-16V48a16 16 0 0 1 16-16h192a16 16 0 0 1 16 16z") }, + { mark: schema.marks.underline, dom: svgIcon("underline", "Underline", "M32 64h32v160c0 88.22 71.78 160 160 160s160-71.78 160-160V64h32a16 16 0 0 0 16-16V16a16 16 0 0 0-16-16H272a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h32v160a80 80 0 0 1-160 0V64h32a16 16 0 0 0 16-16V16a16 16 0 0 0-16-16H32a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16zm400 384H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16z") }, + ]; + const items = [ // init items in full size toolbar + { mark: schema.marks.strikethrough, dom: svgIcon("strikethrough", "Strikethrough", "M496 224H293.9l-87.17-26.83A43.55 43.55 0 0 1 219.55 112h66.79A49.89 49.89 0 0 1 331 139.58a16 16 0 0 0 21.46 7.15l42.94-21.47a16 16 0 0 0 7.16-21.46l-.53-1A128 128 0 0 0 287.51 32h-68a123.68 123.68 0 0 0-123 135.64c2 20.89 10.1 39.83 21.78 56.36H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h480a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zm-180.24 96A43 43 0 0 1 336 356.45 43.59 43.59 0 0 1 292.45 400h-66.79A49.89 49.89 0 0 1 181 372.42a16 16 0 0 0-21.46-7.15l-42.94 21.47a16 16 0 0 0-7.16 21.46l.53 1A128 128 0 0 0 224.49 480h68a123.68 123.68 0 0 0 123-135.64 114.25 114.25 0 0 0-5.34-24.36z") }, + { mark: schema.marks.superscript, dom: svgIcon("superscript", "Superscript", "M496 160h-16V16a16 16 0 0 0-16-16h-48a16 16 0 0 0-14.29 8.83l-16 32A16 16 0 0 0 400 64h16v96h-16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h96a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zM336 64h-67a16 16 0 0 0-13.14 6.87l-79.9 115-79.9-115A16 16 0 0 0 83 64H16A16 16 0 0 0 0 80v48a16 16 0 0 0 16 16h33.48l77.81 112-77.81 112H16a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h67a16 16 0 0 0 13.14-6.87l79.9-115 79.9 115A16 16 0 0 0 269 448h67a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16h-33.48l-77.81-112 77.81-112H336a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16z") }, + { mark: schema.marks.subscript, dom: svgIcon("subscript", "Subscript", "M496 448h-16V304a16 16 0 0 0-16-16h-48a16 16 0 0 0-14.29 8.83l-16 32A16 16 0 0 0 400 352h16v96h-16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h96a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zM336 64h-67a16 16 0 0 0-13.14 6.87l-79.9 115-79.9-115A16 16 0 0 0 83 64H16A16 16 0 0 0 0 80v48a16 16 0 0 0 16 16h33.48l77.81 112-77.81 112H16a16 16 0 0 0-16 16v48a16 16 0 0 0 16 16h67a16 16 0 0 0 13.14-6.87l79.9-115 79.9 115A16 16 0 0 0 269 448h67a16 16 0 0 0 16-16v-48a16 16 0 0 0-16-16h-33.48l-77.81-112 77.81-112H336a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16z") }, ]; - // add menu items - this._marksToDoms = new Map(); - items.forEach(({ dom, command }) => { + basicItems.map(({ dom, mark }) => this.basicTools ?.appendChild(dom.cloneNode(true))); + basicItems.concat(items).forEach(({ dom, mark }) => { this.tooltip.appendChild(dom); - switch (dom.title) { - case "Bold": - this._marksToDoms.set(schema.mark(schema.marks.strong), dom); - this.basicTools && this.basicTools.appendChild(dom.cloneNode(true)); - break; - case "Italic": - this._marksToDoms.set(schema.mark(schema.marks.em), dom); - this.basicTools && this.basicTools.appendChild(dom.cloneNode(true)); - break; - case "Underline": - this._marksToDoms.set(schema.mark(schema.marks.underline), dom); - this.basicTools && this.basicTools.appendChild(dom.cloneNode(true)); - break; - } + this._marksToDoms.set(mark, dom); //pointer down handler to activate button effects dom.addEventListener("pointerdown", e => { - e.preventDefault(); this.view.focus(); if (dom.contains(e.target as Node)) { + e.preventDefault(); e.stopPropagation(); - command(this.view.state, this.view.dispatch, this.view); - // if (this.view.state.selection.empty) { - // if (dom.style.color === "white") { dom.style.color = "greenyellow"; } - // else { dom.style.color = "white"; } - // } + toggleMark(mark)(this.view.state, this.view.dispatch, this.view); + this.updateHighlightStateOfButtons(); } }); - }); - // highlight menu - this.highlightDom = this.createHighlightTool().render(this.view).dom; - this.highlightDropdownDom = this.createHighlightDropdown().render(this.view).dom; - this.tooltip.appendChild(this.highlightDom); - this.tooltip.appendChild(this.highlightDropdownDom); + // summarize menu + this.highighterDom = this.createHighlightTool().render(this.view).dom; + this.highlighterDropdownDom = this.createHighlightDropdown().render(this.view).dom; + this.tooltip.appendChild(this.highighterDom); + this.tooltip.appendChild(this.highlighterDropdownDom); // color menu this.colorDom = this.createColorTool().render(this.view).dom; @@ -144,46 +123,12 @@ export class TooltipTextMenu { this.tooltip.appendChild(this.colorDropdownDom); // link menu - this.updateLinkMenu(); - const dropdown = await this.createLinkDropdown(); - this._linkDropdownDom = dropdown.render(this.view).dom; - this.tooltip.appendChild(this._linkDropdownDom); + this.linkDom = this.createLinkTool().render(this.view).dom; + this.linkDropdownDom = this.createLinkDropdown("").render(this.view).dom; + this.tooltip.appendChild(this.linkDom); + this.tooltip.appendChild(this.linkDropdownDom); // list of font styles - this.initFontStyles(); - - // font sizes - this.initFontSizes(); - - // list types - this.initListTypes(); - - // init brush tool - this._brushdom = this.createBrush().render(this.view).dom; - this.tooltip.appendChild(this._brushdom); - this._brushDropdownDom = this.createBrushDropdown().render(this.view).dom; - this.tooltip.appendChild(this._brushDropdownDom); - - // star - this.tooltip.appendChild(this.createStar().render(this.view).dom); - - // list types dropdown - this.updateListItemDropdown(":", this.listTypeBtnDom); - - await this.updateFromDash(view, undefined, undefined); - } - - initFontStyles() { - this.fontStyles.push(schema.marks.pFontFamily.create({ family: "Times New Roman" })); - this.fontStyles.push(schema.marks.pFontFamily.create({ family: "Arial" })); - this.fontStyles.push(schema.marks.pFontFamily.create({ family: "Georgia" })); - this.fontStyles.push(schema.marks.pFontFamily.create({ family: "Comic Sans MS" })); - this.fontStyles.push(schema.marks.pFontFamily.create({ family: "Tahoma" })); - this.fontStyles.push(schema.marks.pFontFamily.create({ family: "Impact" })); - this.fontStyles.push(schema.marks.pFontFamily.create({ family: "Crimson Text" })); - } - - initFontSizes() { this.fontSizes.push(schema.marks.pFontSize.create({ fontSize: 7 })); this.fontSizes.push(schema.marks.pFontSize.create({ fontSize: 8 })); this.fontSizes.push(schema.marks.pFontSize.create({ fontSize: 9 })); @@ -197,56 +142,89 @@ export class TooltipTextMenu { this.fontSizes.push(schema.marks.pFontSize.create({ fontSize: 32 })); this.fontSizes.push(schema.marks.pFontSize.create({ fontSize: 48 })); this.fontSizes.push(schema.marks.pFontSize.create({ fontSize: 72 })); - } - initListTypes() { - this.listTypeToIcon = new Map(); - //this.listTypeToIcon.set(schema.nodes.bullet_list, ":"); - this.listTypeToIcon.set(schema.nodes.ordered_list.create({ mapStyle: "bullet" }), ":"); - this.listTypeToIcon.set(schema.nodes.ordered_list.create({ mapStyle: "decimal" }), "1.1)"); - this.listTypeToIcon.set(schema.nodes.ordered_list.create({ mapStyle: "multi" }), "1.A)"); - // this.listTypeToIcon.set(schema.nodes.bullet_list, "⬜"); - this.listTypes = Array.from(this.listTypeToIcon.keys()); - } + // font sizes + this.fontStyles.push(schema.marks.pFontFamily.create({ family: "Times New Roman" })); + this.fontStyles.push(schema.marks.pFontFamily.create({ family: "Arial" })); + this.fontStyles.push(schema.marks.pFontFamily.create({ family: "Georgia" })); + this.fontStyles.push(schema.marks.pFontFamily.create({ family: "Comic Sans MS" })); + this.fontStyles.push(schema.marks.pFontFamily.create({ family: "Tahoma" })); + this.fontStyles.push(schema.marks.pFontFamily.create({ family: "Impact" })); + this.fontStyles.push(schema.marks.pFontFamily.create({ family: "Crimson Text" })); - // creates dragger element that allows dragging and collapsing (on double click) - // of editor and appends it to the wrapper - createDragger() { - const draggerWrapper = document.createElement("div"); - draggerWrapper.className = "dragger-wrapper"; - const dragger = document.createElement("div"); - dragger.className = "dragger"; + // init brush tool + this._brushdom = this.createBrushTool().render(this.view).dom; + this.tooltip.appendChild(this._brushdom); + this._brushDropdownDom = this.createBrushDropdown().render(this.view).dom; + this.tooltip.appendChild(this._brushDropdownDom); - const line1 = document.createElement("span"); - line1.className = "dragger-line"; - const line2 = document.createElement("span"); - line2.className = "dragger-line"; - const line3 = document.createElement("span"); - line3.className = "dragger-line"; + // summarizer tool + const summarizer = new MenuItem({ + title: "Summarize", + label: "Summarize", + icon: icons.join, + css: "fill:white;", + class: "menuicon", + execEvent: "", + run: (state, dispatch) => TooltipTextMenu.insertSummarizer(state, dispatch) + }); + this.tooltip.appendChild(summarizer.render(this.view).dom); - dragger.appendChild(line1); - dragger.appendChild(line2); - dragger.appendChild(line3); + // list types dropdown + const listDropdownTypes = [{ mapStyle: "bullet", label: ":" }, { mapStyle: "decimal", label: "1.1" }, { mapStyle: "multi", label: "A.1" }, { label: "X" }]; + const listTypes = new Dropdown(listDropdownTypes.map(({ mapStyle, label }) => + new MenuItem({ + title: "Set Bullet Style", + label: label, + execEvent: "", + class: "dropdown-item", + css: "color: black; width: 40px;", + enable() { return true; }, + run() { + const marks = self.view.state.storedMarks || (view.state.selection.$to.parentOffset && view.state.selection.$from.marks()); + if (!wrapInList(schema.nodes.ordered_list)(view.state, (tx2: any) => { + const tx3 = updateBullets(tx2, schema, mapStyle); + marks && tx3.ensureMarks([...marks]); + marks && tx3.setStoredMarks([...marks]); + + view.dispatch(tx2); + })) { + const tx2 = view.state.tr; + const tx3 = updateBullets(tx2, schema, mapStyle); + marks && tx3.ensureMarks([...marks]); + marks && tx3.setStoredMarks([...marks]); + + view.dispatch(tx3); + } + } + })), { label: ":", css: "color:black; width: 40px;" }); + this.tooltip.appendChild(listTypes.render(this.view).dom); - draggerWrapper.appendChild(dragger); + await this.updateFromDash(view, undefined, undefined); + const draggerWrapper = TooltipTextMenu.createDiv("dragger-wrapper"); + const dragger = TooltipTextMenu.createDiv("dragger"); + dragger.appendChild(TooltipTextMenu.createSpan("dragger-line")); + dragger.appendChild(TooltipTextMenu.createSpan("dragger-line")); + dragger.appendChild(TooltipTextMenu.createSpan("dragger-line")); + draggerWrapper.appendChild(dragger); this.wrapper.appendChild(draggerWrapper); - this.dragElement(draggerWrapper); + this.setupDragElementInteractions(draggerWrapper); } - dragElement(elmnt: HTMLElement) { + setupDragElementInteractions(elmnt: HTMLElement) { var pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0; if (elmnt) { // if present, the header is where you move the DIV from: - elmnt.onpointerdown = dragMouseDown; + elmnt.onpointerdown = dragPointerDown; elmnt.ondblclick = onClick; } const self = this; - function dragMouseDown(e: PointerEvent) { + function dragPointerDown(e: PointerEvent) { e = e || window.event; - //e.preventDefault(); + e.preventDefault(); // get the mouse cursor position at startup: pos3 = e.clientX; pos4 = e.clientY; @@ -288,8 +266,6 @@ export class TooltipTextMenu { // stop moving when mouse button is released: document.onpointerup = null; document.onpointermove = null; - //self.highlightSearchTerms(self.state, ["hello"]); - //FormattedTextBox.Instance.unhighlightSearchTerms(); } } @@ -297,15 +273,33 @@ export class TooltipTextMenu { updateFontSizeDropdown(label: string) { //font SIZES const fontSizeBtns: MenuItem[] = []; - this.fontSizes.forEach(mark => { - fontSizeBtns.push(this.dropdownFontSizeBtn(String(mark.attrs.fontSize), "color: black; width: 50px;", mark, this.view, this.changeToFontSize)); - }); + const self = this; + this.fontSizes.forEach(mark => + fontSizeBtns.push(new MenuItem({ + title: "Set Font Size", + label: String(mark.attrs.fontSize), + execEvent: "", + class: "dropdown-item", + css: "color: black; width: 50px;", + enable() { return true; }, + run() { + const size = mark.attrs.fontSize; + if (size) { self.updateFontSizeDropdown(String(size) + " pt"); } + if (self.editorProps) { + const ruleProvider = self.editorProps.ruleProvider; + const heading = NumCast(self.editorProps.Document.heading); + if (ruleProvider && heading) { + ruleProvider["ruleSize_" + heading] = size; + } + } + TooltipTextMenu.setMark(self.view.state.schema.marks.pFontSize.create({ fontSize: size }), self.view.state, self.view.dispatch); + } + }))); - const newfontSizeDom = (new Dropdown(fontSizeBtns, { - label: label, - css: "color:black; min-width: 60px;" - }) as MenuItem).render(this.view).dom; - if (this.fontSizeDom) { this.tooltip.replaceChild(newfontSizeDom, this.fontSizeDom); } + const newfontSizeDom = (new Dropdown(fontSizeBtns, { label: label, css: "color:black; min-width: 60px;" }) as MenuItem).render(this.view).dom; + if (this.fontSizeDom) { + this.tooltip.replaceChild(newfontSizeDom, this.fontSizeDom); + } else { this.tooltip.appendChild(newfontSizeDom); } @@ -316,112 +310,38 @@ export class TooltipTextMenu { updateFontStyleDropdown(label: string) { //font STYLES const fontBtns: MenuItem[] = []; - this.fontStyles.forEach((mark) => { - fontBtns.push(this.dropdownFontFamilyBtn(mark.attrs.family, "color: black; font-family: " + mark.attrs.family + ", sans-serif; width: 125px;", mark, this.view, this.changeToFontFamily)); - }); + const self = this; + this.fontStyles.forEach(mark => + fontBtns.push(new MenuItem({ + title: "Set Font Family", + label: mark.attrs.family, + execEvent: "", + class: "dropdown-item", + css: "color: black; font-family: " + mark.attrs.family + ", sans-serif; width: 125px;", + enable() { return true; }, + run() { + const fontName = mark.attrs.family; + if (fontName) { self.updateFontStyleDropdown(fontName); } + if (self.editorProps) { + const ruleProvider = self.editorProps.ruleProvider; + const heading = NumCast(self.editorProps.Document.heading); + if (ruleProvider && heading) { + ruleProvider["ruleFont_" + heading] = fontName; + } + } + TooltipTextMenu.setMark(self.view.state.schema.marks.pFontFamily.create({ family: fontName }), self.view.state, self.view.dispatch); + } + }))); - const newfontStyleDom = (new Dropdown(fontBtns, { - label: label, - css: "color:black; width: 125px;" - }) as MenuItem).render(this.view).dom; - if (this.fontStyleDom) { this.tooltip.replaceChild(newfontStyleDom, this.fontStyleDom); } + const newfontStyleDom = (new Dropdown(fontBtns, { label: label, css: "color:black; width: 125px;" }) as MenuItem).render(this.view).dom; + if (this.fontStyleDom) { + this.tooltip.replaceChild(newfontStyleDom, this.fontStyleDom); + } else { this.tooltip.appendChild(newfontStyleDom); } this.fontStyleDom = newfontStyleDom; } - - updateLinkMenu() { - if (!this.linkEditor || !this.linkText) { - this.linkEditor = document.createElement("div"); - this.linkEditor.className = "ProseMirror-icon menuicon"; - this.linkText = document.createElement("div"); - this.linkText.setAttribute("contenteditable", "true"); - this.linkText.style.whiteSpace = "nowrap"; - this.linkText.style.width = "150px"; - this.linkText.style.overflow = "hidden"; - this.linkText.style.color = "white"; - this.linkText.onpointerdown = (e: PointerEvent) => { e.stopPropagation(); }; - const linkBtn = document.createElement("div"); - linkBtn.textContent = ">>"; - linkBtn.style.width = "10px"; - linkBtn.style.height = "10px"; - linkBtn.style.color = "white"; - linkBtn.style.cssFloat = "left"; - linkBtn.onpointerdown = (e: PointerEvent) => { - const node = this.view.state.selection.$from.nodeAfter; - const link = node && node.marks.find(m => m.type.name === "link"); - if (link) { - const href: string = link.attrs.href; - if (href.indexOf(Utils.prepend("/doc/")) === 0) { - const docid = href.replace(Utils.prepend("/doc/"), ""); - DocServer.GetRefField(docid).then(action((f: Opt<Field>) => { - if (f instanceof Doc) { - if (DocumentManager.Instance.getDocumentView(f)) { - DocumentManager.Instance.getDocumentView(f)!.props.focus(f, false); - } - else this.editorProps && this.editorProps.addDocTab(f, undefined, "onRight"); - } - })); - } - // TODO This should have an else to handle external links - e.stopPropagation(); - e.preventDefault(); - } - }; - this.linkDrag = document.createElement("img"); - this.linkDrag.src = "https://seogurusnyc.com/wp-content/uploads/2016/12/link-1.png"; - this.linkDrag.style.width = "15px"; - this.linkDrag.style.height = "15px"; - this.linkDrag.title = "Drag to create link"; - this.linkDrag.id = "link-drag"; - this.linkDrag.onpointerdown = (e: PointerEvent) => { - if (!this.editorProps) return; - const dragData = new DragManager.LinkDragData(this.editorProps.Document); - dragData.dontClearTextBox = true; - // hack to get source context -sy - const docView = DocumentManager.Instance.getDocumentView(this.editorProps.Document); - e.stopPropagation(); - const ctrlKey = e.ctrlKey; - DragManager.StartLinkDrag(this.linkDrag!, dragData, e.clientX, e.clientY, - { - handlers: { - dragComplete: action(() => { - if (dragData.linkDocument) { - const linkDoc = dragData.linkDocument; - const proto = Doc.GetProto(linkDoc); - if (proto && docView) { - proto.sourceContext = docView.props.ContainingCollectionDoc; - } - const text = this.makeLink(linkDoc, StrCast(linkDoc.anchor2.title), ctrlKey ? "onRight" : "inTab"); - if (linkDoc instanceof Doc && linkDoc.anchor2 instanceof Doc) { - proto.title = text === "" ? proto.title : text + " to " + linkDoc.anchor2.title; // TODODO open to more descriptive descriptions of following in text link - } - } - }), - }, - hideSource: false - }); - e.stopPropagation(); - e.preventDefault(); - }; - this.linkEditor.appendChild(this.linkDrag); - this.tooltip.appendChild(this.linkEditor); - } - - const node = this.view.state.selection.$from.nodeAfter; - const link = node && node.marks.find(m => m.type.name === "link"); - this.linkText.textContent = link ? link.attrs.href : "-empty-"; - - this.linkText.onkeydown = (e: KeyboardEvent) => { - if (e.key === "Enter") { - // this.makeLink(this.linkText!.textContent!); - e.stopPropagation(); - e.preventDefault(); - } - }; - } - async getTextLinkTargetTitle() { const node = this.view.state.selection.$from.nodeAfter; const link = node && node.marks.find(m => m.type.name === "link"); @@ -455,8 +375,21 @@ export class TooltipTextMenu { } } - async createLinkDropdown() { - const targetTitle = await this.getTextLinkTargetTitle(); + // LINK TOOL + createLinkTool(active: boolean = false) { + return new MenuItem({ + title: "Link tool", + label: "Link tool", + icon: icons.link, + css: "fill:white;", + class: active ? "menuicon-active" : "menuicon", + execEvent: "", + run: async (state, dispatch) => { }, + active: (state) => true + }); + } + + createLinkDropdown(targetTitle: string) { const input = document.createElement("input"); // menu item for input for hyperlink url @@ -484,9 +417,7 @@ export class TooltipTextMenu { return div; }, enable() { return false; }, - run(p1, p2, p3, event) { - event.stopPropagation(); - } + run(p1, p2, p3, event) { event.stopPropagation(); } }); // menu item to update/apply the hyperlink to the selected text @@ -502,9 +433,22 @@ export class TooltipTextMenu { return button; }, enable() { return false; }, - run: (state, dispatch, view, event) => { + run: async (state, dispatch, view, event) => { event.stopPropagation(); - this.makeLinkToURL(input.value, "onRight"); + let node = this.view.state.selection.$from.nodeAfter; + let link = this.view.state.schema.mark(this.view.state.schema.marks.link, { href: input.value, location: "onRight" }); + this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link)); + this.view.dispatch(this.view.state.tr.addMark(this.view.state.selection.from, this.view.state.selection.to, link)); + node = this.view.state.selection.$from.nodeAfter; + link = node && node.marks.find(m => m.type.name === "link"); + + // update link menu + const linkDom = self.createLinkTool(true).render(self.view).dom; + const linkDropdownDom = self.createLinkDropdown(await self.getTextLinkTargetTitle()).render(self.view).dom; + self.linkDom && self.tooltip.replaceChild(linkDom, self.linkDom); + self.linkDropdownDom && self.tooltip.replaceChild(linkDropdownDom, self.linkDropdownDom); + self.linkDom = linkDom; + self.linkDropdownDom = linkDropdownDom; } }); @@ -526,190 +470,55 @@ export class TooltipTextMenu { }, enable() { return true; }, async run() { - self.deleteLink(); - // update link dropdown - const dropdown = await self.createLinkDropdown(); - const newLinkDropdowndom = dropdown.render(self.view).dom; - self._linkDropdownDom && self.tooltip.replaceChild(newLinkDropdowndom, self._linkDropdownDom); - self._linkDropdownDom = newLinkDropdowndom; - } - }); - - - const linkDropdown = new Dropdown(targetTitle ? [linkInfo, linkApply, deleteLink] : [linkInfo, linkApply], { class: "buttonSettings-dropdown" }) as MenuItem; - return linkDropdown; - } - - // makeLinkWithState = (state: EditorState, target: string, location: string) => { - // let link = state.schema.mark(state.schema.marks.link, { href: target, location: location }); - // } - - makeLink = (targetDoc: Doc, title: string, location: string): string => { - const link = this.view.state.schema.marks.link.create({ href: Utils.prepend("/doc/" + targetDoc[Id]), title: title, location: location }); - this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link). - addMark(this.view.state.selection.from, this.view.state.selection.to, link)); - const node = this.view.state.selection.$from.nodeAfter; - if (node && node.text) { - return node.text; - } - return ""; - } - - makeLinkToURL = (target: String, lcoation: string) => { - let node = this.view.state.selection.$from.nodeAfter; - let link = this.view.state.schema.mark(this.view.state.schema.marks.link, { href: target, location: location }); - this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link)); - this.view.dispatch(this.view.state.tr.addMark(this.view.state.selection.from, this.view.state.selection.to, link)); - node = this.view.state.selection.$from.nodeAfter; - link = node && node.marks.find(m => m.type.name === "link"); - } - - deleteLink = () => { - const node = this.view.state.selection.$from.nodeAfter; - const link = node && node.marks.find(m => m.type === this.view.state.schema.marks.link); - const href = link!.attrs.href; - if (href) { - if (href.indexOf(Utils.prepend("/doc/")) === 0) { - const linkclicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0]; - if (linkclicked) { - DocServer.GetRefField(linkclicked).then(async linkDoc => { + // delete the link + const node = self.view.state.selection.$from.nodeAfter; + const link = node && node.marks.find(m => m.type === self.view.state.schema.marks.link); + const href = link!.attrs.href; + if (href ?.indexOf(Utils.prepend("/doc/")) === 0) { + const linkclicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0]; + linkclicked && DocServer.GetRefField(linkclicked).then(async linkDoc => { if (linkDoc instanceof Doc) { LinkManager.Instance.deleteLink(linkDoc); - this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link)); + self.view.dispatch(self.view.state.tr.removeMark(self.view.state.selection.from, self.view.state.selection.to, self.view.state.schema.marks.link)); } }); } - } - } - } - - deleteLinkItem() { - const icon = { - height: 16, width: 16, - path: "M15.898,4.045c-0.271-0.272-0.713-0.272-0.986,0l-4.71,4.711L5.493,4.045c-0.272-0.272-0.714-0.272-0.986,0s-0.272,0.714,0,0.986l4.709,4.711l-4.71,4.711c-0.272,0.271-0.272,0.713,0,0.986c0.136,0.136,0.314,0.203,0.492,0.203c0.179,0,0.357-0.067,0.493-0.203l4.711-4.711l4.71,4.711c0.137,0.136,0.314,0.203,0.494,0.203c0.178,0,0.355-0.067,0.492-0.203c0.273-0.273,0.273-0.715,0-0.986l-4.711-4.711l4.711-4.711C16.172,4.759,16.172,4.317,15.898,4.045z" - }; - return new MenuItem({ - title: "Delete Link", - label: "X", - icon: icon, - css: "color: red", - class: "summarize", - execEvent: "", - run: (state, dispatch) => { - this.deleteLink(); + // update link menu + const linkDom = self.createLinkTool(false).render(self.view).dom; + const linkDropdownDom = self.createLinkDropdown("").render(self.view).dom; + self.linkDom && self.tooltip.replaceChild(linkDom, self.linkDom); + self.linkDropdownDom && self.tooltip.replaceChild(linkDropdownDom, self.linkDropdownDom); + self.linkDom = linkDom; + self.linkDropdownDom = linkDropdownDom; } }); - } - createLink() { - const markType = schema.marks.link; - return new MenuItem({ - title: "Add or remove link", - label: "Add or remove link", - execEvent: "", - icon: icons.link, - css: "color:white;", - class: "menuicon", - enable(state) { return !state.selection.empty; }, - run: (state, dispatch, view) => { - // to remove link - let curLink = ""; - if (this.markActive(state, markType)) { - - const { from, $from, to, empty } = state.selection; - const node = state.doc.nodeAt(from); - node && node.marks.map(m => { - m.type === markType && (curLink = m.attrs.href); - }); - //toggleMark(markType)(state, dispatch); - //return true; - } - // to create link - openPrompt({ - title: "Create a link", - fields: { - href: new TextField({ - value: curLink, - label: "Link Target", - required: true - }), - title: new TextField({ label: "Title" }) - }, - callback(attrs: any) { - toggleMark(markType, attrs)(view.state, view.dispatch); - view.focus(); - }, - flyout_top: 0, - flyout_left: 0 - }); - } - }); + return new Dropdown(targetTitle ? [linkInfo, linkApply, deleteLink] : [linkInfo, linkApply], { class: "buttonSettings-dropdown" }) as MenuItem; } - //will display a remove-list-type button if selection is in list, otherwise will show list type dropdown - updateListItemDropdown(label: string, listTypeBtn: any) { - //remove old btn - if (listTypeBtn) { this.tooltip.removeChild(listTypeBtn); } - - //Make a dropdown of all list types - const toAdd: MenuItem[] = []; - this.listTypeToIcon.forEach((icon, type) => { - toAdd.push(this.dropdownNodeBtn(icon, "color: black; width: 40px;", type, this.view, this.listTypes, this.changeToNodeType)); - }); - //option to remove the list formatting - toAdd.push(this.dropdownNodeBtn("X", "color: black; width: 40px;", undefined, this.view, this.listTypes, this.changeToNodeType)); - - listTypeBtn = (new Dropdown(toAdd, { - label: label, - css: "color:black; width: 40px;" - }) as MenuItem).render(this.view).dom; - - //add this new button and return it - this.tooltip.appendChild(listTypeBtn); - return listTypeBtn; - } - - createStar() { - return new MenuItem({ - title: "Summarize", - label: "Summarize", - icon: icons.join, - css: "color:white;", - class: "menuicon", - execEvent: "", - run: (state, dispatch) => { - TooltipTextMenu.insertStar(this.view.state, this.view.dispatch); - } - - }); - } - - public static insertStar(state: EditorState<any>, dispatch: any) { - if (state.selection.empty) return false; - const mark = state.schema.marks.highlight.create(); - const tr = state.tr; - tr.addMark(state.selection.from, state.selection.to, mark); - const content = tr.selection.content(); - const newNode = state.schema.nodes.star.create({ visibility: false, text: content, textslice: content.toJSON() }); - dispatch && dispatch(tr.replaceSelectionWith(newNode).removeMark(tr.selection.from - 1, tr.selection.from, mark)); - return true; + public MakeLinkToSelection = (linkDocId: string, title: string, location: string, targetDocId: string): string => { + const link = this.view.state.schema.marks.link.create({ href: Utils.prepend("/doc/" + linkDocId), title: title, location: location, targetId: targetDocId }); + this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link). + addMark(this.view.state.selection.from, this.view.state.selection.to, link)); + return this.view.state.selection.$from.nodeAfter ?.text || ""; } - public static insertComment(state: EditorState<any>, dispatch: any) { - if (state.selection.empty) return false; - const mark = state.schema.marks.highlight.create(); - const tr = state.tr; - tr.addMark(state.selection.from, state.selection.to, mark); - const content = tr.selection.content(); - const newNode = state.schema.nodes.star.create({ visibility: false, text: content, textslice: content.toJSON() }); - dispatch && dispatch(tr.replaceSelectionWith(newNode).removeMark(tr.selection.from - 1, tr.selection.from, mark)); - return true; + // SUMMARIZER TOOL + static insertSummarizer(state: EditorState<any>, dispatch: any) { + if (!state.selection.empty) { + const mark = state.schema.marks.summarize.create(); + const tr = state.tr.addMark(state.selection.from, state.selection.to, mark); + const content = tr.selection.content(); + const newNode = state.schema.nodes.summary.create({ visibility: false, text: content, textslice: content.toJSON() }); + dispatch ?.(tr.replaceSelectionWith(newNode).removeMark(tr.selection.from - 1, tr.selection.from, mark)); + } } + // HIGHLIGHTER TOOL createHighlightTool() { return new MenuItem({ title: "Highlight", - css: "color:white;", + css: "fill:white;", class: "menuicon", execEvent: "", render() { @@ -719,27 +528,22 @@ export class TooltipTextMenu { path.setAttributeNS(null, "d", "M0 479.98L99.92 512l35.45-35.45-67.04-67.04L0 479.98zm124.61-240.01a36.592 36.592 0 0 0-10.79 38.1l13.05 42.83-50.93 50.94 96.23 96.23 50.86-50.86 42.74 13.08c13.73 4.2 28.65-.01 38.15-10.78l35.55-41.64-173.34-173.34-41.52 35.44zm403.31-160.7l-63.2-63.2c-20.49-20.49-53.38-21.52-75.12-2.35L190.55 183.68l169.77 169.78L530.27 154.4c19.18-21.74 18.15-54.63-2.35-75.13z"); svg.appendChild(path); - const color = document.createElement("div"); - color.className = "buttonColor"; - color.style.backgroundColor = TooltipTextMenuManager.Instance.highlight.toString(); + const color = TooltipTextMenu.createDiv("buttonColor"); + color.style.backgroundColor = TooltipTextMenuManager.Instance.highlighter.toString(); - const wrapper = document.createElement("div"); - wrapper.id = "colorPicker"; + const wrapper = TooltipTextMenu.createDiv("colorPicker"); wrapper.appendChild(svg); wrapper.appendChild(color); return wrapper; }, - run: (state, dispatch) => { - TooltipTextMenu.insertHighlight(TooltipTextMenuManager.Instance.highlight, this.view.state, this.view.dispatch); - } + run: (state, dispatch) => TooltipTextMenu.insertHighlight(TooltipTextMenuManager.Instance.highlighter, state, dispatch) }); } - public static insertHighlight(color: String, state: EditorState<any>, dispatch: any) { - if (state.selection.empty) return false; - - const highlightMark = state.schema.mark(state.schema.marks.marker, { highlight: color }); - dispatch(state.tr.addMark(state.selection.from, state.selection.to, highlightMark)); + static insertHighlight(color: String, state: EditorState<any>, dispatch: any) { + if (!state.selection.empty) { + toggleMark(state.schema.marks.marker, { highlight: color })(state, dispatch); + } } createHighlightDropdown() { @@ -754,8 +558,7 @@ export class TooltipTextMenu { const p = document.createElement("p"); p.textContent = "Change highlight:"; - const colorsWrapper = document.createElement("div"); - colorsWrapper.className = "colorPicker-wrapper"; + const colorsWrapper = TooltipTextMenu.createDiv("colorPicker-wrapper"); const colors = [ PastelSchemaPalette.get("pink2"), @@ -772,22 +575,22 @@ export class TooltipTextMenu { colors.forEach(color => { const button = document.createElement("button"); - button.className = color === TooltipTextMenuManager.Instance.highlight ? "colorPicker active" : "colorPicker"; + button.className = color === TooltipTextMenuManager.Instance.highlighter ? "colorPicker active" : "colorPicker"; if (color) { button.style.backgroundColor = color; button.textContent = color === "transparent" ? "X" : ""; button.onclick = e => { - TooltipTextMenuManager.Instance.highlight = color; + TooltipTextMenuManager.Instance.highlighter = color; - TooltipTextMenu.insertHighlight(TooltipTextMenuManager.Instance.highlight, self.view.state, self.view.dispatch); + TooltipTextMenu.insertHighlight(TooltipTextMenuManager.Instance.highlighter, self.view.state, self.view.dispatch); // update color menu const highlightDom = self.createHighlightTool().render(self.view).dom; const highlightDropdownDom = self.createHighlightDropdown().render(self.view).dom; - self.highlightDom && self.tooltip.replaceChild(highlightDom, self.highlightDom); - self.highlightDropdownDom && self.tooltip.replaceChild(highlightDropdownDom, self.highlightDropdownDom); - self.highlightDom = highlightDom; - self.highlightDropdownDom = highlightDropdownDom; + self.highighterDom && self.tooltip.replaceChild(highlightDom, self.highighterDom); + self.highlighterDropdownDom && self.tooltip.replaceChild(highlightDropdownDom, self.highlighterDropdownDom); + self.highighterDom = highlightDom; + self.highlighterDropdownDom = highlightDropdownDom; }; } colorsWrapper.appendChild(button); @@ -804,14 +607,14 @@ export class TooltipTextMenu { } }); - const colorDropdown = new Dropdown([colors], { class: "buttonSettings-dropdown" }) as MenuItem; - return colorDropdown; + return new Dropdown([colors], { class: "buttonSettings-dropdown" }) as MenuItem; } + // COLOR TOOL createColorTool() { return new MenuItem({ title: "Color", - css: "color:white;", + css: "fill:white;", class: "menuicon", execEvent: "", render() { @@ -821,27 +624,25 @@ export class TooltipTextMenu { path.setAttributeNS(null, "d", "M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z"); svg.appendChild(path); - const color = document.createElement("div"); - color.className = "buttonColor"; + const color = TooltipTextMenu.createDiv("buttonColor"); color.style.backgroundColor = TooltipTextMenuManager.Instance.color.toString(); - const wrapper = document.createElement("div"); - wrapper.id = "colorPicker"; + const wrapper = TooltipTextMenu.createDiv("colorPicker"); wrapper.appendChild(svg); wrapper.appendChild(color); return wrapper; }, - run: (state, dispatch) => { - TooltipTextMenu.insertColor(TooltipTextMenuManager.Instance.color, this.view.state, this.view.dispatch); - } + run: (state, dispatch) => TooltipTextMenu.insertColor(TooltipTextMenuManager.Instance.color, state, dispatch) }); } - public static insertColor(color: String, state: EditorState<any>, dispatch: any) { - if (state.selection.empty) return false; - - const colorMark = state.schema.mark(state.schema.marks.color, { color: color }); - dispatch(state.tr.addMark(state.selection.from, state.selection.to, colorMark)); + static insertColor(color: String, state: EditorState<any>, dispatch: any) { + const colorMark = state.schema.mark(state.schema.marks.pFontColor, { color: color }); + if (state.selection.empty) { + dispatch(state.tr.addStoredMark(colorMark)); + } else { + this.setMark(colorMark, state, dispatch); + } } createColorDropdown() { @@ -856,8 +657,7 @@ export class TooltipTextMenu { const p = document.createElement("p"); p.textContent = "Change color:"; - const colorsWrapper = document.createElement("div"); - colorsWrapper.className = "colorPicker-wrapper"; + const colorsWrapper = TooltipTextMenu.createDiv("colorPicker-wrapper"); const colors = [ DarkPastelSchemaPalette.get("pink2"), @@ -900,16 +700,14 @@ export class TooltipTextMenu { return div; }, enable() { return false; }, - run(p1, p2, p3, event) { - event.stopPropagation(); - } + run(p1, p2, p3, event) { event.stopPropagation(); } }); - const colorDropdown = new Dropdown([colors], { class: "buttonSettings-dropdown" }) as MenuItem; - return colorDropdown; + return new Dropdown([colors], { class: "buttonSettings-dropdown" }) as MenuItem; } - createBrush(active: boolean = false) { + // BRUSH TOOL + createBrushTool(active: boolean = false) { const icon = { height: 32, width: 32, path: "M30.828 1.172c-1.562-1.562-4.095-1.562-5.657 0l-5.379 5.379-3.793-3.793-4.243 4.243 3.326 3.326-14.754 14.754c-0.252 0.252-0.358 0.592-0.322 0.921h-0.008v5c0 0.552 0.448 1 1 1h5c0 0 0.083 0 0.125 0 0.288 0 0.576-0.11 0.795-0.329l14.754-14.754 3.326 3.326 4.243-4.243-3.793-3.793 5.379-5.379c1.562-1.562 1.562-4.095 0-5.657zM5.409 30h-3.409v-3.409l14.674-14.674 3.409 3.409-14.674 14.674z" @@ -919,7 +717,7 @@ export class TooltipTextMenu { title: "Brush tool", label: "Brush tool", icon: icon, - css: "color:white;", + css: "fill:white;", class: active ? "menuicon-active" : "menuicon", execEvent: "", run: (state, dispatch) => { @@ -930,23 +728,23 @@ export class TooltipTextMenu { self._brushDropdownDom && self.tooltip.replaceChild(newBrushDropdowndom, self._brushDropdownDom); self._brushDropdownDom = newBrushDropdowndom; }, - active: (state) => { - return true; - } + active: (state) => true }); } brush_function(state: EditorState<any>, dispatch: any) { if (TooltipTextMenuManager.Instance._brushIsEmpty) { - const selected_marks = this.getMarksInSelection(this.view.state); - if (this._brushdom) { - if (selected_marks.size >= 0) { - TooltipTextMenuManager.Instance._brushMarks = selected_marks; - const newbrush = this.createBrush(true).render(this.view).dom; - this.tooltip.replaceChild(newbrush, this._brushdom); - this._brushdom = newbrush; - TooltipTextMenuManager.Instance._brushIsEmpty = !TooltipTextMenuManager.Instance._brushIsEmpty; - } + // get marks in the selection + const selected_marks = new Set<Mark>(); + const { from, to } = state.selection as TextSelection; + state.doc.nodesBetween(from, to, (node) => node.marks ?.forEach(m => selected_marks.add(m))); + + if (this._brushdom && selected_marks.size >= 0) { + TooltipTextMenuManager.Instance._brushMarks = selected_marks; + const newbrush = this.createBrushTool(true).render(this.view).dom; + this.tooltip.replaceChild(newbrush, this._brushdom); + this._brushdom = newbrush; + TooltipTextMenuManager.Instance._brushIsEmpty = !TooltipTextMenuManager.Instance._brushIsEmpty; } } else { @@ -956,13 +754,12 @@ export class TooltipTextMenu { if (TooltipTextMenuManager.Instance._brushMarks && to - from > 0) { this.view.dispatch(this.view.state.tr.removeMark(from, to)); Array.from(TooltipTextMenuManager.Instance._brushMarks).filter(m => m.type !== schema.marks.user_mark).forEach((mark: Mark) => { - const markType = mark.type; - this.changeToMarkInGroup(markType, this.view, []); + TooltipTextMenu.setMark(mark, this.view.state, this.view.dispatch); }); } } else { - const newbrush = this.createBrush(false).render(this.view).dom; + const newbrush = this.createBrushTool(false).render(this.view).dom; this.tooltip.replaceChild(newbrush, this._brushdom); this._brushdom = newbrush; TooltipTextMenuManager.Instance._brushIsEmpty = !TooltipTextMenuManager.Instance._brushIsEmpty; @@ -974,17 +771,12 @@ export class TooltipTextMenu { createBrushDropdown(active: boolean = false) { let label = "Stored marks: "; if (TooltipTextMenuManager.Instance._brushMarks && TooltipTextMenuManager.Instance._brushMarks.size > 0) { - TooltipTextMenuManager.Instance._brushMarks.forEach((mark: Mark) => { - const markType = mark.type; - label += markType.name; - label += ", "; - }); + TooltipTextMenuManager.Instance._brushMarks.forEach((mark: Mark) => label += mark.type.name + ", "); label = label.substring(0, label.length - 2); } else { label = "No marks are currently stored"; } - const brushInfo = new MenuItem({ title: "", label: label, @@ -992,12 +784,11 @@ export class TooltipTextMenu { class: "button-setting-disabled", css: "", enable() { return false; }, - run(p1, p2, p3, event) { - event.stopPropagation(); - } + run(p1, p2, p3, event) { event.stopPropagation(); } }); const self = this; + const input = document.createElement("input"); const clearBrush = new MenuItem({ title: "Clear brush", execEvent: "", @@ -1007,7 +798,31 @@ export class TooltipTextMenu { const button = document.createElement("button"); button.textContent = "Clear brush"; + input.textContent = "editme"; + input.style.width = "75px"; + input.style.height = "30px"; + input.style.background = "white"; + input.setAttribute("contenteditable", "true"); + input.style.whiteSpace = "nowrap"; + input.type = "text"; + input.placeholder = "Enter URL"; + input.onpointerdown = (e: PointerEvent) => { + e.stopPropagation(); + e.preventDefault(); + }; + input.onclick = (e: MouseEvent) => { + input.select(); + input.focus(); + }; + input.onkeypress = (e: KeyboardEvent) => { + if (e.key === "Enter") { + TooltipTextMenuManager.Instance._brushMarks && TooltipTextMenuManager.Instance._brushMap.set(input.value, TooltipTextMenuManager.Instance._brushMarks); + input.style.background = "lightGray"; + } + }; + const wrapper = document.createElement("div"); + wrapper.appendChild(input); wrapper.appendChild(button); return wrapper; }, @@ -1018,7 +833,7 @@ export class TooltipTextMenu { // update brush tool // TODO: this probably isn't very clean - const newBrushdom = self.createBrush().render(self.view).dom; + const newBrushdom = self.createBrushTool().render(self.view).dom; self._brushdom && self.tooltip.replaceChild(newBrushdom, self._brushdom); self._brushdom = newBrushdom; const newBrushDropdowndom = self.createBrushDropdown().render(self.view).dom; @@ -1028,295 +843,31 @@ export class TooltipTextMenu { }); const hasMarks = TooltipTextMenuManager.Instance._brushMarks && TooltipTextMenuManager.Instance._brushMarks.size > 0; - const brushDom = new Dropdown(hasMarks ? [brushInfo, clearBrush] : [brushInfo], { class: "buttonSettings-dropdown" }) as MenuItem; - return brushDom; + return new Dropdown(hasMarks ? [brushInfo, clearBrush] : [brushInfo], { class: "buttonSettings-dropdown" }) as MenuItem; } - //for a specific grouping of marks (passed in), remove all and apply the passed-in one to the selected textchangeToMarkInGroup = (markType: MarkType | undefined, view: EditorView, fontMarks: MarkType[]) => { - changeToMarkInGroup = (markType: MarkType | undefined, view: EditorView, fontMarks: MarkType[]) => { - const { $cursor, ranges } = view.state.selection as TextSelection; - const state = view.state; - const dispatch = view.dispatch; - - //remove all other active font marks - fontMarks.forEach((type) => { - if (dispatch) { - if ($cursor) { - if (type.isInSet(state.storedMarks || $cursor.marks())) { - dispatch(state.tr.removeStoredMark(type)); - } - } else { - let has = false; - for (let i = 0; !has && i < ranges.length; i++) { - const { $from, $to } = ranges[i]; - has = state.doc.rangeHasMark($from.pos, $to.pos, type); - } - for (const i of ranges) { - if (has) { - toggleMark(type)(view.state, view.dispatch, view); - } - } - } - } - }); - - if (markType) { - //actually apply font - if ((view.state.selection as any).node && (view.state.selection as any).node.type === view.state.schema.nodes.ordered_list) { - const status = updateBullets(view.state.tr.setNodeMarkup(view.state.selection.from, (view.state.selection as any).node.type, - { ...(view.state.selection as NodeSelection).node.attrs, setFontFamily: markType.name, setFontSize: Number(markType.name.replace(/p/, "")) }), view.state.schema); - view.dispatch(status.setSelection(new NodeSelection(status.doc.resolve(view.state.selection.from)))); - } - else toggleMark(markType)(view.state, view.dispatch, view); - } - } - - changeToFontFamily = (mark: Mark, view: EditorView) => { - const { $cursor, ranges } = view.state.selection as TextSelection; - const state = view.state; - const dispatch = view.dispatch; - - //remove all other active font marks - if ($cursor) { - if (view.state.schema.marks.pFontFamily.isInSet(state.storedMarks || $cursor.marks())) { - dispatch(state.tr.removeStoredMark(view.state.schema.marks.pFontFamily)); - } - } else { - let has = false; - for (let i = 0; !has && i < ranges.length; i++) { - const { $from, $to } = ranges[i]; - has = state.doc.rangeHasMark($from.pos, $to.pos, view.state.schema.marks.pFontFamily); - } - for (const i of ranges) { - if (has) { - toggleMark(view.state.schema.marks.pFontFamily)(view.state, view.dispatch, view); - } - } - } - const fontName = mark.attrs.family; - if (fontName) { this.updateFontStyleDropdown(fontName); } - if (this.editorProps) { - const ruleProvider = this.editorProps.ruleProvider; - const heading = NumCast(this.editorProps.Document.heading); - if (ruleProvider && heading) { - ruleProvider["ruleFont_" + heading] = fontName; - } - } - //actually apply font - if ((view.state.selection as any).node && (view.state.selection as any).node.type === view.state.schema.nodes.ordered_list) { - const status = updateBullets(view.state.tr.setNodeMarkup(view.state.selection.from, (view.state.selection as any).node.type, - { ...(view.state.selection as NodeSelection).node.attrs, setFontFamily: fontName }), view.state.schema); - view.dispatch(status.setSelection(new NodeSelection(status.doc.resolve(view.state.selection.from)))); - } - else view.dispatch(view.state.tr.addMark(view.state.selection.from, view.state.selection.to, view.state.schema.marks.pFontFamily.create({ family: fontName }))); - view.state.storedMarks = [...(view.state.storedMarks || []), view.state.schema.marks.pFontFamily.create({ family: fontName })]; - } - - changeToFontSize = (mark: Mark, view: EditorView) => { - const { $cursor, ranges } = view.state.selection as TextSelection; - const state = view.state; - const dispatch = view.dispatch; - - //remove all other active font marks - if ($cursor) { - if (view.state.schema.marks.pFontSize.isInSet(state.storedMarks || $cursor.marks())) { - dispatch(state.tr.removeStoredMark(view.state.schema.marks.pFontSize)); - } - } else { - let has = false; - for (let i = 0; !has && i < ranges.length; i++) { - const { $from, $to } = ranges[i]; - has = state.doc.rangeHasMark($from.pos, $to.pos, view.state.schema.marks.pFontSize); - } - for (const i of ranges) { - if (has) { - toggleMark(view.state.schema.marks.pFontSize)(view.state, view.dispatch, view); - } - } - } - - const size = mark.attrs.fontSize; - if (size) { this.updateFontSizeDropdown(String(size) + " pt"); } - if (this.editorProps) { - const ruleProvider = this.editorProps.ruleProvider; - const heading = NumCast(this.editorProps.Document.heading); - if (ruleProvider && heading) { - ruleProvider["ruleSize_" + heading] = size; - } - } - //actually apply font - if ((view.state.selection as any).node && (view.state.selection as any).node.type === view.state.schema.nodes.ordered_list) { - const status = updateBullets(view.state.tr.setNodeMarkup(view.state.selection.from, (view.state.selection as any).node.type, - { ...(view.state.selection as NodeSelection).node.attrs, setFontSize: size }), view.state.schema); - view.dispatch(status.setSelection(new NodeSelection(status.doc.resolve(view.state.selection.from)))); - } - else view.dispatch(view.state.tr.addMark(view.state.selection.from, view.state.selection.to, view.state.schema.marks.pFontSize.create({ fontSize: size }))); - view.state.storedMarks = [...(view.state.storedMarks || []), view.state.schema.marks.pFontSize.create({ fontSize: size })]; - } - - //remove all node typeand apply the passed-in one to the selected text - changeToNodeType = (nodeType: NodeType | undefined) => { - //remove oldif (nodeType) { //add new - const view = this.view; - if (nodeType === schema.nodes.bullet_list) { - wrapInList(nodeType)(view.state, view.dispatch); - } else { - const marks = view.state.storedMarks || (view.state.selection.$to.parentOffset && view.state.selection.$from.marks()); - if (!wrapInList(schema.nodes.ordered_list)(view.state, (tx2: any) => { - const tx3 = updateBullets(tx2, schema, nodeType && (nodeType as any).attrs.mapStyle); - marks && tx3.ensureMarks([...marks]); - marks && tx3.setStoredMarks([...marks]); - - view.dispatch(tx2); - })) { - const tx2 = view.state.tr; - const tx3 = updateBullets(tx2, schema, nodeType && (nodeType as any).attrs.mapStyle); - marks && tx3.ensureMarks([...marks]); - marks && tx3.setStoredMarks([...marks]); - - view.dispatch(tx3); - } - } - } - - //makes a button for the drop down FOR MARKS - //css is the style you want applied to the button - dropdownFontFamilyBtn(label: string, css: string, mark: Mark, view: EditorView, changeFontFamily: (mark: Mark<any>, view: EditorView) => any) { - return new MenuItem({ - title: "", - label: label, - execEvent: "", - class: "dropdown-item", - css: css, - enable() { return true; }, - run() { - changeFontFamily(mark, view); - } - }); - } - //makes a button for the drop down FOR MARKS - //css is the style you want applied to the button - dropdownFontSizeBtn(label: string, css: string, mark: Mark, view: EditorView, changeFontSize: (markType: Mark<any>, view: EditorView) => any) { - return new MenuItem({ - title: "", - label: label, - execEvent: "", - class: "dropdown-item", - css: css, - enable() { return true; }, - run() { - changeFontSize(mark, view); - } - }); - } - - //makes a button for the drop down FOR NODE TYPES - //css is the style you want applied to the button - dropdownNodeBtn(label: string, css: string, nodeType: NodeType | undefined, view: EditorView, groupNodes: NodeType[], changeToNodeInGroup: (nodeType: NodeType<any> | undefined, view: EditorView, groupNodes: NodeType[]) => any) { - return new MenuItem({ - title: "", - label: label, - execEvent: "", - class: "dropdown-item", - css: css, - enable() { return true; }, - run() { - changeToNodeInGroup(nodeType, view, groupNodes); - } - }); - } - - markActive = function (state: EditorState<any>, type: MarkType<Schema<string, string>>) { - const { from, $from, to, empty } = state.selection; - if (empty) return type.isInSet(state.storedMarks || $from.marks()); - else return state.doc.rangeHasMark(from, to, type); - }; - - // Helper function to create menu icons - icon(text: string, name: string, title: string = name) { - const span = document.createElement("span"); - span.className = name + " menuicon"; - span.title = title; - span.textContent = text; - span.style.color = "white"; - return span; - } - - svgIcon(name: string, title: string = name, dpath: string) { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.setAttribute("viewBox", "-100 -100 650 650"); - const path = document.createElementNS('http://www.w3.org/2000/svg', "path"); - path.setAttributeNS(null, "d", dpath); - svg.appendChild(path); - - const span = document.createElement("span"); - span.className = name + " menuicon"; - span.title = title; - span.appendChild(svg); - - return span; - } - - //method for checking whether node can be inserted - canInsert(state: EditorState, nodeType: NodeType<Schema<string, string>>) { - const $from = state.selection.$from; - for (let d = $from.depth; d >= 0; d--) { - const index = $from.index(d); - if ($from.node(d).canReplaceWith(index, index, nodeType)) return true; - } - return false; - } - - - //adapted this method - use it to check if block has a tag (ie bulleting) - blockActive(type: NodeType<Schema<string, string>>, state: EditorState) { - const attrs = {}; - - if (state.selection instanceof NodeSelection) { - const sel: NodeSelection = state.selection; - const $from = sel.$from; - const to = sel.to; - const node = sel.node; - - if (node) { - return node.hasMarkup(type, attrs); - } - - return to <= $from.end() && $from.parent.hasMarkup(type, attrs); - } - } - - // Create an icon for a heading at the given level - heading(level: number) { - return { - command: setBlockType(schema.nodes.heading, { level }), - dom: this.icon("H" + level, "heading") - }; - } - - getMarksInSelection(state: EditorState<any>) { - const found = new Set<Mark>(); - const { from, to } = state.selection as TextSelection; - state.doc.nodesBetween(from, to, (node) => { - const marks = node.marks; - if (marks) { - marks.forEach(m => { - found.add(m); + static setMark = (mark: Mark, state: EditorState<any>, dispatch: any) => { + if (mark) { + const node = (state.selection as NodeSelection).node; + if (node ?.type === schema.nodes.ordered_list) { + let attrs = node.attrs; + if (mark.type === schema.marks.pFontFamily) attrs = { ...attrs, setFontFamily: mark.attrs.family }; + if (mark.type === schema.marks.pFontSize) attrs = { ...attrs, setFontSize: mark.attrs.fontSize }; + if (mark.type === schema.marks.pFontColor) attrs = { ...attrs, setFontColor: mark.attrs.color }; + const tr = updateBullets(state.tr.setNodeMarkup(state.selection.from, node.type, attrs), state.schema); + dispatch(tr.setSelection(new NodeSelection(tr.doc.resolve(state.selection.from)))); + } else { + toggleMark(mark.type, mark.attrs)(state, (tx: any) => { + const { from, $from, to, empty } = tx.selection; + if (!tx.doc.rangeHasMark(from, to, mark.type)) { + toggleMark(mark.type, mark.attrs)({ tr: tx, doc: tx.doc, selection: tx.selection, storedMarks: tx.storedMarks }, dispatch); + } else dispatch(tx); }); } - }); - return found; - } - - reset_mark_doms() { - const iterator = this._marksToDoms.values(); - let next = iterator.next(); - while (!next.done) { - next.value.style.color = "white"; - next = iterator.next(); } } + // called by Prosemirror update(view: EditorView, lastState: EditorState | undefined) { this.updateFromDash(view, lastState, this.editorProps); } //updates the tooltip menu when the selection changes public async updateFromDash(view: EditorView, lastState: EditorState | undefined, props: any) { @@ -1325,64 +876,45 @@ export class TooltipTextMenu { return; } this.view = view; - const state = view.state; DocumentDecorations.Instance.showTextBar(); props && (this.editorProps = props); - // Don't do anything if the document/selection didn't change - if (lastState && lastState.doc.eq(state.doc) && - lastState.selection.eq(state.selection)) return; - - this.reset_mark_doms(); - - // Hide the tooltip if the selection is empty - if (state.selection.empty) { - //this.tooltip.style.display = "none"; - //return; - } - // update link dropdown - const linkDropdown = await this.createLinkDropdown(); - const newLinkDropdowndom = linkDropdown.render(this.view).dom; - this._linkDropdownDom && this.tooltip.replaceChild(newLinkDropdowndom, this._linkDropdownDom); - this._linkDropdownDom = newLinkDropdowndom; - - //UPDATE FONT STYLE DROPDOWN - const activeStyles = this.activeFontFamilyOnSelection(); - if (activeStyles !== undefined) { - if (activeStyles.length === 1) { - console.log("updating font style dropdown", activeStyles[0]); - activeStyles[0] && this.updateFontStyleDropdown(activeStyles[0]); - } else this.updateFontStyleDropdown(activeStyles.length ? "various" : "default"); - } - - //UPDATE FONT SIZE DROPDOWN - const activeSizes = this.activeFontSizeOnSelection(); - if (activeSizes !== undefined) { - if (activeSizes.length === 1) { //if there's only one active font size - activeSizes[0] && this.updateFontSizeDropdown(String(activeSizes[0]) + " pt"); - } else this.updateFontSizeDropdown(activeSizes.length ? "various" : "default"); + // Don't do anything if the document/selection didn't change + if (!lastState || !lastState.doc.eq(view.state.doc) || !lastState.selection.eq(view.state.selection)) { + + // UPDATE LINK DROPDOWN + const linkTarget = await this.getTextLinkTargetTitle(); + const linkDom = this.createLinkTool(linkTarget ? true : false).render(this.view).dom; + const linkDropdownDom = this.createLinkDropdown(linkTarget).render(this.view).dom; + this.linkDom && this.tooltip.replaceChild(linkDom, this.linkDom); + this.linkDropdownDom && this.tooltip.replaceChild(linkDropdownDom, this.linkDropdownDom); + this.linkDom = linkDom; + this.linkDropdownDom = linkDropdownDom; + + //UPDATE FONT STYLE DROPDOWN + const activeStyles = this.activeFontFamilyOnSelection(); + this.updateFontStyleDropdown(activeStyles.length === 1 ? activeStyles[0] : activeStyles.length ? "various" : "default"); + + //UPDATE FONT SIZE DROPDOWN + const activeSizes = this.activeFontSizeOnSelection(); + this.updateFontSizeDropdown(activeSizes.length === 1 ? String(activeSizes[0]) + " pt" : activeSizes.length ? "various" : "default"); + + //UPDATE ALL OTHER BUTTONS + this.updateHighlightStateOfButtons(); } - - this.update_mark_doms(); } - update_mark_doms() { - this.reset_mark_doms(); - this._activeMarks.forEach((mark) => { - if (this._marksToDoms.has(mark)) { - const dom = this._marksToDoms.get(mark); - if (dom) dom.style.color = "greenyellow"; - } - }); + + updateHighlightStateOfButtons() { + Array.from(this._marksToDoms.values()).forEach(val => val.style.fill = "white"); + this.activeMarksOnSelection().filter(mark => this._marksToDoms.has(mark)).forEach(mark => + this._marksToDoms.get(mark)!.style.fill = "greenyellow"); // keeps brush tool highlighted if active when switching between textboxes - if (!TooltipTextMenuManager.Instance._brushIsEmpty) { - if (this._brushdom) { - const newbrush = this.createBrush(true).render(this.view).dom; - this.tooltip.replaceChild(newbrush, this._brushdom); - this._brushdom = newbrush; - } + if (!TooltipTextMenuManager.Instance._brushIsEmpty && this._brushdom) { + const newbrush = this.createBrushTool(true).render(this.view).dom; + this.tooltip.replaceChild(newbrush, this._brushdom); + this._brushdom = newbrush; } - } //finds fontSize at start of selection @@ -1410,24 +942,21 @@ export class TooltipTextMenu { return activeFamilies; } //finds all active marks on selection in given group - activeMarksOnSelection(markGroup: MarkType[]) { + activeMarksOnSelection() { + const markGroup = Array.from(this._marksToDoms.keys()); + if (this.view.state.storedMarks) return this.view.state.storedMarks.map(mark => mark.type); //current selection const { empty, ranges, $to } = this.view.state.selection as TextSelection; const state = this.view.state; - const dispatch = this.view.dispatch; - let activeMarks: MarkType[]; + let activeMarks: MarkType[] = []; if (!empty) { activeMarks = markGroup.filter(mark => { const has = false; for (let i = 0; !has && i < ranges.length; i++) { - const { $from, $to } = ranges[i]; - return state.doc.rangeHasMark($from.pos, $to.pos, mark); + return state.doc.rangeHasMark(ranges[i].$from.pos, ranges[i].$to.pos, mark); } return false; }); - - const refnode = this.reference_node($to); - this._activeMarks = refnode.marks; } else { const pos = this.view.state.selection.$from; @@ -1438,20 +967,14 @@ export class TooltipTextMenu { else { return []; } - this._activeMarks = ref_node.marks; activeMarks = markGroup.filter(mark_type => { if (mark_type === state.schema.marks.pFontSize) { return ref_node.marks.some(m => m.type.name === state.schema.marks.pFontSize.name); } const mark = state.schema.mark(mark_type); return ref_node.marks.includes(mark); - return false; }); } - else { - return []; - } - } return activeMarks; } @@ -1488,20 +1011,21 @@ export class TooltipTextMenu { } -class TooltipTextMenuManager { +export class TooltipTextMenuManager { private static _instance: TooltipTextMenuManager; + private _isPinned: boolean = false; public pinnedX: number = 0; public pinnedY: number = 0; public unpinnedX: number = 0; public unpinnedY: number = 0; - private _isPinned: boolean = false; public _brushMarks: Set<Mark> | undefined; + public _brushMap: Map<string, Set<Mark>> = new Map(); public _brushIsEmpty: boolean = true; public color: String = "#000"; - public highlight: String = "transparent"; + public highlighter: String = "transparent"; public activeMenu: TooltipTextMenu | undefined; @@ -1512,11 +1036,7 @@ class TooltipTextMenuManager { return TooltipTextMenuManager._instance; } - public get isPinned() { - return this._isPinned; - } + public get isPinned() { return this._isPinned; } - public toggleIsPinned() { - this._isPinned = !this._isPinned; - } + public toggleIsPinned() { this._isPinned = !this._isPinned; } } diff --git a/src/client/views/AntimodeMenu.scss b/src/client/views/AntimodeMenu.scss index f3da5f284..d4a76ee17 100644 --- a/src/client/views/AntimodeMenu.scss +++ b/src/client/views/AntimodeMenu.scss @@ -5,13 +5,26 @@ background: #323232; box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25); border-radius: 0px 6px 6px 6px; - overflow: hidden; + // overflow: hidden; display: flex; + &.with-rows { + flex-direction: column + } + + .antimodeMenu-row { + display: flex; + height: 35px; + } + .antimodeMenu-button { background-color: transparent; width: 35px; height: 35px; + + &.active { + background-color: #121212; + } } .antimodeMenu-button:hover { diff --git a/src/client/views/AntimodeMenu.tsx b/src/client/views/AntimodeMenu.tsx index 408df8bc2..4625eb92f 100644 --- a/src/client/views/AntimodeMenu.tsx +++ b/src/client/views/AntimodeMenu.tsx @@ -18,9 +18,13 @@ export default abstract class AntimodeMenu extends React.Component { @observable protected _opacity: number = 1; @observable protected _transition: string = "opacity 0.5s"; @observable protected _transitionDelay: string = ""; + @observable protected _canFade: boolean = true; @observable public Pinned: boolean = false; + get width() { return this._mainCont.current ? this._mainCont.current.getBoundingClientRect().width : 0; } + get height() { return this._mainCont.current ? this._mainCont.current.getBoundingClientRect().height : 0; } + @action /** * @param x @@ -62,7 +66,7 @@ export default abstract class AntimodeMenu extends React.Component { @action protected pointerLeave = (e: React.PointerEvent) => { - if (!this.Pinned) { + if (!this.Pinned && this._canFade) { this._transition = "opacity 0.5s"; this._transitionDelay = "1s"; this._opacity = 0.2; @@ -88,8 +92,8 @@ export default abstract class AntimodeMenu extends React.Component { document.removeEventListener("pointerup", this.dragEnd); document.addEventListener("pointerup", this.dragEnd); - this._offsetX = this._mainCont.current!.getBoundingClientRect().width - e.nativeEvent.offsetX; - this._offsetY = e.nativeEvent.offsetY; + this._offsetX = e.pageX - this._mainCont.current!.getBoundingClientRect().left; + this._offsetY = e.pageY - this._mainCont.current!.getBoundingClientRect().top; e.stopPropagation(); e.preventDefault(); @@ -97,8 +101,14 @@ export default abstract class AntimodeMenu extends React.Component { @action protected dragging = (e: PointerEvent) => { - this._left = e.pageX - this._offsetX; - this._top = e.pageY - this._offsetY; + const width = this._mainCont.current!.getBoundingClientRect().width; + const height = this._mainCont.current!.getBoundingClientRect().height; + + const left = e.pageX - this._offsetX; + const top = e.pageY - this._offsetY; + + this._left = Math.min(Math.max(left, 0), window.innerWidth - width); + this._top = Math.min(Math.max(top, 0), window.innerHeight - height); e.stopPropagation(); e.preventDefault(); @@ -116,6 +126,10 @@ export default abstract class AntimodeMenu extends React.Component { e.preventDefault(); } + protected getDragger = () => { + return <div className="antimodeMenu-dragger" onPointerDown={this.dragStart} style={{ width: this.Pinned ? "20px" : "0px" }} />; + } + protected getElement(buttons: JSX.Element[]) { return ( <div className="antimodeMenu-cont" onPointerLeave={this.pointerLeave} onPointerEnter={this.pointerEntered} ref={this._mainCont} onContextMenu={this.handleContextMenu} @@ -125,4 +139,14 @@ export default abstract class AntimodeMenu extends React.Component { </div> ); } + + protected getElementWithRows(rows: JSX.Element[], numRows: number, hasDragger: boolean = true) { + return ( + <div className="antimodeMenu-cont with-rows" onPointerLeave={this.pointerLeave} onPointerEnter={this.pointerEntered} ref={this._mainCont} onContextMenu={this.handleContextMenu} + style={{ left: this._left, top: this._top, opacity: this._opacity, transition: this._transition, transitionDelay: this._transitionDelay, height: 35 * numRows + "px" }}> + {rows} + {hasDragger ? <div className="antimodeMenu-dragger" onPointerDown={this.dragStart} style={{ width: this.Pinned ? "20px" : "0px" }} /> : <></>} + </div> + ); + } }
\ No newline at end of file diff --git a/src/client/views/CollectionLinearView.tsx b/src/client/views/CollectionLinearView.tsx index 09e4ef99c..5ca861f71 100644 --- a/src/client/views/CollectionLinearView.tsx +++ b/src/client/views/CollectionLinearView.tsx @@ -39,7 +39,7 @@ export class CollectionLinearView extends CollectionSubView(LinearDocument) { protected createDropTarget = (ele: HTMLDivElement) => { //used for stacking and masonry view this._dropDisposer && this._dropDisposer(); if (ele) { - this._dropDisposer = DragManager.MakeDropTarget(ele, { handlers: { drop: this.drop.bind(this) } }); + this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this)); } } @@ -74,6 +74,7 @@ export class CollectionLinearView extends CollectionSubView(LinearDocument) { <DocumentView Document={pair.layout} DataDoc={pair.data} + LibraryPath={this.props.LibraryPath} addDocument={this.props.addDocument} moveDocument={this.props.moveDocument} addDocTab={this.props.addDocTab} diff --git a/src/client/views/CollectionMulticolumnView.scss b/src/client/views/CollectionMulticolumnView.scss new file mode 100644 index 000000000..84e80da4a --- /dev/null +++ b/src/client/views/CollectionMulticolumnView.scss @@ -0,0 +1,7 @@ +.collectionMulticolumnView_outer, +.collectionMulticolumnView_contents { + width: 100%; + height: 100%; + overflow: hidden; +} + diff --git a/src/client/views/CollectionMulticolumnView.tsx b/src/client/views/CollectionMulticolumnView.tsx new file mode 100644 index 000000000..3231c0da1 --- /dev/null +++ b/src/client/views/CollectionMulticolumnView.tsx @@ -0,0 +1,77 @@ +import { observer } from 'mobx-react'; +import { makeInterface, listSpec } from '../../new_fields/Schema'; +import { documentSchema } from '../../new_fields/documentSchemas'; +import { CollectionSubView } from './collections/CollectionSubView'; +import { DragManager } from '../util/DragManager'; +import * as React from "react"; +import { Doc, DocListCast } from '../../new_fields/Doc'; +import { NumCast, Cast, StrCast } from '../../new_fields/Types'; +import { List } from '../../new_fields/List'; +import { ContentFittingDocumentView } from './nodes/ContentFittingDocumentView'; +import { Utils } from '../../Utils'; +import { Transform } from '../util/Transform'; +import "./collectionMulticolumnView.scss"; + +type MulticolumnDocument = makeInterface<[typeof documentSchema]>; +const MulticolumnDocument = makeInterface(documentSchema); + +@observer +export default class CollectionMulticolumnView extends CollectionSubView(MulticolumnDocument) { + private _dropDisposer?: DragManager.DragDropDisposer; + private get configuration() { + const { Document } = this.props; + if (!Document.multicolumnData) { + Document.multicolumnData = new List<Doc>(); + } + return DocListCast(this.Document.multicolumnData); + } + + protected createDropTarget = (ele: HTMLDivElement) => { + this._dropDisposer && this._dropDisposer(); + if (ele) { + this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this)); + } + } + + getTransform = (ele: React.RefObject<HTMLDivElement>) => () => { + if (!ele.current) return Transform.Identity(); + const { scale, translateX, translateY } = Utils.GetScreenTransform(ele.current); + return new Transform(-translateX, -translateY, 1 / scale); + } + + public isCurrent(doc: Doc) { return !doc.isMinimized && (Math.abs(NumCast(doc.displayTimecode, -1) - NumCast(this.Document.currentTimecode, -1)) < 1.5 || NumCast(doc.displayTimecode, -1) === -1); } + + render() { + const { PanelWidth } = this.props; + return ( + <div className={"collectionMulticolumnView_outer"}> + <div className={"collectionMulticolumnView_contents"}> + {this.configuration.map(config => { + const { target, columnWidth } = config; + if (target instanceof Doc) { + let computedWidth: number = 0; + const widthSpecifier = Cast(columnWidth, "number"); + let matches: RegExpExecArray | null; + if (widthSpecifier !== undefined) { + computedWidth = widthSpecifier; + } else if ((matches = /([\d.]+)\%/.exec(StrCast(columnWidth))) !== null) { + computedWidth = Number(matches[1]) / 100 * PanelWidth(); + } + return (!computedWidth ? (null) : + <ContentFittingDocumentView + {...this.props} + Document={target} + DataDocument={undefined} + PanelWidth={() => computedWidth} + getTransform={this.props.ScreenToLocalTransform} + /> + ); + } + return (null); + })} + </div> + </div> + ); + } + +}
\ No newline at end of file diff --git a/src/client/views/DocComponent.tsx b/src/client/views/DocComponent.tsx index 27ee9f122..4dbf26956 100644 --- a/src/client/views/DocComponent.tsx +++ b/src/client/views/DocComponent.tsx @@ -47,6 +47,7 @@ interface DocAnnotatableProps { Document: Doc; DataDoc?: Doc; fieldKey: string; + active: () => boolean; whenActiveChanged: (isActive: boolean) => void; isSelected: (outsideReaction?: boolean) => boolean; renderDepth: number; @@ -57,8 +58,9 @@ export function DocAnnotatableComponent<P extends DocAnnotatableProps, T>(schema //TODO This might be pretty inefficient if doc isn't observed, because computed doesn't cache then @computed get Document(): T { return schemaCtor(this.props.Document); } @computed get layoutDoc() { return Doc.Layout(this.props.Document); } - @computed get dataDoc() { return (this.props.DataDoc && this.props.Document.isTemplateField ? this.props.DataDoc : Doc.GetProto(this.props.Document)) as Doc; } + @computed get dataDoc() { return (this.props.DataDoc && (this.props.Document.isTemplateField || this.props.Document.isTemplateDoc) ? this.props.DataDoc : Doc.GetProto(this.props.Document)) as Doc; } @computed get extensionDoc() { return Doc.fieldExtensionDoc(this.dataDoc, this.props.fieldKey); } + @computed get extensionDocSync() { return Doc.fieldExtensionDocSync(this.dataDoc, this.props.fieldKey); } @computed get annotationsKey() { return "annotations"; } @action.bound @@ -71,7 +73,7 @@ export function DocAnnotatableComponent<P extends DocAnnotatableProps, T>(schema // if the moved document is already in this overlay collection nothing needs to be done. // otherwise, if the document can be removed from where it was, it will then be added to this document's overlay collection. @action.bound - moveDocument(doc: Doc, targetCollection: Doc, addDocument: (doc: Doc) => boolean): boolean { + moveDocument(doc: Doc, targetCollection: Doc | undefined, addDocument: (doc: Doc) => boolean): boolean { return Doc.AreProtosEqual(this.props.Document, targetCollection) ? true : this.removeDocument(doc) ? addDocument(doc) : false; } @action.bound diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx index 7f125dd34..202bfe400 100644 --- a/src/client/views/DocumentButtonBar.tsx +++ b/src/client/views/DocumentButtonBar.tsx @@ -3,13 +3,12 @@ import { faArrowAltCircleDown, faArrowAltCircleUp, faCheckCircle, faCloudUploadA import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { action, observable, runInAction, computed } from "mobx"; import { observer } from "mobx-react"; -import { Doc } from "../../new_fields/Doc"; +import { Doc, DocListCast } from "../../new_fields/Doc"; import { RichTextField } from '../../new_fields/RichTextField'; -import { NumCast, StrCast } from "../../new_fields/Types"; +import { NumCast, StrCast, Cast } from "../../new_fields/Types"; import { emptyFunction } from "../../Utils"; import { Pulls, Pushes } from '../apis/google_docs/GoogleApiClientUtils'; import { DragManager } from "../util/DragManager"; -import { LinkManager } from '../util/LinkManager'; import { UndoManager } from "../util/UndoManager"; import './DocumentButtonBar.scss'; import './collections/ParentDocumentSelector.scss'; @@ -21,6 +20,7 @@ import React = require("react"); import { DocumentView } from './nodes/DocumentView'; import { ParentDocSelector } from './collections/ParentDocumentSelector'; import { CollectionDockingView } from './collections/CollectionDockingView'; +import { Id } from '../../new_fields/FieldSymbols'; const higflyout = require("@hig/flyout"); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; @@ -40,7 +40,7 @@ const cloud: IconProp = "cloud-upload-alt"; const fetch: IconProp = "sync-alt"; @observer -export class DocumentButtonBar extends React.Component<{ views: DocumentView[], stack?: any }, {}> { +export class DocumentButtonBar extends React.Component<{ views: (DocumentView | undefined)[], stack?: any }, {}> { private _linkButton = React.createRef<HTMLDivElement>(); private _downX = 0; private _downY = 0; @@ -60,7 +60,7 @@ export class DocumentButtonBar extends React.Component<{ views: DocumentView[], public static hasPushedHack = false; public static hasPulledHack = false; - constructor(props: { views: DocumentView[] }) { + constructor(props: { views: (DocumentView | undefined)[] }) { super(props); runInAction(() => DocumentButtonBar.Instance = this); } @@ -102,32 +102,28 @@ export class DocumentButtonBar extends React.Component<{ views: DocumentView[], this._pullColorAnimating = false; }); + get view0() { return this.props.views && this.props.views.length ? this.props.views[0] : undefined; } + @action onLinkButtonMoved = (e: PointerEvent): void => { if (this._linkButton.current !== null && (Math.abs(e.clientX - this._downX) > 3 || Math.abs(e.clientY - this._downY) > 3)) { document.removeEventListener("pointermove", this.onLinkButtonMoved); document.removeEventListener("pointerup", this.onLinkButtonUp); - const docView = this.props.views[0]; - const container = docView.props.ContainingCollectionDoc?.proto; - const dragData = new DragManager.LinkDragData(docView.props.Document, container ? [container] : []); const linkDrag = UndoManager.StartBatch("Drag Link"); - DragManager.StartLinkDrag(this._linkButton.current, dragData, e.pageX, e.pageY, { - handlers: { - dragComplete: () => { - const tooltipmenu = FormattedTextBox.ToolTipTextMenu; - const linkDoc = dragData.linkDocument; - if (linkDoc && tooltipmenu) { - const proto = Doc.GetProto(linkDoc); - if (proto && docView) { - proto.sourceContext = docView.props.ContainingCollectionDoc; - } - const text = tooltipmenu.makeLink(linkDoc, StrCast(linkDoc.anchor2.title), e.ctrlKey ? "onRight" : "inTab"); - if (linkDoc instanceof Doc && linkDoc.anchor2 instanceof Doc) { - proto.title = text === "" ? proto.title : text + " to " + linkDoc.anchor2.title; // TODODO open to more descriptive descriptions of following in text link - } + this.view0 && DragManager.StartLinkDrag(this._linkButton.current, this.view0.props.Document, e.pageX, e.pageY, { + dragComplete: dropEv => { + const linkDoc = dropEv.linkDragData?.linkDocument; // equivalent to !dropEve.aborted since linkDocument is only assigned on a completed drop + if (this.view0 && linkDoc && FormattedTextBox.ToolTipTextMenu) { + const proto = Doc.GetProto(linkDoc); + proto.sourceContext = this.view0.props.ContainingCollectionDoc; + + const anchor2Title = linkDoc.anchor2 instanceof Doc ? StrCast(linkDoc.anchor2.title) : "-untitled-"; + if (linkDoc.anchor2 instanceof Doc) { + const text = FormattedTextBox.ToolTipTextMenu.MakeLinkToSelection(linkDoc[Id], anchor2Title, e.ctrlKey ? "onRight" : "inTab", linkDoc.anchor2[Id]); + proto.title = text === "" ? proto.title : text + " to " + linkDoc.anchor2.title; // TODO open to more descriptive descriptions of following in text link } - linkDrag && linkDrag.end(); } + linkDrag?.end(); }, hideSource: false }); @@ -154,10 +150,10 @@ export class DocumentButtonBar extends React.Component<{ views: DocumentView[], @computed get considerGoogleDocsPush() { - const targetDoc = this.props.views[0].props.Document; - const published = Doc.GetProto(targetDoc)[GoogleRef] !== undefined; + const targetDoc = this.view0?.props.Document; + const published = targetDoc && Doc.GetProto(targetDoc)[GoogleRef] !== undefined; const animation = this.isAnimatingPulse ? "shadow-pulse 1s linear infinite" : "none"; - return <div + return !targetDoc ? (null) : <div title={`${published ? "Push" : "Publish"} to Google Docs`} className="documentButtonBar-linker" style={{ animation }} @@ -172,10 +168,10 @@ export class DocumentButtonBar extends React.Component<{ views: DocumentView[], @computed get considerGoogleDocsPull() { - const targetDoc = this.props.views[0].props.Document; - const dataDoc = Doc.GetProto(targetDoc); + const targetDoc = this.view0?.props.Document; + const dataDoc = targetDoc && Doc.GetProto(targetDoc); const animation = this.isAnimatingFetch ? "spin 0.5s linear infinite" : "none"; - return !dataDoc[GoogleRef] ? (null) : <div className="documentButtonBar-linker" + return !targetDoc || !dataDoc || !dataDoc[GoogleRef] ? (null) : <div className="documentButtonBar-linker" title={`${!dataDoc.unchanged ? "Pull from" : "Fetch"} Google Docs`} style={{ backgroundColor: this.pullColor }} onPointerEnter={e => e.altKey && runInAction(() => this.openHover = true)} @@ -200,10 +196,11 @@ export class DocumentButtonBar extends React.Component<{ views: DocumentView[], @computed get linkButton() { - const linkCount = LinkManager.Instance.getAllRelatedLinks(this.props.views[0].props.Document).length; - return <div title="Drag(create link) Tap(view links)" className="documentButtonBar-linkFlyout" ref={this._linkButton}> + const view0 = this.view0; + const linkCount = view0 && DocListCast(view0.props.Document.links).length; + return !view0 ? (null) : <div title="Drag(create link) Tap(view links)" className="documentButtonBar-linkFlyout" ref={this._linkButton}> <Flyout anchorPoint={anchorPoints.RIGHT_TOP} - content={<LinkMenu docView={this.props.views[0]} addDocTab={this.props.views[0].props.addDocTab} changeFlyout={emptyFunction} />}> + content={<LinkMenu docView={view0} addDocTab={view0.props.addDocTab} changeFlyout={emptyFunction} />}> <div className={"documentButtonBar-linkButton-" + (linkCount ? "nonempty" : "empty")} onPointerDown={this.onLinkButtonDown} > {linkCount ? linkCount : <FontAwesomeIcon className="documentdecorations-icon" icon="link" size="sm" />} </div> @@ -213,20 +210,21 @@ export class DocumentButtonBar extends React.Component<{ views: DocumentView[], @computed get contextButton() { - return <ParentDocSelector Views={this.props.views} Document={this.props.views[0].props.Document} addDocTab={(doc, data, where) => { + return !this.view0 ? (null) : <ParentDocSelector Views={this.props.views.filter(v => v).map(v => v as DocumentView)} Document={this.view0.props.Document} addDocTab={(doc, data, where) => { where === "onRight" ? CollectionDockingView.AddRightSplit(doc, data) : this.props.stack ? CollectionDockingView.Instance.AddTab(this.props.stack, doc, data) : - this.props.views[0].props.addDocTab(doc, data, "onRight"); + this.view0?.props.addDocTab(doc, data, "onRight"); return true; }} />; } render() { + if (!this.view0) return (null); const templates: Map<Template, boolean> = new Map(); Array.from(Object.values(Templates.TemplateList)).map(template => - templates.set(template, this.props.views.reduce((checked, doc) => checked || doc.getLayoutPropStr("show" + template.Name) ? true : false, false as boolean))); + templates.set(template, this.props.views.reduce((checked, doc) => checked || doc?.getLayoutPropStr("show" + template.Name) ? true : false, false as boolean))); - const isText = this.props.views[0].props.Document.data instanceof RichTextField; // bcz: Todo - can't assume layout is using the 'data' field. need to add fieldKey to DocumentView + const isText = this.view0.props.Document.data instanceof RichTextField; // bcz: Todo - can't assume layout is using the 'data' field. need to add fieldKey to DocumentView const considerPull = isText && this.considerGoogleDocsPull; const considerPush = isText && this.considerGoogleDocsPush; return <div className="documentButtonBar"> @@ -234,7 +232,7 @@ export class DocumentButtonBar extends React.Component<{ views: DocumentView[], {this.linkButton} </div> <div className="documentButtonBar-button"> - <TemplateMenu docs={this.props.views} templates={templates} /> + <TemplateMenu docs={this.props.views.filter(v => v).map(v => v as DocumentView)} templates={templates} /> </div> <div className="documentButtonBar-button" style={{ display: !considerPush ? "none" : "" }}> {this.considerGoogleDocsPush} diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 7b0c31e37..799b3695c 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -25,6 +25,9 @@ import { FieldView } from "./nodes/FieldView"; import { IconBox } from "./nodes/IconBox"; import React = require("react"); import { DocumentType } from '../documents/DocumentTypes'; +import { ScriptField } from '../../new_fields/ScriptField'; +import { render } from 'react-dom'; +import RichTextMenu from '../util/RichTextMenu'; const higflyout = require("@hig/flyout"); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; @@ -54,11 +57,11 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> private _iconDoc?: Doc = undefined; private _resizeUndo?: UndoManager.Batch; private _radiusDown = [0, 0]; + @observable private _accumulatedTitle = ""; @observable private _minimizedX = 0; @observable private _minimizedY = 0; - @observable private _title: string = ""; + @observable private _titleControlString: string = "#title"; @observable private _edtingTitle = false; - @observable private _fieldKey = "title"; @observable private _hidden = false; @observable private _opacity = 1; @observable private _removeIcon = false; @@ -73,20 +76,32 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> super(props); DocumentDecorations.Instance = this; this._keyinput = React.createRef(); - reaction(() => SelectionManager.SelectedDocuments().slice(), docs => this._edtingTitle = false); + reaction(() => SelectionManager.SelectedDocuments().slice(), docs => this.titleBlur(false)); } - @action titleChanged = (event: any) => { this._title = event.target.value; }; - @action titleBlur = () => { this._edtingTitle = false; }; + @action titleChanged = (event: any) => this._accumulatedTitle = event.target.value; + + titleBlur = undoBatch(action((commit: boolean) => { + this._edtingTitle = false; + if (commit) { + if (this._accumulatedTitle.startsWith("#") || this._accumulatedTitle.startsWith("=")) { + this._titleControlString = this._accumulatedTitle; + } else if (this._titleControlString.startsWith("#")) { + const selectionTitleFieldKey = this._titleControlString.substring(1); + selectionTitleFieldKey === "title" && (SelectionManager.SelectedDocuments()[0].props.Document.customTitle = !this._accumulatedTitle.startsWith("-")); + selectionTitleFieldKey && SelectionManager.SelectedDocuments().forEach(d => + Doc.SetInPlace(d.props.Document, selectionTitleFieldKey, typeof d.props.Document[selectionTitleFieldKey] === "number" ? +this._accumulatedTitle : this._accumulatedTitle, true) + ); + } + } + })); + @action titleEntered = (e: any) => { const key = e.keyCode || e.which; // enter pressed if (key === 13) { const text = e.target.value; - if (text[0] === '#') { - this._fieldKey = text.slice(1, text.length); - this._title = this.selectionTitle; - } else if (text.startsWith("::")) { + if (text.startsWith("::")) { const targetID = text.slice(2, text.length); const promoteDoc = SelectionManager.SelectedDocuments()[0]; DocUtils.Publish(promoteDoc.props.Document, targetID, promoteDoc.props.addDocument, promoteDoc.props.removeDocument); @@ -105,24 +120,7 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> fieldTemplate.title = metaKey; Doc.MakeMetadataFieldTemplate(fieldTemplate, Doc.GetProto(docTemplate)); if (text.startsWith(">>")) { - Doc.GetProto(docTemplate).layout = StrCast(fieldTemplateView.props.Document.layout).replace(/fieldKey={"[^"]*"}/, `fieldKey={"${metaKey}"}`); - } - } - } - else { - if (SelectionManager.SelectedDocuments().length > 0) { - SelectionManager.SelectedDocuments()[0].props.Document.customTitle = !this._title.startsWith("-"); - const field = SelectionManager.SelectedDocuments()[0].props.Document[this._fieldKey]; - if (typeof field === "number") { - SelectionManager.SelectedDocuments().forEach(d => { - const doc = d.props.Document.proto ? d.props.Document.proto : d.props.Document; - doc[this._fieldKey] = +this._title; - }); - } else { - SelectionManager.SelectedDocuments().forEach(d => { - const doc = d.props.Document.proto ? d.props.Document.proto : d.props.Document; - doc[this._fieldKey] = this._title; - }); + Doc.GetProto(docTemplate).layout = StrCast(fieldTemplateView.props.Document.layout).replace(/fieldKey={'[^']*'}/, `fieldKey={"${metaKey}"}`); } } } @@ -147,8 +145,9 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> } @action onTitleUp = (e: PointerEvent): void => { if (Math.abs(e.clientX - this._downX) < 4 || Math.abs(e.clientY - this._downY) < 4) { - this._title = this.selectionTitle; + !this._edtingTitle && (this._accumulatedTitle = this._titleControlString.startsWith("#") ? this.selectionTitle : this._titleControlString); this._edtingTitle = true; + setTimeout(() => this._keyinput.current!.focus(), 0); } document.removeEventListener("pointermove", this.onTitleMove); document.removeEventListener("pointerup", this.onTitleUp); @@ -202,7 +201,7 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> document.removeEventListener("pointermove", this.onTitleMove); document.removeEventListener("pointerup", this.onTitleUp); DragManager.StartDocumentDrag(SelectionManager.SelectedDocuments().map(documentView => documentView.ContentDiv!), dragData, e.x, e.y, { - handlers: { dragComplete: action(() => this._hidden = this.Interacting = false) }, + dragComplete: action(e => this._hidden = this.Interacting = false), hideSource: true }); e.stopPropagation(); @@ -236,11 +235,12 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> e.stopPropagation(); if (e.button === 0) { const recent = Cast(CurrentUserUtils.UserDocument.recentlyClosed, Doc) as Doc; - SelectionManager.SelectedDocuments().map(dv => { + const selected = SelectionManager.SelectedDocuments().slice(); + SelectionManager.DeselectAll(); + selected.map(dv => { recent && Doc.AddDocToList(recent, "data", dv.props.Document, undefined, true, true); dv.props.removeDocument && dv.props.removeDocument(dv.props.Document); }); - SelectionManager.DeselectAll(); document.removeEventListener("pointermove", this.onCloseMove); document.removeEventListener("pointerup", this.onCloseUp); } @@ -527,13 +527,13 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> get selectionTitle(): string { if (SelectionManager.SelectedDocuments().length === 1) { const selected = SelectionManager.SelectedDocuments()[0]; - const field = selected.props.Document[this._fieldKey]; - if (typeof field === "string") { - return field; + if (this._titleControlString.startsWith("=")) { + return ScriptField.MakeFunction(this._titleControlString.substring(1), { doc: Doc.name })!.script.run({ this: selected.props.Document }, console.log).result?.toString() || ""; } - else if (typeof field === "number") { - return field.toString(); + if (this._titleControlString.startsWith("#")) { + return selected.props.Document[this._titleControlString.substring(1)]?.toString() || "-unset-"; } + return this._accumulatedTitle; } else if (SelectionManager.SelectedDocuments().length > 1) { return "-multiple-"; } @@ -593,8 +593,10 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> }}> {minimizeIcon} + {/* <RichTextMenu /> */} + {this._edtingTitle ? - <input ref={this._keyinput} className="title" type="text" name="dynbox" value={this._title} onBlur={this.titleBlur} onChange={this.titleChanged} onKeyPress={this.titleEntered} /> : + <input ref={this._keyinput} className="title" type="text" name="dynbox" value={this._accumulatedTitle} onBlur={e => this.titleBlur(true)} onChange={this.titleChanged} onKeyPress={this.titleEntered} /> : <div className="title" onPointerDown={this.onTitleDown} ><span>{`${this.selectionTitle}`}</span></div>} <div className="documentDecorations-closeButton" title="Close Document" onPointerDown={this.onCloseDown}> <FontAwesomeIcon className="documentdecorations-times" icon={faTimes} size="lg" /> diff --git a/src/client/views/EditableView.tsx b/src/client/views/EditableView.tsx index f78b61892..faf02b946 100644 --- a/src/client/views/EditableView.tsx +++ b/src/client/views/EditableView.tsx @@ -10,7 +10,7 @@ export interface EditableProps { /** * Called to get the initial value for editing * */ - GetValue(): string; + GetValue(): string | undefined; /** * Called to apply changes @@ -36,7 +36,7 @@ export interface EditableProps { resetValue: () => void; value: string, onChange: (e: React.ChangeEvent, { newValue }: { newValue: string }) => void, - autosuggestProps: Autosuggest.AutosuggestProps<string> + autosuggestProps: Autosuggest.AutosuggestProps<string, any> }; oneLine?: boolean; @@ -108,8 +108,8 @@ export class EditableView extends React.Component<EditableProps> { @action private finalizeEdit(value: string, shiftDown: boolean) { + this._editing = false; if (this.props.SetValue(value, shiftDown)) { - this._editing = false; this.props.isEditingCallback && this.props.isEditingCallback(false); } } @@ -120,11 +120,13 @@ export class EditableView extends React.Component<EditableProps> { @action setIsFocused = (value: boolean) => { + const wasFocused = this._editing; this._editing = value; + return wasFocused !== this._editing; } render() { - if (this._editing) { + if (this._editing && this.props.GetValue() !== undefined) { return this.props.autosuggestProps ? <Autosuggest {...this.props.autosuggestProps.autosuggestProps} diff --git a/src/client/views/GlobalKeyHandler.ts b/src/client/views/GlobalKeyHandler.ts index edc7df12a..979687ffb 100644 --- a/src/client/views/GlobalKeyHandler.ts +++ b/src/client/views/GlobalKeyHandler.ts @@ -12,6 +12,7 @@ import { Cast, PromiseValue } from "../../new_fields/Types"; import { ScriptField } from "../../new_fields/ScriptField"; import { InkingControl } from "./InkingControl"; import { InkTool } from "../../new_fields/InkField"; +import { DocumentView } from "./nodes/DocumentView"; const modifiers = ["control", "meta", "shift", "alt"]; type KeyHandler = (keycode: string, e: KeyboardEvent) => KeyControlInfo | Promise<KeyControlInfo>; @@ -108,7 +109,7 @@ export default class KeyManager { let preventDefault = false; switch (keyname) { - case " ": + case "~": DictationManager.Controls.listen({ useOverlay: true, tryExecute: true }); stopPropagation = true; preventDefault = true; @@ -125,6 +126,13 @@ export default class KeyManager { const preventDefault = true; switch (keyname) { + case "f": + const dv = SelectionManager.SelectedDocuments()?.[0]; + if (dv) { + const ex = dv.props.ScreenToLocalTransform().inverse().transformPoint(0, 0)[0]; + const ey = dv.props.ScreenToLocalTransform().inverse().transformPoint(0, 0)[1]; + DocumentView.FloatDoc(dv, ex, ey); + } // case "n": // let toggle = MainView.Instance.addMenuToggle.current!; // toggle.checked = !toggle.checked; diff --git a/src/client/views/InkingStroke.tsx b/src/client/views/InkingStroke.tsx index 7cee84fc5..a413eebc9 100644 --- a/src/client/views/InkingStroke.tsx +++ b/src/client/views/InkingStroke.tsx @@ -13,8 +13,8 @@ import React = require("react"); type InkDocument = makeInterface<[typeof documentSchema]>; const InkDocument = makeInterface(documentSchema); -export function CreatePolyline(points: { x: number, y: number }[], left: number, top: number, color?: string, width?: number) { - const pts = points.reduce((acc: string, pt: { x: number, y: number }) => acc + `${pt.x - left},${pt.y - top} `, ""); +export function CreatePolyline(points: { X: number, Y: number }[], left: number, top: number, color?: string, width?: number) { + const pts = points.reduce((acc: string, pt: { X: number, Y: number }) => acc + `${pt.X - left},${pt.Y - top} `, ""); return ( <polyline points={pts} @@ -36,8 +36,8 @@ export class InkingStroke extends DocExtendableComponent<FieldViewProps, InkDocu render() { const data: InkData = Cast(this.Document.data, InkField)?.inkData ?? []; - const xs = data.map(p => p.x); - const ys = data.map(p => p.y); + const xs = data.map(p => p.X); + const ys = data.map(p => p.Y); const left = Math.min(...xs); const top = Math.min(...ys); const right = Math.max(...xs); @@ -53,7 +53,7 @@ export class InkingStroke extends DocExtendableComponent<FieldViewProps, InkDocu transform: `translate(${left}px, ${top}px) scale(${scaleX}, ${scaleY})`, mixBlendMode: this.Document.tool === InkTool.Highlighter ? "multiply" : "unset", pointerEvents: "all" - }} onTouchStart={this.onTouchStart}> + }}> {points} </svg> ); diff --git a/src/client/views/Main.tsx b/src/client/views/Main.tsx index 9e699978f..b21eb9c8f 100644 --- a/src/client/views/Main.tsx +++ b/src/client/views/Main.tsx @@ -5,12 +5,10 @@ import * as ReactDOM from 'react-dom'; import * as React from 'react'; import { DocServer } from "../DocServer"; import { AssignAllExtensions } from "../../extensions/General/Extensions"; -import { ClientDiagnostics } from "../util/ClientDiagnostics"; AssignAllExtensions(); (async () => { - await ClientDiagnostics.start(); const info = await CurrentUserUtils.loadCurrentUser(); DocServer.init(window.location.protocol, window.location.hostname, 4321, info.email); await Docs.Prototypes.initialize(); diff --git a/src/client/views/MainView.scss b/src/client/views/MainView.scss index 87820abff..ab0a8e49b 100644 --- a/src/client/views/MainView.scss +++ b/src/client/views/MainView.scss @@ -5,13 +5,28 @@ .mainView-tabButtons { position: relative; width: 100%; + .documentView-node-topmost { + height: 200% !important; + } +} + +.mainContent-div { + position: relative; + width:100%; + height:100%; } +.mainView-contentArea { + .documentView-node-topmost { + height: 200% !important; + } +} // add nodes menu. Note that the + button is actually an input label, not an actual button. .mainView-docButtons { position: absolute; bottom: 20px; - left: 250px; + left: calc(100% + 5px); + z-index: 1; } #mainView-container { @@ -27,13 +42,13 @@ width: 100%; height: 100%; position: absolute; + display: flex; } .mainView-flyoutContainer { display: flex; flex-direction: column; - position: absolute; - width: 100%; + position: relative; height: 100%; .documentView-node-topmost { @@ -64,8 +79,8 @@ .mainView-logout { position: absolute; - right: 0; - bottom: 0; + right: 5; + bottom: 5; font-size: 8px; } @@ -75,9 +90,11 @@ .mainView-libraryFlyout { height: 100%; + width:100%; position: absolute; display: flex; flex-direction: column; + z-index: 2; } .mainView-expandFlyoutButton { @@ -89,13 +106,15 @@ .mainView-libraryHandle { width: 20px; + left: calc(100% - 10px); height: 40px; top: 50%; border: 1px solid black; border-radius: 5px; position: absolute; - z-index: 1; + z-index: 2; touch-action: none; + cursor: ew-resize; } .mainView-workspace { diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index b0b21ac2d..05bfee95b 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -15,7 +15,7 @@ import { List } from '../../new_fields/List'; import { listSpec } from '../../new_fields/Schema'; import { Cast, FieldValue, StrCast } from '../../new_fields/Types'; import { CurrentUserUtils } from '../../server/authentication/models/current_user_utils'; -import { emptyFunction, returnEmptyString, returnFalse, returnOne, returnTrue, Utils } from '../../Utils'; +import { emptyFunction, returnEmptyString, returnFalse, returnOne, returnTrue, Utils, emptyPath } from '../../Utils'; import GoogleAuthenticationManager from '../apis/GoogleAuthenticationManager'; import { DocServer } from '../DocServer'; import { Docs, DocumentOptions } from '../documents/Documents'; @@ -39,7 +39,12 @@ import MarqueeOptionsMenu from './collections/collectionFreeForm/MarqueeOptionsM import InkSelectDecorations from './InkSelectDecorations'; import { Scripting } from '../util/Scripting'; import { AudioBox } from './nodes/AudioBox'; +<<<<<<< HEAD import SettingsManager from '../util/SettingsManager'; +======= +import { TraceMobx } from '../../new_fields/util'; +import RichTextMenu from '../util/RichTextMenu'; +>>>>>>> e410cde0e430553002d4e1a2f64364b57b65fdbc @observer export class MainView extends React.Component { @@ -57,6 +62,7 @@ export class MainView extends React.Component { @computed private get userDoc() { return CurrentUserUtils.UserDocument; } @computed private get mainContainer() { return this.userDoc ? FieldValue(Cast(this.userDoc.activeWorkspace, Doc)) : CurrentUserUtils.GuestWorkspace; } @computed public get mainFreeform(): Opt<Doc> { return (docs => (docs && docs.length > 1) ? docs[1] : undefined)(DocListCast(this.mainContainer!.data)); } + @computed public get sidebarButtonsDoc() { return Cast(CurrentUserUtils.UserDocument.sidebarButtons, Doc) as Doc; } public isPointerDown = false; @@ -199,15 +205,14 @@ export class MainView extends React.Component { }; const freeformDoc = CurrentUserUtils.GuestTarget || Docs.Create.FreeformDocument([], freeformOptions); Doc.AddDocToList(Doc.GetProto(CurrentUserUtils.UserDocument.documents as Doc), "data", freeformDoc); - const dockingLayout = { content: [{ type: 'row', content: [CollectionDockingView.makeDocumentConfig(freeformDoc, freeformDoc, 600)] }] }; - const mainDoc = Docs.Create.DockDocument([freeformDoc], JSON.stringify(dockingLayout), { title: `Workspace ${workspaceCount}` }, id); + const mainDoc = Docs.Create.StandardCollectionDockingDocument([{ doc: freeformDoc, initialWidth: 600, path: [Doc.UserDoc().documents as Doc] }], { title: `Workspace ${workspaceCount}` }, id, "row"); Doc.AddDocToList(workspaces, "data", mainDoc); // bcz: strangely, we need a timeout to prevent exceptions/issues initializing GoldenLayout (the rendering engine for Main Container) setTimeout(() => this.openWorkspace(mainDoc), 0); } @action - openWorkspace = async (doc: Doc, fromHistory = false) => { + openWorkspace = (doc: Doc, fromHistory = false) => { CurrentUserUtils.MainDocId = doc[Id]; if (doc) { // this has the side-effect of setting the main container since we're assigning the active/guest workspace @@ -262,37 +267,40 @@ export class MainView extends React.Component { getPHeight = () => this._panelHeight; getContentsHeight = () => this._panelHeight - this._buttonBarHeight; + @computed get mainDocView() { + return <DocumentView Document={this.mainContainer!} + DataDoc={undefined} + LibraryPath={emptyPath} + addDocument={undefined} + addDocTab={this.addDocTabFunc} + pinToPres={emptyFunction} + onClick={undefined} + ruleProvider={undefined} + removeDocument={undefined} + ScreenToLocalTransform={Transform.Identity} + ContentScaling={returnOne} + PanelWidth={this.getPWidth} + PanelHeight={this.getPHeight} + renderDepth={0} + backgroundColor={returnEmptyString} + focus={emptyFunction} + parentActive={returnTrue} + whenActiveChanged={emptyFunction} + bringToFront={emptyFunction} + ContainingCollectionView={undefined} + ContainingCollectionDoc={undefined} + zoomToScale={emptyFunction} + getScale={returnOne} + />; + } @computed get dockingContent() { + TraceMobx(); const mainContainer = this.mainContainer; - const flyoutWidth = this.flyoutWidth; // bcz: need to be here because Measure messes with observables. - const flyoutTranslate = this._flyoutTranslate; + const width = this.flyoutWidth; return <Measure offset onResize={this.onResize}> {({ measureRef }) => - <div ref={measureRef} id="mainContent-div" style={{ width: `calc(100% - ${flyoutTranslate ? flyoutWidth : 0}px`, transform: `translate(${flyoutTranslate ? flyoutWidth : 0}px, 0px)` }} onDrop={this.onDrop}> - {!mainContainer ? (null) : - <DocumentView Document={mainContainer} - DataDoc={undefined} - addDocument={undefined} - addDocTab={this.addDocTabFunc} - pinToPres={emptyFunction} - onClick={undefined} - ruleProvider={undefined} - removeDocument={undefined} - ScreenToLocalTransform={Transform.Identity} - ContentScaling={returnOne} - PanelWidth={this.getPWidth} - PanelHeight={this.getPHeight} - renderDepth={0} - backgroundColor={returnEmptyString} - focus={emptyFunction} - parentActive={returnTrue} - whenActiveChanged={emptyFunction} - bringToFront={emptyFunction} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} - zoomToScale={emptyFunction} - getScale={returnOne} - />} + <div ref={measureRef} className="mainContent-div" onDrop={this.onDrop} style={{ width: `calc(100% - ${width}px)` }}> + {!mainContainer ? (null) : this.mainDocView} </div> } </Measure>; @@ -312,10 +320,11 @@ export class MainView extends React.Component { @action pointerOverDragger = () => { - if (this.flyoutWidth === 0) { - this.flyoutWidth = 250; - this._flyoutTranslate = false; - } + // if (this.flyoutWidth === 0) { + // this.flyoutWidth = 250; + // this.sidebarButtonsDoc.columnWidth = this.flyoutWidth / 3 - 30; + // this._flyoutTranslate = false; + // } } @action @@ -329,26 +338,22 @@ export class MainView extends React.Component { @action onPointerMove = (e: PointerEvent) => { this.flyoutWidth = Math.max(e.clientX, 0); + this.sidebarButtonsDoc.columnWidth = this.flyoutWidth / 3 - 30; } @action onPointerUp = (e: PointerEvent) => { if (Math.abs(e.clientX - this._flyoutSizeOnDown) < 4) { - this.flyoutWidth = this.flyoutWidth < 5 ? 250 : 0; + this.flyoutWidth = this.flyoutWidth < 15 ? 250 : 0; + this.flyoutWidth && (this.sidebarButtonsDoc.columnWidth = this.flyoutWidth / 3 - 30); } document.removeEventListener("pointermove", this.onPointerMove); document.removeEventListener("pointerup", this.onPointerUp); } flyoutWidthFunc = () => this.flyoutWidth; - addDocTabFunc = (doc: Doc, data: Opt<Doc>, where: string) => { - if (where === "close") { - return CollectionDockingView.CloseRightSplit(doc); - } - if (doc.dockingConfig) { - this.openWorkspace(doc); - return true; - } else { - return CollectionDockingView.AddRightSplit(doc, undefined); - } + addDocTabFunc = (doc: Doc, data: Opt<Doc>, where: string, libraryPath?: Doc[]): boolean => { + return where === "close" ? CollectionDockingView.CloseRightSplit(doc) : + doc.dockingConfig ? this.openWorkspace(doc) : + CollectionDockingView.AddRightSplit(doc, undefined, libraryPath); } mainContainerXf = () => new Transform(0, -this._buttonBarHeight, 1); @@ -358,12 +363,12 @@ export class MainView extends React.Component { return (null); } const sidebarButtonsDoc = Cast(CurrentUserUtils.UserDocument.sidebarButtons, Doc) as Doc; - sidebarButtonsDoc.columnWidth = this.flyoutWidth / 3 - 30; return <div className="mainView-flyoutContainer" > <div className="mainView-tabButtons" style={{ height: `${this._buttonBarHeight}px` }}> <DocumentView Document={sidebarButtonsDoc} DataDoc={undefined} + LibraryPath={emptyPath} addDocument={undefined} addDocTab={this.addDocTabFunc} pinToPres={emptyFunction} @@ -386,10 +391,11 @@ export class MainView extends React.Component { getScale={returnOne}> </DocumentView> </div> - <div style={{ position: "relative", height: `calc(100% - ${this._buttonBarHeight}px)`, width: "100%", overflow: "auto" }}> + <div className="mainView-contentArea" style={{ position: "relative", height: `calc(100% - ${this._buttonBarHeight}px)`, width: "100%", overflow: "visible" }}> <DocumentView Document={sidebarContent} DataDoc={undefined} + LibraryPath={emptyPath} addDocument={undefined} addDocTab={this.addDocTabFunc} pinToPres={emptyFunction} @@ -418,6 +424,7 @@ export class MainView extends React.Component { {CurrentUserUtils.GuestWorkspace ? "Exit" : "Log Out"} </button> </div> + {this.docButtons} </div>; } @@ -425,23 +432,20 @@ export class MainView extends React.Component { const sidebar = this.userDoc && this.userDoc.sidebarContainer; return !this.userDoc || !(sidebar instanceof Doc) ? (null) : ( <div className="mainView-mainContent" > - <div className="mainView-flyoutContainer" onPointerLeave={this.pointerLeaveDragger}> - <div className="mainView-libraryHandle" - style={{ cursor: "ew-resize", left: `${(this.flyoutWidth * (this._flyoutTranslate ? 1 : 0)) - 10}px`, backgroundColor: `${StrCast(sidebar.backgroundColor, "lightGray")}` }} - onPointerDown={this.onPointerDown} onPointerOver={this.pointerOverDragger}> + <div className="mainView-flyoutContainer" onPointerLeave={this.pointerLeaveDragger} style={{ width: this.flyoutWidth }}> + <div className="mainView-libraryHandle" onPointerDown={this.onPointerDown} onPointerOver={this.pointerOverDragger} + style={{ backgroundColor: `${StrCast(sidebar.backgroundColor, "lightGray")}` }} > <span title="library View Dragger" style={{ width: (this.flyoutWidth !== 0 && this._flyoutTranslate) ? "100%" : "3vw", - height: (this.flyoutWidth !== 0 && this._flyoutTranslate) ? "100%" : "100vh", + //height: (this.flyoutWidth !== 0 && this._flyoutTranslate) ? "100%" : "100vh", position: (this.flyoutWidth !== 0 && this._flyoutTranslate) ? "absolute" : "fixed", top: (this.flyoutWidth !== 0 && this._flyoutTranslate) ? "" : "0" }} /> </div> <div className="mainView-libraryFlyout" style={{ - width: `${this.flyoutWidth}px`, - zIndex: 1, - transformOrigin: this._flyoutTranslate ? "" : "left center", + //transformOrigin: this._flyoutTranslate ? "" : "left center", transition: this._flyoutTranslate ? "" : "width .5s", - transform: `scale(${this._flyoutTranslate ? 1 : 0.8})`, + //transform: `scale(${this._flyoutTranslate ? 1 : 0.8})`, boxShadow: this._flyoutTranslate ? "" : "rgb(156, 147, 150) 0.2vw 0.2vw 0.8vw" }}> {this.flyout} @@ -454,7 +458,8 @@ export class MainView extends React.Component { public static expandFlyout = action(() => { MainView.Instance._flyoutTranslate = true; - MainView.Instance.flyoutWidth = 250; + MainView.Instance.flyoutWidth = (MainView.Instance.flyoutWidth || 250); + MainView.Instance.sidebarButtonsDoc.columnWidth = MainView.Instance.flyoutWidth / 3 - 30; }); @computed get expandButton() { @@ -463,7 +468,7 @@ export class MainView extends React.Component { addButtonDoc = (doc: Doc) => Doc.AddDocToList(CurrentUserUtils.UserDocument.expandingButtons as Doc, "data", doc); remButtonDoc = (doc: Doc) => Doc.RemoveDocFromList(CurrentUserUtils.UserDocument.expandingButtons as Doc, "data", doc); - moveButtonDoc = (doc: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean) => this.remButtonDoc(doc) && addDocument(doc); + moveButtonDoc = (doc: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean) => this.remButtonDoc(doc) && addDocument(doc); buttonBarXf = () => { if (!this._docBtnRef.current) return Transform.Identity(); @@ -473,11 +478,12 @@ export class MainView extends React.Component { @computed get docButtons() { if (CurrentUserUtils.UserDocument?.expandingButtons instanceof Doc) { return <div className="mainView-docButtons" ref={this._docBtnRef} - style={{ left: (this._flyoutTranslate ? this.flyoutWidth : 0) + 20, height: !CurrentUserUtils.UserDocument.expandingButtons.isExpanded ? "42px" : undefined }} > + style={{ height: !CurrentUserUtils.UserDocument.expandingButtons.isExpanded ? "42px" : undefined }} > <MainViewNotifs /> <CollectionLinearView Document={CurrentUserUtils.UserDocument.expandingButtons} DataDoc={undefined} + LibraryPath={emptyPath} fieldKey={"data"} annotationsKey={""} select={emptyFunction} @@ -517,9 +523,9 @@ export class MainView extends React.Component { {this.mainContent} <PreviewCursor /> <ContextMenu /> - {this.docButtons} <PDFMenu /> <MarqueeOptionsMenu /> + <RichTextMenu /> <OverlayView /> </div >); } diff --git a/src/client/views/MetadataEntryMenu.tsx b/src/client/views/MetadataEntryMenu.tsx index 8859c14cb..243cdb8f6 100644 --- a/src/client/views/MetadataEntryMenu.tsx +++ b/src/client/views/MetadataEntryMenu.tsx @@ -6,7 +6,7 @@ import { KeyValueBox } from './nodes/KeyValueBox'; import { Doc, Field, DocListCastAsync } from '../../new_fields/Doc'; import * as Autosuggest from 'react-autosuggest'; import { undoBatch } from '../util/UndoManager'; -import { emptyFunction } from '../../Utils'; +import { emptyFunction, emptyPath } from '../../Utils'; export type DocLike = Doc | Doc[] | Promise<Doc> | Promise<Doc[]>; export interface MetadataEntryProps { @@ -194,6 +194,7 @@ export class MetadataEntryMenu extends React.Component<MetadataEntryProps>{ ); } + _ref = React.createRef<HTMLInputElement>(); render() { return ( <div className="metadataEntry-outerDiv"> @@ -201,14 +202,14 @@ export class MetadataEntryMenu extends React.Component<MetadataEntryProps>{ Key: <Autosuggest inputProps={{ value: this._currentKey, onChange: this.onKeyChange }} getSuggestionValue={this.getSuggestionValue} - suggestions={[]} + suggestions={emptyPath} alwaysRenderSuggestions={false} renderSuggestion={this.renderSuggestion} onSuggestionsFetchRequested={emptyFunction} onSuggestionsClearRequested={emptyFunction} ref={this.autosuggestRef} /> Value: - <input className="metadataEntry-input" value={this._currentValue} onChange={this.onValueChange} onKeyDown={this.onValueKeyDown} /> + <input className="metadataEntry-input" ref={this._ref} value={this._currentValue} onClick={e => this._ref.current!.focus()} onChange={this.onValueChange} onKeyDown={this.onValueKeyDown} /> {this.considerChildOptions} </div> <div className="metadataEntry-keys" > diff --git a/src/client/views/OverlayView.tsx b/src/client/views/OverlayView.tsx index 32ada293c..350a75d29 100644 --- a/src/client/views/OverlayView.tsx +++ b/src/client/views/OverlayView.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { observer } from "mobx-react"; import { observable, action, trace, computed } from "mobx"; -import { Utils, emptyFunction, returnOne, returnTrue, returnEmptyString, returnZero, returnFalse } from "../../Utils"; +import { Utils, emptyFunction, returnOne, returnTrue, returnEmptyString, returnZero, returnFalse, emptyPath } from "../../Utils"; import './OverlayView.scss'; import { CurrentUserUtils } from "../../server/authentication/models/current_user_utils"; @@ -9,8 +9,6 @@ import { DocListCast, Doc } from "../../new_fields/Doc"; import { Id } from "../../new_fields/FieldSymbols"; import { DocumentView } from "./nodes/DocumentView"; import { Transform } from "../util/Transform"; -import { CollectionFreeFormDocumentView } from "./nodes/CollectionFreeFormDocumentView"; -import { DocumentContentsView } from "./nodes/DocumentContentsView"; import { NumCast } from "../../new_fields/Types"; import { CollectionFreeFormLinksView } from "./collections/collectionFreeForm/CollectionFreeFormLinksView"; @@ -174,6 +172,7 @@ export class OverlayView extends React.Component { return <div className="overlayView-doc" key={d[Id]} onPointerDown={onPointerDown} style={{ transform: `translate(${d.x}px, ${d.y}px)`, display: d.isMinimized ? "none" : "" }}> <DocumentView Document={d} + LibraryPath={emptyPath} ChromeHeight={returnZero} // isSelected={returnFalse} // select={emptyFunction} diff --git a/src/client/views/PreviewCursor.tsx b/src/client/views/PreviewCursor.tsx index fd0287b6c..9706d0f99 100644 --- a/src/client/views/PreviewCursor.tsx +++ b/src/client/views/PreviewCursor.tsx @@ -1,13 +1,11 @@ -import { action, observable, runInAction, trace } from 'mobx'; +import { action, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import "normalize.css"; import * as React from 'react'; import "./PreviewCursor.scss"; import { Docs } from '../documents/Documents'; -// import { Transform } from 'prosemirror-transform'; import { Doc } from '../../new_fields/Doc'; import { Transform } from "../util/Transform"; -import { TraceMobx } from '../../new_fields/util'; @observer export class PreviewCursor extends React.Component<{}> { @@ -24,64 +22,53 @@ export class PreviewCursor extends React.Component<{}> { } paste = (e: ClipboardEvent) => { - if (PreviewCursor.Visible) { - if (e.clipboardData) { - const newPoint = PreviewCursor._getTransform().transformPoint(PreviewCursor._clickPoint[0], PreviewCursor._clickPoint[1]); - runInAction(() => { PreviewCursor.Visible = false; }); + if (PreviewCursor.Visible && e.clipboardData) { + const newPoint = PreviewCursor._getTransform().transformPoint(PreviewCursor._clickPoint[0], PreviewCursor._clickPoint[1]); + runInAction(() => PreviewCursor.Visible = false); - - if (e.clipboardData.getData("text/plain") !== "") { - - // tests for youtube and makes video document - if (e.clipboardData.getData("text/plain").indexOf("www.youtube.com/watch") !== -1) { - const url = e.clipboardData.getData("text/plain").replace("youtube.com/watch?v=", "youtube.com/embed/"); - PreviewCursor._addDocument(Docs.Create.VideoDocument(url, { - title: url, width: 400, height: 315, - nativeWidth: 600, nativeHeight: 472.5, - x: newPoint[0], y: newPoint[1] - })); - return; - } - - // tests for URL and makes web document - const re: any = /^https?:\/\//g; - if (re.test(e.clipboardData.getData("text/plain"))) { - const url = e.clipboardData.getData("text/plain"); - PreviewCursor._addDocument(Docs.Create.WebDocument(url, { - title: url, width: 300, height: 300, - // nativeWidth: 300, nativeHeight: 472.5, - x: newPoint[0], y: newPoint[1] - })); - return; - } - - // creates text document - const newBox = Docs.Create.TextDocument({ - width: 200, height: 100, - x: newPoint[0], - y: newPoint[1], - title: "-pasted text-" - }); - - newBox.proto!.autoHeight = true; - PreviewCursor._addLiveTextDoc(newBox); - return; + if (e.clipboardData.getData("text/plain") !== "") { + // tests for youtube and makes video document + if (e.clipboardData.getData("text/plain").indexOf("www.youtube.com/watch") !== -1) { + const url = e.clipboardData.getData("text/plain").replace("youtube.com/watch?v=", "youtube.com/embed/"); + return PreviewCursor._addDocument(Docs.Create.VideoDocument(url, { + title: url, width: 400, height: 315, + nativeWidth: 600, nativeHeight: 472.5, + x: newPoint[0], y: newPoint[1] + })); } - //pasting in images - if (e.clipboardData.getData("text/html") !== "" && e.clipboardData.getData("text/html").includes("<img src=")) { - const re: any = /<img src="(.*?)"/g; - const arr: any[] = re.exec(e.clipboardData.getData("text/html")); - const img: Doc = Docs.Create.ImageDocument( - arr[1], { - width: 300, title: arr[1], - x: newPoint[0], - y: newPoint[1], - }); - PreviewCursor._addDocument(img); - return; + // tests for URL and makes web document + const re: any = /^https?:\/\//g; + if (re.test(e.clipboardData.getData("text/plain"))) { + const url = e.clipboardData.getData("text/plain"); + return PreviewCursor._addDocument(Docs.Create.WebDocument(url, { + title: url, width: 500, height: 300, + // nativeWidth: 300, nativeHeight: 472.5, + x: newPoint[0], y: newPoint[1] + })); } + // creates text document + return PreviewCursor._addLiveTextDoc(Docs.Create.TextDocument({ + width: 500, + limitHeight: 400, + autoHeight: true, + x: newPoint[0], + y: newPoint[1], + title: "-pasted text-" + })); + } + //pasting in images + if (e.clipboardData.getData("text/html") !== "" && e.clipboardData.getData("text/html").includes("<img src=")) { + const re: any = /<img src="(.*?)"/g; + const arr: any[] = re.exec(e.clipboardData.getData("text/html")); + + return PreviewCursor._addDocument(Docs.Create.ImageDocument( + arr[1], { + width: 300, title: arr[1], + x: newPoint[0], + y: newPoint[1], + })); } } } diff --git a/src/client/views/ScriptBox.tsx b/src/client/views/ScriptBox.tsx index 65d96f364..d24256886 100644 --- a/src/client/views/ScriptBox.tsx +++ b/src/client/views/ScriptBox.tsx @@ -82,28 +82,19 @@ export class ScriptBox extends React.Component<ScriptBoxProps> { ); } //let l = docList(this.source[0].data).length; if (l) { let ind = this.target[0].index !== undefined ? (this.target[0].index+1) % l : 0; this.target[0].index = ind; this.target[0].proto = getProto(docList(this.source[0].data)[ind]);} - public static EditButtonScript(title: string, doc: Doc, fieldKey: string, clientX: number, clientY: number, prewrapper?: string, postwrapper?: string) { + public static EditButtonScript(title: string, doc: Doc, fieldKey: string, clientX: number, clientY: number, contextParams?: { [name: string]: string }) { let overlayDisposer: () => void = emptyFunction; const script = ScriptCast(doc[fieldKey]); let originalText: string | undefined = undefined; if (script) { originalText = script.script.originalScript; - if (prewrapper && originalText.startsWith(prewrapper)) { - originalText = originalText.substr(prewrapper.length); - } - if (postwrapper && originalText.endsWith(postwrapper)) { - originalText = originalText.substr(0, originalText.length - postwrapper.length); - } } // tslint:disable-next-line: no-unnecessary-callback-wrapper const params: string[] = []; const setParams = (p: string[]) => params.splice(0, params.length, ...p); const scriptingBox = <ScriptBox initialText={originalText} setParams={setParams} onCancel={overlayDisposer} onSave={(text, onError) => { - if (prewrapper) { - text = prewrapper + text + (postwrapper ? postwrapper : ""); - } const script = CompileScript(text, { - params: { this: Doc.name }, + params: { this: Doc.name, ...contextParams }, typecheck: false, editable: true, transformer: DocumentIconContainer.getTransformer() @@ -113,7 +104,15 @@ export class ScriptBox extends React.Component<ScriptBoxProps> { return; } - params.length && DragManager.StartButtonDrag([], text, "a script", {}, params, (button: Doc) => { }, clientX, clientY); + const div = document.createElement("div"); + div.style.width = "90"; + div.style.height = "20"; + div.style.background = "gray"; + div.style.position = "absolute"; + div.style.display = "inline-block"; + div.style.transform = `translate(${clientX}px, ${clientY}px)`; + div.innerHTML = "button"; + params.length && DragManager.StartButtonDrag([div], text, doc.title + "-instance", {}, params, (button: Doc) => { }, clientX, clientY); doc[fieldKey] = new ScriptField(script); overlayDisposer(); diff --git a/src/client/views/TemplateMenu.scss b/src/client/views/TemplateMenu.scss index 186d3ab0d..69bebe0e9 100644 --- a/src/client/views/TemplateMenu.scss +++ b/src/client/views/TemplateMenu.scss @@ -30,15 +30,15 @@ } .template-list { - position: absolute; - top: 25px; - left: 0px; - width: max-content; font-family: $sans-serif; font-size: 12px; background-color: $light-color-secondary; padding: 2px 12px; list-style: none; + position: relative; + display: inline-block; + height: 100%; + width: 100%; .templateToggle, .chromeToggle { text-align: left; diff --git a/src/client/views/TemplateMenu.tsx b/src/client/views/TemplateMenu.tsx index e6116ca09..8f2ec4bef 100644 --- a/src/client/views/TemplateMenu.tsx +++ b/src/client/views/TemplateMenu.tsx @@ -1,6 +1,5 @@ import { action, observable } from "mobx"; import { observer } from "mobx-react"; -import { DocumentManager } from "../util/DocumentManager"; import { DragManager } from "../util/DragManager"; import { SelectionManager } from "../util/SelectionManager"; import { undoBatch } from "../util/UndoManager"; @@ -10,7 +9,8 @@ import { Template, Templates } from "./Templates"; import React = require("react"); import { Doc } from "../../new_fields/Doc"; import { StrCast } from "../../new_fields/Types"; -import { emptyFunction } from "../../Utils"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faEdit, faChevronCircleUp } from "@fortawesome/free-solid-svg-icons"; const higflyout = require("@hig/flyout"); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; @@ -62,34 +62,12 @@ export class TemplateMenu extends React.Component<TemplateMenuProps> { toggleFloat = (e: React.ChangeEvent<HTMLInputElement>): void => { SelectionManager.DeselectAll(); const topDocView = this.props.docs[0]; - const topDoc = topDocView.props.Document; - const xf = topDocView.props.ScreenToLocalTransform(); - const ex = e.target.clientLeft; - const ey = e.target.clientTop; - undoBatch(action(() => topDoc.z = topDoc.z ? 0 : 1))(); - if (e.target.checked) { - setTimeout(() => { - const newDocView = DocumentManager.Instance.getDocumentView(topDoc); - if (newDocView) { - const de = new DragManager.DocumentDragData([topDoc]); - de.moveDocument = topDocView.props.moveDocument; - const xf = newDocView.ContentDiv!.getBoundingClientRect(); - DragManager.StartDocumentDrag([newDocView.ContentDiv!], de, ex, ey, { - offsetX: (ex - xf.left), offsetY: (ey - xf.top), - handlers: { dragComplete: () => { }, }, - hideSource: false - }); - } - }, 10); - } else if (topDocView.props.ContainingCollectionView) { - const collView = topDocView.props.ContainingCollectionView; - const [sx, sy] = xf.inverse().transformPoint(0, 0); - const [x, y] = collView.props.ScreenToLocalTransform().transformPoint(sx, sy); - topDoc.x = x; - topDoc.y = y; - } + const ex = e.target.getBoundingClientRect().left; + const ey = e.target.getBoundingClientRect().top; + DocumentView.FloatDoc(topDocView, ex, ey); } + @undoBatch @action toggleTemplate = (event: React.ChangeEvent<HTMLInputElement>, template: Template): void => { @@ -155,9 +133,6 @@ export class TemplateMenu extends React.Component<TemplateMenuProps> { DragManager.StartDocumentDrag([dragDocView.ContentDiv!], dragData, left, top, { offsetX: dragData.offset[0], offsetY: dragData.offset[1], - handlers: { - dragComplete: action(emptyFunction), - }, hideSource: false }); } @@ -173,13 +148,18 @@ export class TemplateMenu extends React.Component<TemplateMenuProps> { templateMenu.push(<OtherToggle key={"custom"} name={"Custom"} checked={StrCast(this.props.docs[0].Document.layoutKey, "layout") !== "layout"} toggle={this.toggleCustom} />); templateMenu.push(<OtherToggle key={"chrome"} name={"Chrome"} checked={layout.chromeStatus !== "disabled"} toggle={this.toggleChrome} />); return ( - <div className="templating-menu" onPointerDown={this.onAliasButtonDown}> - <div title="Drag:(create alias). Tap:(modify layout)." className="templating-button" onClick={() => this.toggleTemplateActivity()}>+</div> - <ul className="template-list" ref={this._dragRef} style={{ display: this._hidden ? "none" : "block" }}> + <Flyout anchorPoint={anchorPoints.LEFT_TOP} + content={<ul className="template-list" ref={this._dragRef} style={{ display: "block" }}> {templateMenu} {<button onClick={this.clearTemplates}>Restore Defaults</button>} - </ul> - </div> + </ul>}> + <span className="parentDocumentSelector-button" > + <FontAwesomeIcon icon={faEdit} size={"lg"} /> + </span> + {/* <div className="templating-menu" onPointerDown={this.onAliasButtonDown}> + <div title="Drag:(create alias). Tap:(modify layout)." className="templating-button" onClick={() => this.toggleTemplateActivity()}>+</div> + </div> */} + </Flyout> ); } }
\ No newline at end of file diff --git a/src/client/views/Templates.tsx b/src/client/views/Templates.tsx index ef78b60d4..8af8a6280 100644 --- a/src/client/views/Templates.tsx +++ b/src/client/views/Templates.tsx @@ -56,8 +56,17 @@ export namespace Templates { <div style="width:100%;overflow:auto">{layout}</div> </div> </div>` ); + export const TitleHover = new Template("TitleHover", TemplatePosition.InnerTop, + `<div> + <div style="height:25px; width:100%; background-color: rgba(0, 0, 0, .4); color: white; z-index: 100"> + <span style="text-align:center;width:100%;font-size:20px;position:absolute;overflow:hidden;white-space:nowrap;text-overflow:ellipsis">{props.Document.title}</span> + </div> + <div style="height:calc(100% - 25px);"> + <div style="width:100%;overflow:auto">{layout}</div> + </div> + </div>` ); - export const TemplateList: Template[] = [Title, Caption]; + export const TemplateList: Template[] = [Title, TitleHover, Caption]; export function sortTemplates(a: Template, b: Template) { if (a.Position < b.Position) { return -1; } diff --git a/src/client/views/Touchable.tsx b/src/client/views/Touchable.tsx index 183d3e4e8..251cd41e5 100644 --- a/src/client/views/Touchable.tsx +++ b/src/client/views/Touchable.tsx @@ -2,7 +2,11 @@ import * as React from 'react'; import { action } from 'mobx'; import { InteractionUtils } from '../util/InteractionUtils'; +const HOLD_DURATION = 1000; + export abstract class Touchable<T = {}> extends React.Component<T> { + private holdTimer: NodeJS.Timeout | undefined; + protected _touchDrag: boolean = false; protected prevPoints: Map<number, React.Touch> = new Map<number, React.Touch>(); @@ -18,26 +22,24 @@ export abstract class Touchable<T = {}> extends React.Component<T> { protected onTouchStart = (e: React.TouchEvent): void => { for (let i = 0; i < e.targetTouches.length; i++) { const pt: any = e.targetTouches.item(i); - // pen is also a touch, but with a radius of 0.5 (at least with the surface pens). i doubt anyone's fingers are 2 pixels wide, + // pen is also a touch, but with a radius of 0.5 (at least with the surface pens) // and this seems to be the only way of differentiating pen and touch on touch events - if (pt.radiusX > 2 && pt.radiusY > 2) { + if (pt.radiusX > 0.5 && pt.radiusY > 0.5) { this.prevPoints.set(pt.identifier, pt); } } if (this.prevPoints.size) { - switch (e.targetTouches.length) { + switch (this.prevPoints.size) { case 1: this.handle1PointerDown(e); + e.persist(); + this.holdTimer = setTimeout(() => this.handle1PointerHoldStart(e), HOLD_DURATION); break; case 2: this.handle2PointersDown(e); + break; } - - document.removeEventListener("touchmove", this.onTouch); - document.addEventListener("touchmove", this.onTouch); - document.removeEventListener("touchend", this.onTouchEnd); - document.addEventListener("touchend", this.onTouchEnd); } } @@ -46,10 +48,15 @@ export abstract class Touchable<T = {}> extends React.Component<T> { */ @action protected onTouch = (e: TouchEvent): void => { + const myTouches = InteractionUtils.GetMyTargetTouches(e, this.prevPoints); + // if we're not actually moving a lot, don't consider it as dragging yet - // if (!InteractionUtils.IsDragging(this.prevPoints, e.targetTouches, 5) && !this._touchDrag) return; + if (!InteractionUtils.IsDragging(this.prevPoints, myTouches, 5) && !this._touchDrag) return; this._touchDrag = true; - switch (e.targetTouches.length) { + if (this.holdTimer) { + clearTimeout(this.holdTimer); + } + switch (myTouches.length) { case 1: this.handle1PointerMove(e); break; @@ -64,32 +71,36 @@ export abstract class Touchable<T = {}> extends React.Component<T> { if (this.prevPoints.has(pt.identifier)) { this.prevPoints.set(pt.identifier, pt); } - else { - this.prevPoints.set(pt.identifier, pt); - } } } } @action protected onTouchEnd = (e: TouchEvent): void => { - this._touchDrag = false; - e.stopPropagation(); - + // console.log(InteractionUtils.GetMyTargetTouches(e, this.prevPoints).length + " up"); // remove all the touches associated with the event - for (let i = 0; i < e.targetTouches.length; i++) { - const pt = e.targetTouches.item(i); + for (let i = 0; i < e.changedTouches.length; i++) { + const pt = e.changedTouches.item(i); if (pt) { if (this.prevPoints.has(pt.identifier)) { this.prevPoints.delete(pt.identifier); } } } + if (this.holdTimer) { + clearTimeout(this.holdTimer); + } + this._touchDrag = false; + e.stopPropagation(); + - if (e.targetTouches.length === 0) { - this.prevPoints.clear(); + // if (e.targetTouches.length === 0) { + // this.prevPoints.clear(); + // } + + if (this.prevPoints.size === 0) { + this.cleanUpInteractions(); } - this.cleanUpInteractions(); } cleanUpInteractions = (): void => { @@ -107,6 +118,23 @@ export abstract class Touchable<T = {}> extends React.Component<T> { e.preventDefault(); } - handle1PointerDown = (e: React.TouchEvent): any => { }; - handle2PointersDown = (e: React.TouchEvent): any => { }; + handle1PointerDown = (e: React.TouchEvent): any => { + document.removeEventListener("touchmove", this.onTouch); + document.addEventListener("touchmove", this.onTouch); + document.removeEventListener("touchend", this.onTouchEnd); + document.addEventListener("touchend", this.onTouchEnd); + } + + handle2PointersDown = (e: React.TouchEvent): any => { + document.removeEventListener("touchmove", this.onTouch); + document.addEventListener("touchmove", this.onTouch); + document.removeEventListener("touchend", this.onTouchEnd); + document.addEventListener("touchend", this.onTouchEnd); + } + + handle1PointerHoldStart = (e: React.TouchEvent): any => { + console.log("Hold"); + e.stopPropagation(); + e.preventDefault(); + } }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionDockingView.scss b/src/client/views/collections/CollectionDockingView.scss index bcdc9c97e..f518ef8fb 100644 --- a/src/client/views/collections/CollectionDockingView.scss +++ b/src/client/views/collections/CollectionDockingView.scss @@ -25,7 +25,7 @@ position: absolute; top: 0; left: 0; - overflow: hidden; + // overflow: hidden; // bcz: menus don't show up when this is on (e.g., the parentSelectorMenu) .collectionDockingView-dragAsDocument { touch-action: none; diff --git a/src/client/views/collections/CollectionDockingView.tsx b/src/client/views/collections/CollectionDockingView.tsx index 77e6e1c93..6c50ea0f2 100644 --- a/src/client/views/collections/CollectionDockingView.tsx +++ b/src/client/views/collections/CollectionDockingView.tsx @@ -20,7 +20,7 @@ import { emptyFunction, returnEmptyString, returnFalse, returnOne, returnTrue, U import { DocServer } from "../../DocServer"; import { Docs } from '../../documents/Documents'; import { DocumentManager } from '../../util/DocumentManager'; -import { DragLinksAsDocuments, DragManager } from "../../util/DragManager"; +import { DragManager } from "../../util/DragManager"; import { SelectionManager } from '../../util/SelectionManager'; import { Transform } from '../../util/Transform'; import { undoBatch } from "../../util/UndoManager"; @@ -32,6 +32,9 @@ import React = require("react"); import { ButtonSelector } from './ParentDocumentSelector'; import { DocumentType } from '../../documents/DocumentTypes'; import { ComputedField } from '../../../new_fields/ScriptField'; +import { InteractionUtils } from '../../util/InteractionUtils'; +import { TraceMobx } from '../../../new_fields/util'; +import { Scripting } from '../../util/Scripting'; library.add(faFile); const _global = (window /* browser */ || global /* node */) as any; @@ -39,7 +42,7 @@ const _global = (window /* browser */ || global /* node */) as any; export class CollectionDockingView extends React.Component<SubCollectionViewProps> { @observable public static Instances: CollectionDockingView[] = []; @computed public static get Instance() { return CollectionDockingView.Instances[0]; } - public static makeDocumentConfig(document: Doc, dataDoc: Doc | undefined, width?: number) { + public static makeDocumentConfig(document: Doc, dataDoc: Doc | undefined, width?: number, libraryPath?: Doc[]) { return { type: 'react-component', component: 'DocumentFrameRenderer', @@ -47,7 +50,8 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp width: width, props: { documentId: document[Id], - dataDocumentId: dataDoc && dataDoc[Id] !== document[Id] ? dataDoc[Id] : "" + dataDocumentId: dataDoc && dataDoc[Id] !== document[Id] ? dataDoc[Id] : "", + libraryPath: libraryPath ? libraryPath.map(d => d[Id]) : [] //collectionDockingView: CollectionDockingView.Instance } }; @@ -95,12 +99,12 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp @undoBatch @action - public OpenFullScreen(docView: DocumentView) { + public OpenFullScreen(docView: DocumentView, libraryPath?: Doc[]) { const document = Doc.MakeAlias(docView.props.Document); const dataDoc = docView.props.DataDoc; const newItemStackConfig = { type: 'stack', - content: [CollectionDockingView.makeDocumentConfig(document, dataDoc)] + content: [CollectionDockingView.makeDocumentConfig(document, dataDoc, undefined, libraryPath)] }; const docconfig = this._goldenLayout.root.layoutManager.createContentItem(newItemStackConfig, this._goldenLayout); this._goldenLayout.root.contentItems[0].addChild(docconfig); @@ -174,12 +178,12 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp // @undoBatch @action - public static AddRightSplit(document: Doc, dataDoc: Doc | undefined, minimize: boolean = false) { + public static AddRightSplit(document: Doc, dataDoc: Doc | undefined, libraryPath?: Doc[]) { if (!CollectionDockingView.Instance) return false; const instance = CollectionDockingView.Instance; const newItemStackConfig = { type: 'stack', - content: [CollectionDockingView.makeDocumentConfig(document, dataDoc)] + content: [CollectionDockingView.makeDocumentConfig(document, dataDoc, undefined, libraryPath)] }; const newContentItem = instance._goldenLayout.root.layoutManager.createContentItem(newItemStackConfig, instance._goldenLayout); @@ -199,11 +203,6 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp collayout.config.width = 50; newContentItem.config.width = 50; } - if (minimize) { - // bcz: this makes the drag image show up better, but it also messes with fixed layout sizes - // newContentItem.config.width = 10; - // newContentItem.config.height = 10; - } newContentItem.callDownwards('_$init'); instance.layoutChanged(); return true; @@ -211,9 +210,9 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp @undoBatch @action - public AddTab = (stack: any, document: Doc, dataDocument: Doc | undefined) => { + public AddTab = (stack: any, document: Doc, dataDocument: Doc | undefined, libraryPath?: Doc[]) => { Doc.GetProto(document).lastOpened = new DateField; - const docContentConfig = CollectionDockingView.makeDocumentConfig(document, dataDocument); + const docContentConfig = CollectionDockingView.makeDocumentConfig(document, dataDocument, undefined, libraryPath); if (stack === undefined) { let stack: any = this._goldenLayout.root; while (!stack.isStack) { @@ -344,7 +343,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp const docid = (e.target as any).DashDocId; const tab = (e.target as any).parentElement as HTMLElement; DocServer.GetRefField(docid).then(action(async (sourceDoc: Opt<Field>) => - (sourceDoc instanceof Doc) && DragLinksAsDocuments(tab, x, y, sourceDoc))); + (sourceDoc instanceof Doc) && DragManager.StartLinkTargetsDrag(tab, x, y, sourceDoc))); } if (className === "lm_drag_handle" || className === "lm_close" || className === "lm_maximise" || className === "lm_minimise" || className === "lm_close_tab") { this._flush = true; @@ -417,20 +416,14 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp }; ReactDOM.render(<span title="Drag as document" className="collectionDockingView-dragAsDocument" - onPointerDown={ - e => { - e.preventDefault(); - e.stopPropagation(); - DragManager.StartDocumentDrag([dragSpan], new DragManager.DocumentDragData([doc]), e.clientX, e.clientY, { - handlers: { dragComplete: emptyFunction }, - hideSource: false - }); - }}><FontAwesomeIcon icon="file" size="lg" /></span>, dragSpan); + onPointerDown={e => { + e.preventDefault(); + e.stopPropagation(); + DragManager.StartDocumentDrag([dragSpan], new DragManager.DocumentDragData([doc]), e.clientX, e.clientY); + }}> + <FontAwesomeIcon icon="file" size="lg" /> + </span>, dragSpan); ReactDOM.render(<ButtonSelector Document={doc} Stack={stack} />, gearSpan); - // ReactDOM.render(<ParentDocSelector Document={doc} addDocTab={(doc, data, where) => { - // where === "onRight" ? CollectionDockingView.AddRightSplit(doc, dataDoc) : CollectionDockingView.Instance.AddTab(stack, doc, dataDoc); - // return true; - // }} />, upDiv); tab.reactComponents = [dragSpan, gearSpan, upDiv]; tab.element.append(dragSpan); tab.element.append(gearSpan); @@ -482,6 +475,28 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp this.AddTab(stack, Docs.Create.FreeformDocument([], { width: this.props.PanelWidth(), height: this.props.PanelHeight(), title: "Untitled Collection" }), undefined); } }); + + // starter code for bezel to add new pane + // stack.element.on("touchstart", (e: TouchEvent) => { + // if (e.targetTouches.length === 2) { + // let pt1 = e.targetTouches.item(0); + // let pt2 = e.targetTouches.item(1); + // let threshold = 40 * window.devicePixelRatio; + // if (pt1 && pt2 && InteractionUtils.TwoPointEuclidist(pt1, pt2) < threshold) { + // let edgeThreshold = 30 * window.devicePixelRatio; + // let center = InteractionUtils.CenterPoint([pt1, pt2]); + // let stackRect: DOMRect = stack.element.getBoundingClientRect(); + // let nearLeft = center.X - stackRect.x < edgeThreshold; + // let nearTop = center.Y - stackRect.y < edgeThreshold; + // let nearRight = stackRect.right - center.X < edgeThreshold; + // let nearBottom = stackRect.bottom - center.Y < edgeThreshold; + // let ns = [nearLeft, nearTop, nearRight, nearBottom].filter(n => n); + // if (ns.length === 1) { + + // } + // } + // } + // }); stack.header.controlsContainer.find('.lm_close') //get the close icon .off('click') //unbind the current click handler .click(action(async function () { @@ -532,11 +547,13 @@ interface DockedFrameProps { documentId: FieldId; dataDocumentId: FieldId; glContainer: any; + libraryPath: (FieldId[]); //collectionDockingView: CollectionDockingView } @observer export class DockedFrameRenderer extends React.Component<DockedFrameProps> { _mainCont: HTMLDivElement | null = null; + @observable private _libraryPath: Doc[] = []; @observable private _panelWidth = 0; @observable private _panelHeight = 0; @observable private _document: Opt<Doc>; @@ -554,6 +571,14 @@ export class DockedFrameRenderer extends React.Component<DockedFrameProps> { DocServer.GetRefField(this.props.dataDocumentId).then(action((f: Opt<Field>) => this._dataDoc = f as Doc)); } })); + this.props.libraryPath && this.setupLibraryPath(); + } + + async setupLibraryPath() { + Promise.all(this.props.libraryPath.map(async docid => { + const d = await DocServer.GetRefField(docid); + return d instanceof Doc ? d : undefined; + })).then(action((list: (Doc | undefined)[]) => this._libraryPath = list.filter(d => d).map(d => d as Doc))); } /** @@ -640,25 +665,26 @@ export class DockedFrameRenderer extends React.Component<DockedFrameProps> { get previewPanelCenteringOffset() { return this.nativeWidth() && !this.layoutDoc!.ignoreAspect ? (this._panelWidth - this.nativeWidth() * this.contentScaling()) / 2 : 0; } get widthpercent() { return this.nativeWidth() && !this.layoutDoc!.ignoreAspect ? `${(this.nativeWidth() * this.contentScaling()) / this.panelWidth() * 100}%` : undefined; } - addDocTab = (doc: Doc, dataDoc: Opt<Doc>, location: string) => { + addDocTab = (doc: Doc, dataDoc: Opt<Doc>, location: string, libraryPath?: Doc[]) => { SelectionManager.DeselectAll(); if (doc.dockingConfig) { - MainView.Instance.openWorkspace(doc); - return true; + return MainView.Instance.openWorkspace(doc); } else if (location === "onRight") { - return CollectionDockingView.AddRightSplit(doc, dataDoc); + return CollectionDockingView.AddRightSplit(doc, dataDoc, libraryPath); } else if (location === "close") { return CollectionDockingView.CloseRightSplit(doc); } else { - return CollectionDockingView.Instance.AddTab(this._stack, doc, dataDoc); + return CollectionDockingView.Instance.AddTab(this._stack, doc, dataDoc, libraryPath); } } @computed get docView() { + TraceMobx(); if (!this._document) return (null); const document = this._document; const resolvedDataDoc = document.layout instanceof Doc ? document : this._dataDoc; return <DocumentView key={document[Id]} + LibraryPath={this._libraryPath} Document={document} DataDoc={resolvedDataDoc} bringToFront={emptyFunction} @@ -694,3 +720,4 @@ export class DockedFrameRenderer extends React.Component<DockedFrameProps> { </div >); } } +Scripting.addGlobal(function openOnRight(doc: any) { CollectionDockingView.AddRightSplit(doc, undefined); }); diff --git a/src/client/views/collections/CollectionMasonryViewFieldRow.tsx b/src/client/views/collections/CollectionMasonryViewFieldRow.tsx index d697e721b..80752303c 100644 --- a/src/client/views/collections/CollectionMasonryViewFieldRow.tsx +++ b/src/client/views/collections/CollectionMasonryViewFieldRow.tsx @@ -56,7 +56,7 @@ export class CollectionMasonryViewFieldRow extends React.Component<CMVFieldRowPr createRowDropRef = (ele: HTMLDivElement | null) => { this._dropDisposer && this._dropDisposer(); if (ele) { - this._dropDisposer = DragManager.MakeDropTarget(ele, { handlers: { drop: this.rowDrop.bind(this) } }); + this._dropDisposer = DragManager.MakeDropTarget(ele, this.rowDrop.bind(this)); } } @@ -74,12 +74,12 @@ export class CollectionMasonryViewFieldRow extends React.Component<CMVFieldRowPr @undoBatch rowDrop = action((e: Event, de: DragManager.DropEvent) => { this._createAliasSelected = false; - if (de.data instanceof DragManager.DocumentDragData) { + if (de.complete.docDragData) { (this.props.parent.Document.dropConverter instanceof ScriptField) && - this.props.parent.Document.dropConverter.script.run({ dragData: de.data }); + this.props.parent.Document.dropConverter.script.run({ dragData: de.complete.docDragData }); const key = StrCast(this.props.parent.props.Document.sectionFilter); const castedValue = this.getValue(this._heading); - de.data.droppedDocuments.forEach(d => d[key] = castedValue); + de.complete.docDragData.droppedDocuments.forEach(d => d[key] = castedValue); this.props.parent.drop(e, de); e.stopPropagation(); } @@ -171,10 +171,8 @@ export class CollectionMasonryViewFieldRow extends React.Component<CMVFieldRowPr const script = `return doc.${key} === ${value}`; const compiled = CompileScript(script, { params: { doc: Doc.name } }); if (compiled.compiled) { - const scriptField = new ScriptField(compiled); - alias.viewSpecScript = scriptField; - const dragData = new DragManager.DocumentDragData([alias]); - DragManager.StartDocumentDrag([this._headerRef.current!], dragData, e.clientX, e.clientY); + alias.viewSpecScript = new ScriptField(compiled); + DragManager.StartDocumentDrag([this._headerRef.current!], new DragManager.DocumentDragData([alias]), e.clientX, e.clientY); } e.stopPropagation(); diff --git a/src/client/views/collections/CollectionSchemaCells.tsx b/src/client/views/collections/CollectionSchemaCells.tsx index 1700c14cf..79a34bc00 100644 --- a/src/client/views/collections/CollectionSchemaCells.tsx +++ b/src/client/views/collections/CollectionSchemaCells.tsx @@ -37,7 +37,7 @@ export interface CellProps { renderDepth: number; addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => boolean; pinToPres: (document: Doc) => void; - moveDocument: (document: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean) => boolean; + moveDocument: (document: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean) => boolean; isFocused: boolean; changeFocusedCellByIndex: (row: number, col: number) => void; setIsEditing: (isEditing: boolean) => void; @@ -105,13 +105,13 @@ export class CollectionSchemaCell extends React.Component<CellProps> { } private drop = (e: Event, de: DragManager.DropEvent) => { - if (de.data instanceof DragManager.DocumentDragData) { + if (de.complete.docDragData) { const fieldKey = this.props.rowProps.column.id as string; - if (de.data.draggedDocuments.length === 1) { - this._document[fieldKey] = de.data.draggedDocuments[0]; + if (de.complete.docDragData.draggedDocuments.length === 1) { + this._document[fieldKey] = de.complete.docDragData.draggedDocuments[0]; } else { - const coll = Docs.Create.SchemaDocument([new SchemaHeaderField("title", "#f1efeb")], de.data.draggedDocuments, {}); + const coll = Docs.Create.SchemaDocument([new SchemaHeaderField("title", "#f1efeb")], de.complete.docDragData.draggedDocuments, {}); this._document[fieldKey] = coll; } e.stopPropagation(); @@ -121,7 +121,7 @@ export class CollectionSchemaCell extends React.Component<CellProps> { private dropRef = (ele: HTMLElement | null) => { this._dropDisposer && this._dropDisposer(); if (ele) { - this._dropDisposer = DragManager.MakeDropTarget(ele, { handlers: { drop: this.drop.bind(this) } }); + this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this)); } } @@ -143,6 +143,7 @@ export class CollectionSchemaCell extends React.Component<CellProps> { const props: FieldViewProps = { Document: this.props.rowProps.original, DataDoc: this.props.rowProps.original, + LibraryPath: [], fieldKey: this.props.rowProps.column.id as string, ruleProvider: undefined, ContainingCollectionView: this.props.CollectionView, @@ -166,11 +167,10 @@ export class CollectionSchemaCell extends React.Component<CellProps> { const fieldIsDoc = (type === "document" && typeof field === "object") || (typeof field === "object" && doc); const onItemDown = (e: React.PointerEvent) => { - if (fieldIsDoc) { - SetupDrag(this._focusRef, () => this._document[props.fieldKey] instanceof Doc ? this._document[props.fieldKey] : this._document, - this._document[props.fieldKey] instanceof Doc ? (doc: Doc, target: Doc, addDoc: (newDoc: Doc) => any) => addDoc(doc) : this.props.moveDocument, - this._document[props.fieldKey] instanceof Doc ? "alias" : this.props.Document.schemaDoc ? "copy" : undefined)(e); - } + fieldIsDoc && SetupDrag(this._focusRef, + () => this._document[props.fieldKey] instanceof Doc ? this._document[props.fieldKey] : this._document, + this._document[props.fieldKey] instanceof Doc ? (doc: Doc, target: Doc | undefined, addDoc: (newDoc: Doc) => any) => addDoc(doc) : this.props.moveDocument, + this._document[props.fieldKey] instanceof Doc ? "alias" : this.props.Document.schemaDoc ? "copy" : undefined)(e); }; const onPointerEnter = (e: React.PointerEvent): void => { if (e.buttons === 1 && SelectionManager.GetIsDragging() && (type === "document" || type === undefined)) { diff --git a/src/client/views/collections/CollectionSchemaHeaders.tsx b/src/client/views/collections/CollectionSchemaHeaders.tsx index 0114342b9..92dc8780e 100644 --- a/src/client/views/collections/CollectionSchemaHeaders.tsx +++ b/src/client/views/collections/CollectionSchemaHeaders.tsx @@ -286,7 +286,6 @@ class KeysDropdown extends React.Component<KeysDropdownProps> { } @undoBatch - @action onKeyDown = (e: React.KeyboardEvent): void => { if (e.key === "Enter") { const keyOptions = this._searchTerm === "" ? this.props.possibleKeys : this.props.possibleKeys.filter(key => key.toUpperCase().indexOf(this._searchTerm.toUpperCase()) > -1); @@ -296,7 +295,7 @@ class KeysDropdown extends React.Component<KeysDropdownProps> { if (!exactFound && this._searchTerm !== "" && this.props.canAddNew) { this.onSelect(this._searchTerm); } else { - this._searchTerm = this._key; + this.setSearchTerm(this._key); } } } diff --git a/src/client/views/collections/CollectionSchemaMovableTableHOC.tsx b/src/client/views/collections/CollectionSchemaMovableTableHOC.tsx index 90320df82..153bbd410 100644 --- a/src/client/views/collections/CollectionSchemaMovableTableHOC.tsx +++ b/src/client/views/collections/CollectionSchemaMovableTableHOC.tsx @@ -56,7 +56,7 @@ export class MovableColumn extends React.Component<MovableColumnProps> { createColDropTarget = (ele: HTMLDivElement) => { this._colDropDisposer && this._colDropDisposer(); if (ele) { - this._colDropDisposer = DragManager.MakeDropTarget(ele, { handlers: { drop: this.colDrop.bind(this) } }); + this._colDropDisposer = DragManager.MakeDropTarget(ele, this.colDrop.bind(this)); } } @@ -66,8 +66,8 @@ export class MovableColumn extends React.Component<MovableColumnProps> { const rect = this._header!.current!.getBoundingClientRect(); const bounds = this.props.ScreenToLocalTransform().transformPoint(rect.left + ((rect.right - rect.left) / 2), rect.top); const before = x[0] < bounds[0]; - if (de.data instanceof DragManager.ColumnDragData) { - this.props.reorderColumns(de.data.colKey, this.props.columnValue, before, this.props.allColumns); + if (de.complete.columnDragData) { + this.props.reorderColumns(de.complete.columnDragData.colKey, this.props.columnValue, before, this.props.allColumns); return true; } return false; @@ -165,7 +165,7 @@ export class MovableRow extends React.Component<MovableRowProps> { createRowDropTarget = (ele: HTMLDivElement) => { this._rowDropDisposer && this._rowDropDisposer(); if (ele) { - this._rowDropDisposer = DragManager.MakeDropTarget(ele, { handlers: { drop: this.rowDrop.bind(this) } }); + this._rowDropDisposer = DragManager.MakeDropTarget(ele, this.rowDrop.bind(this)); } } @@ -178,16 +178,17 @@ export class MovableRow extends React.Component<MovableRowProps> { const bounds = this.props.ScreenToLocalTransform().transformPoint(rect.left, rect.top + rect.height / 2); const before = x[1] < bounds[1]; - if (de.data instanceof DragManager.DocumentDragData) { + const docDragData = de.complete.docDragData; + if (docDragData) { e.stopPropagation(); - if (de.data.draggedDocuments[0] === rowDoc) return true; + if (docDragData.draggedDocuments[0] === rowDoc) return true; const addDocument = (doc: Doc) => this.props.addDoc(doc, rowDoc, before); - const movedDocs = de.data.draggedDocuments; - return (de.data.dropAction || de.data.userDropAction) ? - de.data.droppedDocuments.reduce((added: boolean, d) => this.props.addDoc(d, rowDoc, before) || added, false) - : (de.data.moveDocument) ? - movedDocs.reduce((added: boolean, d) => de.data.moveDocument(d, rowDoc, addDocument) || added, false) - : de.data.droppedDocuments.reduce((added: boolean, d) => this.props.addDoc(d, rowDoc, before), false); + const movedDocs = docDragData.draggedDocuments; + return (docDragData.dropAction || docDragData.userDropAction) ? + docDragData.droppedDocuments.reduce((added: boolean, d) => this.props.addDoc(d, rowDoc, before) || added, false) + : (docDragData.moveDocument) ? + movedDocs.reduce((added: boolean, d) => docDragData.moveDocument?.(d, rowDoc, addDocument) || added, false) + : docDragData.droppedDocuments.reduce((added: boolean, d) => this.props.addDoc(d, rowDoc, before), false); } return false; } @@ -199,12 +200,12 @@ export class MovableRow extends React.Component<MovableRowProps> { @undoBatch @action - move: DragManager.MoveFunction = (doc: Doc, target: Doc, addDoc) => { - const targetView = DocumentManager.Instance.getDocumentView(target); + move: DragManager.MoveFunction = (doc: Doc, targetCollection: Doc | undefined, addDoc) => { + const targetView = targetCollection && DocumentManager.Instance.getDocumentView(targetCollection); if (targetView && targetView.props.ContainingCollectionDoc) { - return doc !== target && doc !== targetView.props.ContainingCollectionDoc && this.props.removeDoc(doc) && addDoc(doc); + return doc !== targetCollection && doc !== targetView.props.ContainingCollectionDoc && this.props.removeDoc(doc) && addDoc(doc); } - return doc !== target && this.props.removeDoc(doc) && addDoc(doc); + return doc !== targetCollection && this.props.removeDoc(doc) && addDoc(doc); } render() { diff --git a/src/client/views/collections/CollectionSchemaView.tsx b/src/client/views/collections/CollectionSchemaView.tsx index f336eaf75..b466d9511 100644 --- a/src/client/views/collections/CollectionSchemaView.tsx +++ b/src/client/views/collections/CollectionSchemaView.tsx @@ -141,6 +141,7 @@ export class CollectionSchemaView extends CollectionSubView(doc => doc) { <ContentFittingDocumentView Document={layoutDoc} DataDocument={this.previewDocument !== this.props.DataDoc ? this.props.DataDoc : undefined} + LibraryPath={this.props.LibraryPath} childDocs={this.childDocs} renderDepth={this.props.renderDepth} ruleProvider={this.props.Document.isRuleProvider && layoutDoc && layoutDoc.type !== DocumentType.TEXT ? this.props.Document : this.props.ruleProvider} @@ -156,8 +157,6 @@ export class CollectionSchemaView extends CollectionSubView(doc => doc) { whenActiveChanged={this.props.whenActiveChanged} addDocTab={this.props.addDocTab} pinToPres={this.props.pinToPres} - setPreviewScript={this.setPreviewScript} - previewScript={this.previewScript} /> </div>; } @@ -223,7 +222,7 @@ export interface SchemaTableProps { renderDepth: number; deleteDocument: (document: Doc) => boolean; addDocument: (document: Doc) => boolean; - moveDocument: (document: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean) => boolean; + moveDocument: (document: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean) => boolean; ScreenToLocalTransform: () => Transform; active: (outsideReaction: boolean) => boolean; onDrop: (e: React.DragEvent<Element>, options: DocumentOptions, completed?: (() => void) | undefined) => void; diff --git a/src/client/views/collections/CollectionStackingView.tsx b/src/client/views/collections/CollectionStackingView.tsx index 955fcda80..886a9c870 100644 --- a/src/client/views/collections/CollectionStackingView.tsx +++ b/src/client/views/collections/CollectionStackingView.tsx @@ -22,8 +22,9 @@ import { CollectionStackingViewFieldColumn } from "./CollectionStackingViewField import { CollectionSubView } from "./CollectionSubView"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from "../ContextMenuItem"; -import { ScriptBox } from "../ScriptBox"; import { CollectionMasonryViewFieldRow } from "./CollectionMasonryViewFieldRow"; +import { TraceMobx } from "../../../new_fields/util"; +import { CollectionViewType } from "./CollectionView"; @observer export class CollectionStackingView extends CollectionSubView(doc => doc) { @@ -38,9 +39,9 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { @observable _scroll = 0; // used to force the document decoration to update when scrolling @computed get sectionHeaders() { return Cast(this.props.Document.sectionHeaders, listSpec(SchemaHeaderField)); } @computed get sectionFilter() { return StrCast(this.props.Document.sectionFilter); } - @computed get filteredChildren() { return this.childDocs.filter(d => !d.isMinimized); } + @computed get filteredChildren() { return this.childDocs.filter(d => !d.isMinimized).map(d => (Doc.GetLayoutDataDocPair(this.props.Document, this.props.DataDoc, this.props.fieldKey, d).layout as Doc) || d); } @computed get xMargin() { return NumCast(this.props.Document.xMargin, 2 * this.gridGap); } - @computed get yMargin() { return Math.max(this.props.Document.showTitle ? 30 : 0, NumCast(this.props.Document.yMargin, 2 * this.gridGap)); } + @computed get yMargin() { return Math.max(this.props.Document.showTitle && !this.props.Document.showTitleHover ? 30 : 0, NumCast(this.props.Document.yMargin, 2 * this.gridGap)); } @computed get gridGap() { return NumCast(this.props.Document.gridGap, 10); } @computed get isStackingView() { return BoolCast(this.props.Document.singleColumn, true); } @computed get numGroupColumns() { return this.isStackingView ? Math.max(1, this.Sections.size + (this.showAddAGroup ? 1 : 0)) : 1; } @@ -51,22 +52,18 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { } @computed get NodeWidth() { return this.props.PanelWidth() - this.gridGap; } - childDocHeight(child: Doc) { return this.getDocHeight(Doc.GetLayoutDataDocPair(this.props.Document, this.props.DataDoc, this.props.fieldKey, child).layout); } - children(docs: Doc[]) { this._docXfs.length = 0; return docs.map((d, i) => { - const pair = Doc.GetLayoutDataDocPair(this.props.Document, this.props.DataDoc, this.props.fieldKey, d); - const layoutDoc = pair.layout ? Doc.Layout(pair.layout) : d; - const width = () => Math.min(layoutDoc.nativeWidth && !layoutDoc.ignoreAspect && !this.props.Document.fillColumn ? layoutDoc[WidthSym]() : Number.MAX_VALUE, this.columnWidth / this.numGroupColumns); - const height = () => this.getDocHeight(layoutDoc); + const width = () => Math.min(d.nativeWidth && !d.ignoreAspect && !this.props.Document.fillColumn ? d[WidthSym]() : Number.MAX_VALUE, this.columnWidth / this.numGroupColumns); + const height = () => this.getDocHeight(d); const dref = React.createRef<HTMLDivElement>(); - const dxf = () => this.getDocTransform(layoutDoc, dref.current!); + const dxf = () => this.getDocTransform(d, dref.current!); this._docXfs.push({ dxf: dxf, width: width, height: height }); const rowSpan = Math.ceil((height() + this.gridGap) / this.gridGap); const style = this.isStackingView ? { width: width(), marginTop: i === 0 ? 0 : this.gridGap, height: height() } : { gridRowEnd: `span ${rowSpan}` }; return <div className={`collectionStackingView-${this.isStackingView ? "columnDoc" : "masonryDoc"}`} key={d[Id]} ref={dref} style={style} > - {this.getDisplayDoc(pair.layout || d, pair.data, dxf, width)} + {this.getDisplayDoc(d, (d.resolvedDataDoc as Doc) || d, dxf, width)} </div>; }); } @@ -113,7 +110,7 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { const res = this.props.ContentScaling() * sectionsList.reduce((maxHght, s) => { const r1 = Math.max(maxHght, (this.Sections.size ? 50 : 0) + s.reduce((height, d, i) => { - const val = height + this.childDocHeight(d) + (i === s.length - 1 ? this.yMargin : this.gridGap); + const val = height + this.getDocHeight(d) + (i === s.length - 1 ? this.yMargin : this.gridGap); return val; }, this.yMargin)); return r1; @@ -146,7 +143,7 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { } @action - moveDocument = (doc: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean): boolean => { + moveDocument = (doc: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean): boolean => { return this.props.removeDocument(doc) && addDocument(doc); } createRef = (ele: HTMLDivElement | null) => { @@ -155,7 +152,7 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { } overlays = (doc: Doc) => { - return doc.type === DocumentType.IMG || doc.type === DocumentType.VID ? { title: StrCast(this.props.Document.showTitles), caption: StrCast(this.props.Document.showCaptions) } : {}; + return doc.type === DocumentType.IMG || doc.type === DocumentType.VID ? { title: StrCast(this.props.Document.showTitles), titleHover: StrCast(this.props.Document.showTitleHovers), caption: StrCast(this.props.Document.showCaptions) } : {}; } @computed get onChildClickHandler() { return ScriptCast(this.Document.onChildClick); } @@ -164,18 +161,18 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { getDisplayDoc(doc: Doc, dataDoc: Doc | undefined, dxf: () => Transform, width: () => number) { const layoutDoc = Doc.Layout(doc); const height = () => this.getDocHeight(doc); - const finalDxf = () => dxf().scale(this.columnWidth / layoutDoc[WidthSym]()); return <ContentFittingDocumentView Document={doc} DataDocument={dataDoc} + LibraryPath={this.props.LibraryPath} showOverlays={this.overlays} - renderDepth={this.props.renderDepth} + renderDepth={this.props.renderDepth + 1} ruleProvider={this.props.Document.isRuleProvider && layoutDoc.type !== DocumentType.TEXT ? this.props.Document : this.props.ruleProvider} fitToBox={this.props.fitToBox} onClick={layoutDoc.isTemplateDoc ? this.onClickHandler : this.onChildClickHandler} PanelWidth={width} PanelHeight={height} - getTransform={finalDxf} + getTransform={dxf} focus={this.props.focus} CollectionDoc={this.props.CollectionView && this.props.CollectionView.props.Document} CollectionView={this.props.CollectionView} @@ -185,9 +182,7 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { active={this.props.active} whenActiveChanged={this.props.whenActiveChanged} addDocTab={this.props.addDocTab} - pinToPres={this.props.pinToPres} - setPreviewScript={emptyFunction} - previewScript={undefined}> + pinToPres={this.props.pinToPres}> </ContentFittingDocumentView>; } getDocHeight(d?: Doc) { @@ -240,26 +235,27 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { drop = (e: Event, de: DragManager.DropEvent) => { const where = [de.x, de.y]; let targInd = -1; - let plusOne = false; - if (de.data instanceof DragManager.DocumentDragData) { + let plusOne = 0; + if (de.complete.docDragData) { this._docXfs.map((cd, i) => { const pos = cd.dxf().inverse().transformPoint(-2 * this.gridGap, -2 * this.gridGap); const pos1 = cd.dxf().inverse().transformPoint(cd.width(), cd.height()); if (where[0] > pos[0] && where[0] < pos1[0] && where[1] > pos[1] && where[1] < pos1[1]) { targInd = i; - plusOne = (where[1] > (pos[1] + pos1[1]) / 2 ? 1 : 0) ? true : false; + const axis = this.Document.viewType === CollectionViewType.Masonry ? 0 : 1; + plusOne = where[axis] > (pos[axis] + pos1[axis]) / 2 ? 1 : 0; } }); - } - if (super.drop(e, de)) { - const newDoc = de.data.droppedDocuments[0]; - const docs = this.childDocList; - if (docs) { - if (targInd === -1) targInd = docs.length; - else targInd = docs.indexOf(this.filteredChildren[targInd]); - const srcInd = docs.indexOf(newDoc); - docs.splice(srcInd, 1); - docs.splice((targInd > srcInd ? targInd - 1 : targInd) + (plusOne ? 1 : 0), 0, newDoc); + if (super.drop(e, de)) { + const newDoc = de.complete.docDragData.droppedDocuments[0]; + const docs = this.childDocList; + if (docs) { + if (targInd === -1) targInd = docs.length; + else targInd = docs.indexOf(this.filteredChildren[targInd]); + const srcInd = docs.indexOf(newDoc); + docs.splice(srcInd, 1); + docs.splice((targInd > srcInd ? targInd - 1 : targInd) + plusOne, 0, newDoc); + } } } return false; @@ -291,7 +287,7 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { sectionStacking = (heading: SchemaHeaderField | undefined, docList: Doc[]) => { const key = this.sectionFilter; let type: "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function" | undefined = undefined; - const types = docList.length ? docList.map(d => typeof d[key]) : this.childDocs.map(d => typeof d[key]); + const types = docList.length ? docList.map(d => typeof d[key]) : this.filteredChildren.map(d => typeof d[key]); if (types.map((i, idx) => types.indexOf(i) === idx).length === 1) { type = types[0]; } @@ -316,16 +312,17 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { const y = this._scroll; // required for document decorations to update when the text box container is scrolled const { scale, translateX, translateY } = Utils.GetScreenTransform(dref); const outerXf = Utils.GetScreenTransform(this._masonryGridRef!); + const scaling = 1 / Math.min(1, this.props.PanelHeight() / this.layoutDoc[HeightSym]()); const offset = this.props.ScreenToLocalTransform().transformDirection(outerXf.translateX - translateX, outerXf.translateY - translateY); - return this.props.ScreenToLocalTransform(). - translate(offset[0], offset[1] + (this.props.ChromeHeight && this.props.ChromeHeight() < 0 ? this.props.ChromeHeight() : 0)). - scale(NumCast(doc.width, 1) / this.columnWidth); + const offsetx = (doc[WidthSym]() - doc[WidthSym]() / scaling) / 2; + const offsety = (this.props.ChromeHeight && this.props.ChromeHeight() < 0 ? this.props.ChromeHeight() : 0); + return this.props.ScreenToLocalTransform().translate(offset[0] - offsetx, offset[1] + offsety).scale(scaling); } sectionMasonry = (heading: SchemaHeaderField | undefined, docList: Doc[]) => { const key = this.sectionFilter; let type: "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function" | undefined = undefined; - const types = docList.length ? docList.map(d => typeof d[key]) : this.childDocs.map(d => typeof d[key]); + const types = docList.length ? docList.map(d => typeof d[key]) : this.filteredChildren.map(d => typeof d[key]); if (types.map((i, idx) => types.indexOf(i) === idx).length === 1) { type = types[0]; } @@ -374,34 +371,39 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { subItems.push({ description: `${this.props.Document.showTitles ? "Hide Titles" : "Show Titles"}`, event: () => this.props.Document.showTitles = !this.props.Document.showTitles ? "title" : "", icon: "plus" }); subItems.push({ description: `${this.props.Document.showCaptions ? "Hide Captions" : "Show Captions"}`, event: () => this.props.Document.showCaptions = !this.props.Document.showCaptions ? "caption" : "", icon: "plus" }); ContextMenu.Instance.addItem({ description: "Stacking Options ...", subitems: subItems, icon: "eye" }); - - const existingOnClick = ContextMenu.Instance.findByDescription("OnClick..."); - const onClicks: ContextMenuProps[] = existingOnClick && "subitems" in existingOnClick ? existingOnClick.subitems : []; - onClicks.push({ description: "Edit onChildClick script", icon: "edit", event: (obj: any) => ScriptBox.EditButtonScript("On Child Clicked...", this.props.Document, "onChildClick", obj.x, obj.y) }); - !existingOnClick && ContextMenu.Instance.addItem({ description: "OnClick...", subitems: onClicks, icon: "hand-point-right" }); } } + @computed get renderedSections() { + TraceMobx(); + let sections = [[undefined, this.filteredChildren] as [SchemaHeaderField | undefined, Doc[]]]; + if (this.sectionFilter) { + const entries = Array.from(this.Sections.entries()); + sections = entries.sort(this.sortFunc); + } + return sections.map(section => this.isStackingView ? this.sectionStacking(section[0], section[1]) : this.sectionMasonry(section[0], section[1])); + } render() { + TraceMobx(); const editableViewProps = { GetValue: () => "", SetValue: this.addGroup, contents: "+ ADD A GROUP" }; - let sections = [[undefined, this.filteredChildren] as [SchemaHeaderField | undefined, Doc[]]]; - if (this.sectionFilter) { - const entries = Array.from(this.Sections.entries()); - sections = entries.sort(this.sortFunc); - } return ( <div className="collectionStackingMasonry-cont" > <div className={this.isStackingView ? "collectionStackingView" : "collectionMasonryView"} ref={this.createRef} + style={{ + transform: `scale(${Math.min(1, this.props.PanelHeight() / this.layoutDoc[HeightSym]())})`, + height: `${100 * 1 / Math.min(this.props.PanelWidth() / this.layoutDoc[WidthSym](), this.props.PanelHeight() / this.layoutDoc[HeightSym]())}%`, + transformOrigin: "top" + }} onScroll={action((e: React.UIEvent<HTMLDivElement>) => this._scroll = e.currentTarget.scrollTop)} onDrop={this.onDrop.bind(this)} onContextMenu={this.onContextMenu} - onWheel={(e: React.WheelEvent) => e.stopPropagation()} > - {sections.map(section => this.isStackingView ? this.sectionStacking(section[0], section[1]) : this.sectionMasonry(section[0], section[1]))} + onWheel={e => e.stopPropagation()} > + {this.renderedSections} {!this.showAddAGroup ? (null) : <div key={`${this.props.Document[Id]}-addGroup`} className="collectionStackingView-addGroupButton" style={{ width: !this.isStackingView ? "100%" : this.columnWidth / this.numGroupColumns - 10, marginTop: 10 }}> diff --git a/src/client/views/collections/CollectionStackingViewFieldColumn.tsx b/src/client/views/collections/CollectionStackingViewFieldColumn.tsx index 80dc482af..39b4e4e1d 100644 --- a/src/client/views/collections/CollectionStackingViewFieldColumn.tsx +++ b/src/client/views/collections/CollectionStackingViewFieldColumn.tsx @@ -17,6 +17,7 @@ import { anchorPoints, Flyout } from "../DocumentDecorations"; import { EditableView } from "../EditableView"; import { CollectionStackingView } from "./CollectionStackingView"; import "./CollectionStackingView.scss"; +import { TraceMobx } from "../../../new_fields/util"; library.add(faPalette); @@ -50,21 +51,21 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC this._dropRef = ele; this.dropDisposer && this.dropDisposer(); if (ele) { - this.dropDisposer = DragManager.MakeDropTarget(ele, { handlers: { drop: this.columnDrop.bind(this) } }); + this.dropDisposer = DragManager.MakeDropTarget(ele, this.columnDrop.bind(this)); } } @undoBatch columnDrop = action((e: Event, de: DragManager.DropEvent) => { this._createAliasSelected = false; - if (de.data instanceof DragManager.DocumentDragData) { + if (de.complete.docDragData) { const key = StrCast(this.props.parent.props.Document.sectionFilter); const castedValue = this.getValue(this._heading); if (castedValue) { - de.data.droppedDocuments.forEach(d => d[key] = castedValue); + de.complete.docDragData.droppedDocuments.forEach(d => d[key] = castedValue); } else { - de.data.droppedDocuments.forEach(d => d[key] = undefined); + de.complete.docDragData.droppedDocuments.forEach(d => d[key] = undefined); } this.props.parent.drop(e, de); e.stopPropagation(); @@ -252,13 +253,12 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC @observable private collapsed: boolean = false; - private toggleVisibility = action(() => { - this.collapsed = !this.collapsed; - }); + private toggleVisibility = action(() => this.collapsed = !this.collapsed); @observable _headingsHack: number = 1; render() { + TraceMobx(); const cols = this.props.cols(); const key = StrCast(this.props.parent.props.Document.sectionFilter); let templatecols = ""; diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index 0a2e27165..5753dd34e 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -27,7 +27,7 @@ import { Networking } from "../../Network"; export interface CollectionViewProps extends FieldViewProps { addDocument: (document: Doc) => boolean; removeDocument: (document: Doc) => boolean; - moveDocument: (document: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean) => boolean; + moveDocument: (document: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean) => boolean; PanelWidth: () => number; PanelHeight: () => number; VisibleHeight?: () => number; @@ -51,7 +51,7 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { protected createDropTarget = (ele: HTMLDivElement) => { //used for stacking and masonry view this.dropDisposer && this.dropDisposer(); if (ele) { - this.dropDisposer = DragManager.MakeDropTarget(ele, { handlers: { drop: this.drop.bind(this) } }); + this.dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this)); } } protected CreateDropTarget(ele: HTMLDivElement) { //used in schema view @@ -85,8 +85,10 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { return this.props.annotationsKey ? (this.extensionDoc ? this.extensionDoc[this.props.annotationsKey] : undefined) : this.dataDoc[this.props.fieldKey]; } - get childLayoutPairs() { - return this.childDocs.map(cd => Doc.GetLayoutDataDocPair(this.props.Document, this.props.DataDoc, this.props.fieldKey, cd)).filter(pair => pair.layout).map(pair => ({ layout: pair.layout!, data: pair.data! })); + get childLayoutPairs(): { layout: Doc; data: Doc; }[] { + const { Document, DataDoc, fieldKey } = this.props; + const validPairs = this.childDocs.map(doc => Doc.GetLayoutDataDocPair(Document, DataDoc, fieldKey, doc)).filter(pair => pair.layout); + return validPairs.map(({ data, layout }) => ({ data: data!, layout: layout! })); // this mapping is a bit of a hack to coerce types } get childDocList() { return Cast(this.dataField, listSpec(Doc)); @@ -132,32 +134,33 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { @undoBatch @action protected drop(e: Event, de: DragManager.DropEvent): boolean { + const docDragData = de.complete.docDragData; (this.props.Document.dropConverter instanceof ScriptField) && - this.props.Document.dropConverter.script.run({ dragData: de.data }); - if (de.data instanceof DragManager.DocumentDragData && !de.data.applyAsTemplate) { - if (de.mods === "AltKey" && de.data.draggedDocuments.length) { + this.props.Document.dropConverter.script.run({ dragData: docDragData }); /// bcz: check this + if (docDragData && !docDragData.applyAsTemplate) { + if (de.altKey && docDragData.draggedDocuments.length) { this.childDocs.map(doc => - Doc.ApplyTemplateTo(de.data.draggedDocuments[0], doc, "layoutFromParent")); + Doc.ApplyTemplateTo(docDragData.draggedDocuments[0], doc, "layoutFromParent")); e.stopPropagation(); return true; } let added = false; - if (de.data.dropAction || de.data.userDropAction) { - added = de.data.droppedDocuments.reduce((added: boolean, d) => this.props.addDocument(d) || added, false); - } else if (de.data.moveDocument) { - const movedDocs = de.data.draggedDocuments; + if (docDragData.dropAction || docDragData.userDropAction) { + added = docDragData.droppedDocuments.reduce((added: boolean, d) => this.props.addDocument(d) || added, false); + } else if (docDragData.moveDocument) { + const movedDocs = docDragData.draggedDocuments; added = movedDocs.reduce((added: boolean, d, i) => - de.data.droppedDocuments[i] !== d ? this.props.addDocument(de.data.droppedDocuments[i]) : - de.data.moveDocument(d, this.props.Document, this.props.addDocument) || added, false); + docDragData.droppedDocuments[i] !== d ? this.props.addDocument(docDragData.droppedDocuments[i]) : + docDragData.moveDocument?.(d, this.props.Document, this.props.addDocument) || added, false); } else { - added = de.data.droppedDocuments.reduce((added: boolean, d) => this.props.addDocument(d) || added, false); + added = docDragData.droppedDocuments.reduce((added: boolean, d) => this.props.addDocument(d) || added, false); } e.stopPropagation(); return added; } - else if (de.data instanceof DragManager.AnnotationDragData) { + else if (de.complete.annoDragData) { e.stopPropagation(); - return this.props.addDocument(de.data.dropDocument); + return this.props.addDocument(de.complete.annoDragData.dropDocument); } return false; } @@ -190,7 +193,7 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { } }); } else { - this.props.addDocument && this.props.addDocument(Docs.Create.WebDocument(href, options)); + this.props.addDocument && this.props.addDocument(Docs.Create.WebDocument(href, { ...options, title: href })); } } else if (text) { this.props.addDocument && this.props.addDocument(Docs.Create.TextDocument({ ...options, width: 100, height: 25, documentText: "@@@" + text })); @@ -261,7 +264,7 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { .then(result => { const type = result["content-type"]; if (type) { - Docs.Get.DocumentFromType(type, str, { ...options, width: 300, nativeWidth: type.indexOf("video") !== -1 ? 600 : 300 }) + Docs.Get.DocumentFromType(type, str, options) .then(doc => doc && this.props.addDocument(doc)); } }); @@ -280,7 +283,7 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { const dropFileName = file ? file.name : "-empty-"; promises.push(Networking.PostFormDataToServer("/upload", formData).then(results => { results.map(action(({ clientAccessPath }: any) => { - const full = { ...options, nativeWidth: type.indexOf("video") !== -1 ? 600 : 300, width: 300, title: dropFileName }; + const full = { ...options, width: 300, title: dropFileName }; const pathname = Utils.prepend(clientAccessPath); Docs.Get.DocumentFromType(type, pathname, full).then(doc => { doc && (Doc.GetProto(doc).fileUpload = basename(pathname).replace("upload_", "").replace(/\.[a-z0-9]*$/, "")); @@ -290,14 +293,13 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { })); } } - if (text && !text.includes("https://")) { - this.props.addDocument(Docs.Create.TextDocument({ ...options, documentText: "@@@" + text, width: 400, height: 315 })); - return; - } if (promises.length) { Promise.all(promises).finally(() => { completed && completed(); batch.end(); }); } else { + if (text && !text.includes("https://")) { + this.props.addDocument(Docs.Create.TextDocument({ ...options, documentText: "@@@" + text, width: 400, height: 315 })); + } batch.end(); } } diff --git a/src/client/views/collections/CollectionTreeView.scss b/src/client/views/collections/CollectionTreeView.scss index 8b12395a7..0b9dc2eb2 100644 --- a/src/client/views/collections/CollectionTreeView.scss +++ b/src/client/views/collections/CollectionTreeView.scss @@ -15,6 +15,7 @@ background: $light-color-secondary; font-size: 13px; overflow: auto; + user-select: none; cursor: default; ul { diff --git a/src/client/views/collections/CollectionTreeView.tsx b/src/client/views/collections/CollectionTreeView.tsx index 48ea35c6b..3356aed68 100644 --- a/src/client/views/collections/CollectionTreeView.tsx +++ b/src/client/views/collections/CollectionTreeView.tsx @@ -1,15 +1,15 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { faAngleRight, faArrowsAltH, faBell, faCamera, faCaretDown, faCaretRight, faCaretSquareDown, faCaretSquareRight, faExpand, faMinus, faPlus, faTrash, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { action, computed, observable } from "mobx"; +import { action, computed, observable, untracked, runInAction } from "mobx"; import { observer } from "mobx-react"; import { Doc, DocListCast, Field, HeightSym, WidthSym } from '../../../new_fields/Doc'; import { Id } from '../../../new_fields/FieldSymbols'; import { List } from '../../../new_fields/List'; import { Document, listSpec } from '../../../new_fields/Schema'; import { ComputedField, ScriptField } from '../../../new_fields/ScriptField'; -import { BoolCast, Cast, NumCast, StrCast } from '../../../new_fields/Types'; -import { emptyFunction, Utils, returnFalse } from '../../../Utils'; +import { BoolCast, Cast, NumCast, StrCast, ScriptCast } from '../../../new_fields/Types'; +import { emptyFunction, Utils, returnFalse, emptyPath } from '../../../Utils'; import { Docs, DocUtils } from '../../documents/Documents'; import { DocumentType } from "../../documents/DocumentTypes"; import { DocumentManager } from '../../util/DocumentManager'; @@ -28,11 +28,13 @@ import { CollectionSubView } from "./CollectionSubView"; import "./CollectionTreeView.scss"; import React = require("react"); import { CurrentUserUtils } from '../../../server/authentication/models/current_user_utils'; +import { ScriptBox } from '../ScriptBox'; export interface TreeViewProps { document: Doc; dataDoc?: Doc; + libraryPath: Doc[] | undefined; containingCollection: Doc; prevSibling?: Doc; renderDepth: number; @@ -40,7 +42,7 @@ export interface TreeViewProps { ruleProvider: Doc | undefined; moveDocument: DragManager.MoveFunction; dropAction: "alias" | "copy" | undefined; - addDocTab: (doc: Doc, dataDoc: Doc | undefined, where: string) => boolean; + addDocTab: (doc: Doc, dataDoc: Doc | undefined, where: string, libraryPath?: Doc[]) => boolean; pinToPres: (document: Doc) => void; panelWidth: () => number; panelHeight: () => number; @@ -56,6 +58,7 @@ export interface TreeViewProps { hideHeaderFields: () => boolean; preventTreeViewOpen: boolean; renderedIds: string[]; + onCheckedClick?: ScriptField; } library.add(faTrashAlt); @@ -89,14 +92,14 @@ class TreeView extends React.Component<TreeViewProps> { get defaultExpandedView() { return this.childDocs ? this.fieldKey : StrCast(this.props.document.defaultExpandedView, "fields"); } @observable _overrideTreeViewOpen = false; // override of the treeViewOpen field allowing the display state to be independent of the document's state - set treeViewOpen(c: boolean) { if (this.props.preventTreeViewOpen) this._overrideTreeViewOpen = c; else this.props.document.treeViewOpen = c; } - @computed get treeViewOpen() { return (this.props.document.treeViewOpen && !this.props.preventTreeViewOpen) || this._overrideTreeViewOpen; } + set treeViewOpen(c: boolean) { if (this.props.preventTreeViewOpen) this._overrideTreeViewOpen = c; else this.props.document.treeViewOpen = this._overrideTreeViewOpen = c; } + @computed get treeViewOpen() { return (!this.props.preventTreeViewOpen && BoolCast(this.props.document.treeViewOpen)) || this._overrideTreeViewOpen; } @computed get treeViewExpandedView() { return StrCast(this.props.document.treeViewExpandedView, this.defaultExpandedView); } @computed get MAX_EMBED_HEIGHT() { return NumCast(this.props.document.maxEmbedHeight, 300); } @computed get dataDoc() { return this.templateDataDoc ? this.templateDataDoc : this.props.document; } @computed get fieldKey() { - const splits = StrCast(Doc.LayoutField(this.props.document)).split("fieldKey={\""); - return splits.length > 1 ? splits[1].split("\"")[0] : "data"; + const splits = StrCast(Doc.LayoutField(this.props.document)).split("fieldKey={\'"); + return splits.length > 1 ? splits[1].split("\'")[0] : "data"; } childDocList(field: string) { const layout = Doc.LayoutField(this.props.document) instanceof Doc ? Doc.LayoutField(this.props.document) as Doc : undefined; @@ -120,9 +123,9 @@ class TreeView extends React.Component<TreeViewProps> { } @undoBatch delete = () => this.props.deleteDoc(this.props.document); - @undoBatch openRight = () => this.props.addDocTab(this.props.document, this.templateDataDoc, "onRight"); + @undoBatch openRight = () => this.props.addDocTab(this.props.document, this.templateDataDoc, "onRight", this.props.libraryPath); @undoBatch indent = () => this.props.addDocument(this.props.document) && this.delete(); - @undoBatch move = (doc: Doc, target: Doc, addDoc: (doc: Doc) => boolean) => { + @undoBatch move = (doc: Doc, target: Doc | undefined, addDoc: (doc: Doc) => boolean) => { return this.props.document !== target && this.props.deleteDoc(doc) && addDoc(doc); } @undoBatch @action remove = (document: Document, key: string) => { @@ -131,7 +134,7 @@ class TreeView extends React.Component<TreeViewProps> { protected createTreeDropTarget = (ele: HTMLDivElement) => { this._treedropDisposer && this._treedropDisposer(); - ele && (this._treedropDisposer = DragManager.MakeDropTarget(ele, { handlers: { drop: this.treeDrop.bind(this) } })); + ele && (this._treedropDisposer = DragManager.MakeDropTarget(ele, this.treeDrop.bind(this))); } onPointerDown = (e: React.PointerEvent) => e.stopPropagation(); @@ -194,14 +197,13 @@ class TreeView extends React.Component<TreeViewProps> { ContextMenu.Instance.addItem({ description: "Clear All", event: () => Doc.GetProto(CurrentUserUtils.UserDocument.recentlyClosed as Doc).data = new List<Doc>(), icon: "plus" }); } else if (this.props.document !== CurrentUserUtils.UserDocument.workspaces) { ContextMenu.Instance.addItem({ description: "Pin to Presentation", event: () => this.props.pinToPres(this.props.document), icon: "tv" }); - ContextMenu.Instance.addItem({ description: "Open Tab", event: () => this.props.addDocTab(this.props.document, this.templateDataDoc, "inTab"), icon: "folder" }); - ContextMenu.Instance.addItem({ description: "Open Right", event: () => this.props.addDocTab(this.props.document, this.templateDataDoc, "onRight"), icon: "caret-square-right" }); + ContextMenu.Instance.addItem({ description: "Open Tab", event: () => this.props.addDocTab(this.props.document, this.templateDataDoc, "inTab", this.props.libraryPath), icon: "folder" }); + ContextMenu.Instance.addItem({ description: "Open Right", event: () => this.props.addDocTab(this.props.document, this.templateDataDoc, "onRight", this.props.libraryPath), icon: "caret-square-right" }); if (DocumentManager.Instance.getDocumentViews(this.dataDoc).length) { - ContextMenu.Instance.addItem({ description: "Focus", event: () => (view => view && view.props.focus(this.props.document, true))(DocumentManager.Instance.getFirstDocumentView(this.dataDoc)), icon: "camera" }); + ContextMenu.Instance.addItem({ description: "Focus", event: () => (view => view && view.props.focus(this.props.document, true))(DocumentManager.Instance.getFirstDocumentView(this.props.document)), icon: "camera" }); } ContextMenu.Instance.addItem({ description: "Delete Item", event: () => this.props.deleteDoc(this.props.document), icon: "trash-alt" }); } else { - ContextMenu.Instance.addItem({ description: "Open as Workspace", event: () => MainView.Instance.openWorkspace(this.dataDoc), icon: "caret-square-right" }); ContextMenu.Instance.addItem({ description: "Delete Workspace", event: () => this.props.deleteDoc(this.props.document), icon: "trash-alt" }); ContextMenu.Instance.addItem({ description: "Create New Workspace", event: () => MainView.Instance.createNewWorkspace(), icon: "plus" }); } @@ -219,25 +221,25 @@ class TreeView extends React.Component<TreeViewProps> { const rect = this._header!.current!.getBoundingClientRect(); const before = pt[1] < rect.top + rect.height / 2; const inside = pt[0] > Math.min(rect.left + 75, rect.left + rect.width * .75) || (!before && this.treeViewOpen && DocListCast(this.dataDoc[this.fieldKey]).length); - if (de.data instanceof DragManager.LinkDragData) { - const sourceDoc = de.data.linkSourceDocument; + if (de.complete.linkDragData) { + const sourceDoc = de.complete.linkDragData.linkSourceDocument; const destDoc = this.props.document; DocUtils.MakeLink({ doc: sourceDoc }, { doc: destDoc }); e.stopPropagation(); } - if (de.data instanceof DragManager.DocumentDragData) { + if (de.complete.docDragData) { e.stopPropagation(); - if (de.data.draggedDocuments[0] === this.props.document) return true; + if (de.complete.docDragData.draggedDocuments[0] === this.props.document) return true; let addDoc = (doc: Doc) => this.props.addDocument(doc, undefined, before); if (inside) { addDoc = (doc: Doc) => Doc.AddDocToList(this.dataDoc, this.fieldKey, doc) || addDoc(doc); } - const movedDocs = (de.data.options === this.props.treeViewId ? de.data.draggedDocuments : de.data.droppedDocuments); - return ((de.data.dropAction && (de.data.options !== this.props.treeViewId)) || de.data.userDropAction) ? - de.data.droppedDocuments.reduce((added, d) => addDoc(d) || added, false) - : de.data.moveDocument ? - movedDocs.reduce((added, d) => de.data.moveDocument(d, undefined, addDoc) || added, false) - : de.data.droppedDocuments.reduce((added, d) => addDoc(d), false); + const movedDocs = (de.complete.docDragData.treeViewId === this.props.treeViewId ? de.complete.docDragData.draggedDocuments : de.complete.docDragData.droppedDocuments); + return ((de.complete.docDragData.dropAction && (de.complete.docDragData.treeViewId !== this.props.treeViewId)) || de.complete.docDragData.userDropAction) ? + de.complete.docDragData.droppedDocuments.reduce((added, d) => addDoc(d) || added, false) + : de.complete.docDragData.moveDocument ? + movedDocs.reduce((added, d) => de.complete.docDragData?.moveDocument?.(d, undefined, addDoc) || added, false) + : de.complete.docDragData.droppedDocuments.reduce((added, d) => addDoc(d), false); } return false; } @@ -279,14 +281,14 @@ class TreeView extends React.Component<TreeViewProps> { const contents = doc[key]; let contentElement: (JSX.Element | null)[] | JSX.Element = []; - if (contents instanceof Doc || Cast(contents, listSpec(Doc))) { + if (contents instanceof Doc || (Cast(contents, listSpec(Doc)) && (Cast(contents, listSpec(Doc))!.length && Cast(contents, listSpec(Doc))![0] instanceof Doc))) { const remDoc = (doc: Doc) => this.remove(doc, key); const addDoc = (doc: Doc, addBefore?: Doc, before?: boolean) => Doc.AddDocToList(this.dataDoc, key, doc, addBefore, before, false, true); contentElement = TreeView.GetChildElements(contents instanceof Doc ? [contents] : DocListCast(contents), this.props.treeViewId, doc, undefined, key, this.props.containingCollection, this.props.prevSibling, addDoc, remDoc, this.move, this.props.dropAction, this.props.addDocTab, this.props.pinToPres, this.props.ScreenToLocalTransform, this.props.outerXf, this.props.active, this.props.panelWidth, this.props.ChromeHeight, this.props.renderDepth, this.props.hideHeaderFields, this.props.preventTreeViewOpen, - [...this.props.renderedIds, doc[Id]]); + [...this.props.renderedIds, doc[Id]], this.props.libraryPath, this.props.onCheckedClick); } else { contentElement = <EditableView key="editableView" @@ -294,7 +296,7 @@ class TreeView extends React.Component<TreeViewProps> { height={13} fontSize={12} GetValue={() => Field.toKeyValueString(doc, key)} - SetValue={(value: string) => KeyValueBox.SetField(doc, key, value)} />; + SetValue={(value: string) => KeyValueBox.SetField(doc, key, value, true)} />; } rows.push(<div style={{ display: "flex" }} key={key}> <span style={{ fontWeight: "bold" }}>{key + ":"}</span> @@ -302,6 +304,18 @@ class TreeView extends React.Component<TreeViewProps> { {contentElement} </div>); } + rows.push(<div style={{ display: "flex" }} key={"newKeyValue"}> + <EditableView + key="editableView" + contents={"+key:value"} + height={13} + fontSize={12} + GetValue={() => ""} + SetValue={(value: string) => { + value.indexOf(":") !== -1 && KeyValueBox.SetField(doc, value.substring(0, value.indexOf(":")), value.substring(value.indexOf(":") + 1, value.length), true); + return true; + }} /> + </div>); return rows; } @@ -319,7 +333,7 @@ class TreeView extends React.Component<TreeViewProps> { this.templateDataDoc, expandKey, this.props.containingCollection, this.props.prevSibling, addDoc, remDoc, this.move, this.props.dropAction, this.props.addDocTab, this.props.pinToPres, this.props.ScreenToLocalTransform, this.props.outerXf, this.props.active, this.props.panelWidth, this.props.ChromeHeight, this.props.renderDepth, this.props.hideHeaderFields, this.props.preventTreeViewOpen, - [...this.props.renderedIds, this.props.document[Id]])} + [...this.props.renderedIds, this.props.document[Id]], this.props.libraryPath, this.props.onCheckedClick)} </ul >; } else if (this.treeViewExpandedView === "fields") { return <ul><div ref={this._dref} style={{ display: "inline-block" }} key={this.props.document[Id] + this.props.document.title}> @@ -331,7 +345,8 @@ class TreeView extends React.Component<TreeViewProps> { <ContentFittingDocumentView Document={layoutDoc} DataDocument={this.templateDataDoc} - renderDepth={this.props.renderDepth} + LibraryPath={emptyPath} + renderDepth={this.props.renderDepth + 1} showOverlays={this.noOverlays} ruleProvider={this.props.document.isRuleProvider && layoutDoc.type !== DocumentType.TEXT ? this.props.document : this.props.ruleProvider} fitToBox={this.boundsOfCollectionDocument !== undefined} @@ -346,16 +361,31 @@ class TreeView extends React.Component<TreeViewProps> { active={this.props.active} whenActiveChanged={emptyFunction} addDocTab={this.props.addDocTab} - pinToPres={this.props.pinToPres} - setPreviewScript={emptyFunction} /> + pinToPres={this.props.pinToPres} /> </div>; } } + @action + bulletClick = (e: React.MouseEvent) => { + if (this.props.onCheckedClick) { + this.props.document.treeViewChecked = this.props.document.treeViewChecked === "check" ? "x" : this.props.document.treeViewChecked === "x" ? undefined : "check"; + ScriptCast(this.props.onCheckedClick).script.run({ + this: this.props.document.isTemplateField && this.props.dataDoc ? this.props.dataDoc : this.props.document, + heading: this.props.containingCollection.title, + checked: this.props.document.treeViewChecked === "check" ? false : this.props.document.treeViewChecked === "x" ? "x" : "none" + }, console.log); + } else { + this.treeViewOpen = !this.treeViewOpen; + } + e.stopPropagation(); + } + @computed get renderBullet() { - return <div className="bullet" title="view inline" onClick={action((e: React.MouseEvent) => { this.treeViewOpen = !this.treeViewOpen; e.stopPropagation(); })} style={{ color: StrCast(this.props.document.color, "black"), opacity: 0.4 }}> - {<FontAwesomeIcon icon={!this.treeViewOpen ? (this.childDocs ? "caret-square-right" : "caret-right") : (this.childDocs ? "caret-square-down" : "caret-down")} />} + const checked = this.props.onCheckedClick ? (this.props.document.treeViewChecked ? this.props.document.treeViewChecked : "unchecked") : undefined; + return <div className="bullet" title="view inline" onClick={this.bulletClick} style={{ color: StrCast(this.props.document.color, checked === "unchecked" ? "white" : "black"), opacity: 0.4 }}> + {<FontAwesomeIcon icon={checked === "check" ? "check" : (checked === "x" ? "times" : checked === "unchecked" ? "square" : !this.treeViewOpen ? (this.childDocs ? "caret-square-right" : "caret-right") : (this.childDocs ? "caret-square-down" : "caret-down"))} />} </div>; } /** @@ -386,8 +416,8 @@ class TreeView extends React.Component<TreeViewProps> { <div className="docContainer" title="click to edit title" id={`docContainer-${this.props.parentKey}`} ref={reference} onPointerDown={onItemDown} style={{ color: this.props.document.isMinimized ? "red" : "black", - background: Doc.IsBrushed(this.props.document) ? "#06121212" : "0", - fontWeight: this.props.document.search_string ? "bold" : undefined, + background: Doc.IsHighlighted(this.props.document) ? "orange" : Doc.IsBrushed(this.props.document) ? "#06121212" : "0", + fontWeight: this.props.document.searchMatch ? "bold" : undefined, outline: BoolCast(this.props.document.workspaceBrush) ? "dashed 1px #06123232" : undefined, pointerEvents: this.props.active() || SelectionManager.GetIsDragging() ? "all" : "none" }} > @@ -399,6 +429,7 @@ class TreeView extends React.Component<TreeViewProps> { } render() { + setTimeout(() => runInAction(() => untracked(() => this._overrideTreeViewOpen = this.treeViewOpen)), 0); return <div className="treeViewItem-container" ref={this.createTreeDropTarget} onContextMenu={this.onWorkspaceContextMenu}> <li className="collection-child"> <div className="treeViewItem-header" ref={this._header} onPointerEnter={this.onPointerEnter} onPointerLeave={this.onPointerLeave}> @@ -433,7 +464,9 @@ class TreeView extends React.Component<TreeViewProps> { renderDepth: number, hideHeaderFields: () => boolean, preventTreeViewOpen: boolean, - renderedIds: string[] + renderedIds: string[], + libraryPath: Doc[] | undefined, + onCheckedClick: ScriptField | undefined ) { const viewSpecScript = Cast(containingCollection.viewSpecScript, ScriptField); if (viewSpecScript) { @@ -487,9 +520,9 @@ class TreeView extends React.Component<TreeViewProps> { } const indent = i === 0 ? undefined : () => { - if (StrCast(docs[i - 1].layout).indexOf("fieldKey") !== -1) { - const fieldKeysub = StrCast(docs[i - 1].layout).split("fieldKey")[1]; - const fieldKey = fieldKeysub.split("\"")[1]; + if (StrCast(docs[i - 1].layout).indexOf('fieldKey') !== -1) { + const fieldKeysub = StrCast(docs[i - 1].layout).split('fieldKey')[1]; + const fieldKey = fieldKeysub.split("\'")[1]; if (fieldKey && Cast(docs[i - 1][fieldKey], listSpec(Doc)) !== undefined) { Doc.AddDocToList(docs[i - 1], fieldKey, child); docs[i - 1].treeViewOpen = true; @@ -498,9 +531,9 @@ class TreeView extends React.Component<TreeViewProps> { } }; const outdent = !parentCollectionDoc ? undefined : () => { - if (StrCast(parentCollectionDoc.layout).indexOf("fieldKey") !== -1) { - const fieldKeysub = StrCast(parentCollectionDoc.layout).split("fieldKey")[1]; - const fieldKey = fieldKeysub.split("\"")[1]; + if (StrCast(parentCollectionDoc.layout).indexOf('fieldKey') !== -1) { + const fieldKeysub = StrCast(parentCollectionDoc.layout).split('fieldKey')[1]; + const fieldKey = fieldKeysub.split("\'")[1]; Doc.AddDocToList(parentCollectionDoc, fieldKey, child, parentPrevSibling, false); parentCollectionDoc.treeViewOpen = true; remove(child); @@ -517,6 +550,7 @@ class TreeView extends React.Component<TreeViewProps> { return !(child instanceof Doc) ? (null) : <TreeView document={pair.layout} dataDoc={pair.data} + libraryPath={libraryPath ? [...libraryPath, containingCollection] : undefined} containingCollection={containingCollection} prevSibling={docs[i]} treeViewId={treeViewId} @@ -524,6 +558,7 @@ class TreeView extends React.Component<TreeViewProps> { key={child[Id]} indentDocument={indent} outdentDocument={outdent} + onCheckedClick={onCheckedClick} renderDepth={renderDepth} deleteDoc={remove} addDocument={addDocument} @@ -555,7 +590,7 @@ export class CollectionTreeView extends CollectionSubView(Document) { protected createTreeDropTarget = (ele: HTMLDivElement) => { this.treedropDisposer && this.treedropDisposer(); if (this._mainEle = ele) { - this.treedropDisposer = DragManager.MakeDropTarget(ele, { handlers: { drop: this.drop.bind(this) } }); + this.treedropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this)); } } @@ -592,6 +627,10 @@ export class CollectionTreeView extends CollectionSubView(Document) { layoutItems.push({ description: (this.props.Document.hideHeaderFields ? "Show" : "Hide") + " Header Fields", event: () => this.props.Document.hideHeaderFields = !this.props.Document.hideHeaderFields, icon: "paint-brush" }); ContextMenu.Instance.addItem({ description: "Treeview Options ...", subitems: layoutItems, icon: "eye" }); } + const existingOnClick = ContextMenu.Instance.findByDescription("OnClick..."); + const onClicks: ContextMenuProps[] = existingOnClick && "subitems" in existingOnClick ? existingOnClick.subitems : []; + onClicks.push({ description: "Edit onChecked Script", icon: "edit", event: (obj: any) => ScriptBox.EditButtonScript("On Checked Changed ...", this.props.Document, "onCheckedClick", obj.x, obj.y, { heading: "boolean", checked: "boolean" }) }); + !existingOnClick && ContextMenu.Instance.addItem({ description: "OnClick...", subitems: onClicks, icon: "hand-point-right" }); } outerXf = () => Utils.GetScreenTransform(this._mainEle!); onTreeDrop = (e: React.DragEvent) => this.onDrop(e, {}); @@ -608,10 +647,10 @@ export class CollectionTreeView extends CollectionSubView(Document) { render() { const dropAction = StrCast(this.props.Document.dropAction) as dropActionType; const addDoc = (doc: Doc, relativeTo?: Doc, before?: boolean) => Doc.AddDocToList(this.props.Document, this.props.fieldKey, doc, relativeTo, before, false, false, false); - const moveDoc = (d: Doc, target: Doc, addDoc: (doc: Doc) => boolean) => this.props.moveDocument(d, target, addDoc); + const moveDoc = (d: Doc, target: Doc | undefined, addDoc: (doc: Doc) => boolean) => this.props.moveDocument(d, target, addDoc); return !this.childDocs ? (null) : ( - <div id="body" className="collectionTreeView-dropTarget" - style={{ overflow: "auto", background: StrCast(this.props.Document.backgroundColor, "lightgray"), paddingTop: `${NumCast(this.props.Document.yMargin, 20)}px` }} + <div className="collectionTreeView-dropTarget" id="body" + style={{ background: StrCast(this.props.Document.backgroundColor, "lightgray"), paddingTop: `${NumCast(this.props.Document.yMargin, 20)}px` }} onContextMenu={this.onContextMenu} onWheel={(e: React.WheelEvent) => this._mainEle && this._mainEle.scrollHeight > this._mainEle.clientHeight && e.stopPropagation()} onDrop={this.onTreeDrop} @@ -636,7 +675,7 @@ export class CollectionTreeView extends CollectionSubView(Document) { TreeView.GetChildElements(this.childDocs, this.props.Document[Id], this.props.Document, this.props.DataDoc, this.props.fieldKey, this.props.ContainingCollectionDoc, undefined, addDoc, this.remove, moveDoc, dropAction, this.props.addDocTab, this.props.pinToPres, this.props.ScreenToLocalTransform, this.outerXf, this.props.active, this.props.PanelWidth, this.props.ChromeHeight, this.props.renderDepth, () => BoolCast(this.props.Document.hideHeaderFields), - BoolCast(this.props.Document.preventTreeViewOpen), []) + BoolCast(this.props.Document.preventTreeViewOpen), [], this.props.LibraryPath, ScriptCast(this.props.Document.onCheckedClick)) } </ul> </div > diff --git a/src/client/views/collections/CollectionView.scss b/src/client/views/collections/CollectionView.scss index e4187e4d6..1c46081a1 100644 --- a/src/client/views/collections/CollectionView.scss +++ b/src/client/views/collections/CollectionView.scss @@ -9,7 +9,7 @@ border-radius: inherit; width: 100%; height: 100%; - overflow: auto; + overflow: hidden; // bcz: used to be 'auto' which would create scrollbars when there's a floating doc that's not visible. not sure if that's better, but the scrollbars are annoying... } #google-tags { diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index 54f5a2c42..21371dd39 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -32,6 +32,11 @@ import { SelectionManager } from '../../util/SelectionManager'; import './CollectionView.scss'; import { FieldViewProps, FieldView } from '../nodes/FieldView'; import { Touchable } from '../Touchable'; +import { TraceMobx } from '../../../new_fields/util'; +import { Utils } from '../../../Utils'; +import { ScriptBox } from '../ScriptBox'; +import CollectionMulticolumnView from '../CollectionMulticolumnView'; +const path = require('path'); library.add(faTh, faTree, faSquare, faProjectDiagram, faSignature, faThList, faFingerprint, faColumns, faEllipsisV, faImage, faEye as any, faCopy); export enum CollectionViewType { @@ -44,7 +49,8 @@ export enum CollectionViewType { Masonry, Pivot, Linear, - Staff + Staff, + Multicolumn } export namespace CollectionViewType { @@ -57,7 +63,8 @@ export namespace CollectionViewType { ["stacking", CollectionViewType.Stacking], ["masonry", CollectionViewType.Masonry], ["pivot", CollectionViewType.Pivot], - ["linear", CollectionViewType.Linear] + ["linear", CollectionViewType.Linear], + ["multicolumn", CollectionViewType.Multicolumn] ]); export const valueOf = (value: string) => stringMapping.get(value.toLowerCase()); @@ -66,7 +73,7 @@ export namespace CollectionViewType { export interface CollectionRenderProps { addDocument: (document: Doc) => boolean; removeDocument: (document: Doc) => boolean; - moveDocument: (document: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean) => boolean; + moveDocument: (document: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean) => boolean; active: () => boolean; whenActiveChanged: (isActive: boolean) => void; } @@ -148,7 +155,7 @@ export class CollectionView extends Touchable<FieldViewProps> { // otherwise, the document being moved must be able to be removed from its container before // moving it into the target. @action.bound - moveDocument(doc: Doc, targetCollection: Doc, addDocument: (doc: Doc) => boolean): boolean { + moveDocument(doc: Doc, targetCollection: Doc | undefined, addDocument: (doc: Doc) => boolean): boolean { if (Doc.AreProtosEqual(this.props.Document, targetCollection)) { return true; } @@ -169,6 +176,7 @@ export class CollectionView extends Touchable<FieldViewProps> { case CollectionViewType.Docking: return (<CollectionDockingView key="collview" {...props} />); case CollectionViewType.Tree: return (<CollectionTreeView key="collview" {...props} />); case CollectionViewType.Staff: return (<CollectionStaffView chromeCollapsed={true} key="collview" {...props} ChromeHeight={this.chromeHeight} CollectionView={this} />); + case CollectionViewType.Multicolumn: return (<CollectionMulticolumnView chromeCollapsed={true} key="collview" {...props} ChromeHeight={this.chromeHeight} CollectionView={this} />); case CollectionViewType.Linear: { return (<CollectionLinearView key="collview" {...props} />); } case CollectionViewType.Stacking: { this.props.Document.singleColumn = true; return (<CollectionStackingView key="collview" {...props} />); } case CollectionViewType.Masonry: { this.props.Document.singleColumn = false; return (<CollectionStackingView key="collview" {...props} />); } @@ -210,6 +218,7 @@ export class CollectionView extends Touchable<FieldViewProps> { }, icon: "ellipsis-v" }); subItems.push({ description: "Staff", event: () => this.props.Document.viewType = CollectionViewType.Staff, icon: "music" }); + subItems.push({ description: "Multicolumn", event: () => this.props.Document.viewType = CollectionViewType.Multicolumn, icon: "columns" }); subItems.push({ description: "Masonry", event: () => this.props.Document.viewType = CollectionViewType.Masonry, icon: "columns" }); subItems.push({ description: "Pivot", event: () => this.props.Document.viewType = CollectionViewType.Pivot, icon: "columns" }); switch (this.props.Document.viewType) { @@ -230,19 +239,32 @@ export class CollectionView extends Touchable<FieldViewProps> { const moreItems = more && "subitems" in more ? more.subitems : []; moreItems.push({ description: "Export Image Hierarchy", icon: "columns", event: () => ImageUtils.ExportHierarchyToFileSystem(this.props.Document) }); !more && ContextMenu.Instance.addItem({ description: "More...", subitems: moreItems, icon: "hand-point-right" }); + + const existingOnClick = ContextMenu.Instance.findByDescription("OnClick..."); + const onClicks = existingOnClick && "subitems" in existingOnClick ? existingOnClick.subitems : []; + onClicks.push({ description: "Edit onChildClick script", icon: "edit", event: (obj: any) => ScriptBox.EditButtonScript("On Child Clicked...", this.props.Document, "onChildClick", obj.x, obj.y) }); + !existingOnClick && ContextMenu.Instance.addItem({ description: "OnClick...", subitems: onClicks, icon: "hand-point-right" }); } } lightbox = (images: string[]) => { + if (!images.length) return (null); + const mainPath = path.extname(images[this._curLightboxImg]); + const nextPath = path.extname(images[(this._curLightboxImg + 1) % images.length]); + const prevPath = path.extname(images[(this._curLightboxImg + images.length - 1) % images.length]); + const main = images[this._curLightboxImg].replace(mainPath, "_o" + mainPath); + const next = images[(this._curLightboxImg + 1) % images.length].replace(nextPath, "_o" + nextPath); + const prev = images[(this._curLightboxImg + images.length - 1) % images.length].replace(prevPath, "_o" + prevPath); return !this._isLightboxOpen ? (null) : (<Lightbox key="lightbox" - mainSrc={images[this._curLightboxImg]} - nextSrc={images[(this._curLightboxImg + 1) % images.length]} - prevSrc={images[(this._curLightboxImg + images.length - 1) % images.length]} + mainSrc={main} + nextSrc={next} + prevSrc={prev} onCloseRequest={action(() => this._isLightboxOpen = false)} onMovePrevRequest={action(() => this._curLightboxImg = (this._curLightboxImg + images.length - 1) % images.length)} onMoveNextRequest={action(() => this._curLightboxImg = (this._curLightboxImg + 1) % images.length)} />); } render() { + TraceMobx(); const props: CollectionRenderProps = { addDocument: this.addDocument, removeDocument: this.removeDocument, @@ -258,7 +280,12 @@ export class CollectionView extends Touchable<FieldViewProps> { onContextMenu={this.onContextMenu}> {this.showIsTagged()} {this.collectionViewType !== undefined ? this.SubView(this.collectionViewType, props) : (null)} - {this.lightbox(DocListCast(this.props.Document[this.props.fieldKey]).filter(d => d.type === DocumentType.IMG).map(d => Cast(d.data, ImageField) ? Cast(d.data, ImageField)!.url.href : ""))} + {this.lightbox(DocListCast(this.props.Document[this.props.fieldKey]).filter(d => d.type === DocumentType.IMG).map(d => + Cast(d.data, ImageField) ? + (Cast(d.data, ImageField)!.url.href.indexOf(window.location.origin) === -1) ? + Utils.CorsProxy(Cast(d.data, ImageField)!.url.href) : Cast(d.data, ImageField)!.url.href + : + ""))} </div>); } }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionViewChromes.tsx b/src/client/views/collections/CollectionViewChromes.tsx index 4161e5d6e..996c7671e 100644 --- a/src/client/views/collections/CollectionViewChromes.tsx +++ b/src/client/views/collections/CollectionViewChromes.tsx @@ -217,7 +217,12 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewChro `(${keyRestrictionScript}) ${dateRestrictionScript.length ? "&&" : ""} ${dateRestrictionScript}` : "true"; - this.props.CollectionView.props.Document.viewSpecScript = ScriptField.MakeFunction(fullScript, { doc: Doc.name }); + const docFilter = Cast(this.props.CollectionView.props.Document.docFilter, listSpec("string"), []); + const docFilterText = Doc.MakeDocFilter(docFilter); + const finalScript = docFilterText && !fullScript.startsWith("(())") ? `${fullScript} ${docFilterText ? "&&" : ""} (${docFilterText})` : + docFilterText ? docFilterText : fullScript; + + this.props.CollectionView.props.Document.viewSpecScript = ScriptField.MakeFunction(finalScript, { doc: Doc.name }); } @action @@ -288,15 +293,15 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewChro protected createDropTarget = (ele: HTMLDivElement) => { this.dropDisposer && this.dropDisposer(); if (ele) { - this.dropDisposer = DragManager.MakeDropTarget(ele, { handlers: { drop: this.drop.bind(this) } }); + this.dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this)); } } @undoBatch @action protected drop(e: Event, de: DragManager.DropEvent): boolean { - if (de.data instanceof DragManager.DocumentDragData && de.data.draggedDocuments.length) { - this._buttonizableCommands.filter(c => c.title === this._currentKey).map(c => c.immediate(de.data.draggedDocuments)); + if (de.complete.docDragData && de.complete.docDragData.draggedDocuments.length) { + this._buttonizableCommands.filter(c => c.title === this._currentKey).map(c => c.immediate(de.complete.docDragData?.draggedDocuments || [])); e.stopPropagation(); } return true; diff --git a/src/client/views/collections/ParentDocumentSelector.scss b/src/client/views/collections/ParentDocumentSelector.scss index aa25a900c..d293bb5ca 100644 --- a/src/client/views/collections/ParentDocumentSelector.scss +++ b/src/client/views/collections/ParentDocumentSelector.scss @@ -1,14 +1,25 @@ -.PDS-flyout { - position: absolute; +.parentDocumentSelector-linkFlyout { + div { + overflow: visible !important; + } + .metadataEntry-outerDiv { + overflow: hidden !important; + pointer-events: all; + } +} +.parentDocumentSelector-flyout { + position: relative; z-index: 9999; background-color: #eeeeee; box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2); - min-width: 150px; color: black; - top: 12px; padding: 10px; border-radius: 3px; + display: inline-block; + height: 100%; + width: 100%; + border-radius: 3px; hr { height: 1px; @@ -21,7 +32,11 @@ } } .parentDocumentSelector-button { - pointer-events: all; + pointer-events: all; + position: relative; + display: inline-block; + padding-left: 5px; + padding-right: 5px; } .parentDocumentSelector-metadata { pointer-events: auto; @@ -30,6 +45,9 @@ display: inline-block; } .buttonSelector { + div { + overflow: visible !important; + } position: absolute; display: inline-block; padding-left: 5px; diff --git a/src/client/views/collections/ParentDocumentSelector.tsx b/src/client/views/collections/ParentDocumentSelector.tsx index 75ee8de32..24aa6ddfa 100644 --- a/src/client/views/collections/ParentDocumentSelector.tsx +++ b/src/client/views/collections/ParentDocumentSelector.tsx @@ -11,7 +11,7 @@ import { CollectionViewType } from "./CollectionView"; import { DocumentButtonBar } from "../DocumentButtonBar"; import { DocumentManager } from "../../util/DocumentManager"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faEdit } from "@fortawesome/free-solid-svg-icons"; +import { faEdit, faChevronCircleUp } from "@fortawesome/free-solid-svg-icons"; import { library } from "@fortawesome/fontawesome-svg-core"; import { MetadataEntryMenu } from "../MetadataEntryMenu"; import { DocumentView } from "../nodes/DocumentView"; @@ -80,35 +80,20 @@ export class SelectorContextMenu extends React.Component<SelectorProps> { @observer export class ParentDocSelector extends React.Component<SelectorProps> { - @observable hover = false; - - @action - onMouseLeave = () => { - this.hover = false; - } - - @action - onMouseEnter = () => { - this.hover = true; - } - render() { - let flyout; - if (this.hover) { - flyout = ( - <div className="PDS-flyout" title=" "> - <SelectorContextMenu {...this.props} /> - </div> - ); - } - return ( - <span className="parentDocumentSelector-button" style={{ position: "relative", display: "inline-block", paddingLeft: "5px", paddingRight: "5px" }} - onMouseEnter={this.onMouseEnter} - onMouseLeave={this.onMouseLeave}> - <p>^</p> - {flyout} - </span> + const flyout = ( + <div className="parentDocumentSelector-flyout" style={{}} title=" "> + <SelectorContextMenu {...this.props} /> + </div> ); + return <div title="Tap to View Contexts/Metadata" onPointerDown={e => e.stopPropagation()} className="parentDocumentSelector-linkFlyout"> + <Flyout anchorPoint={anchorPoints.LEFT_TOP} + content={flyout}> + <span className="parentDocumentSelector-button" > + <FontAwesomeIcon icon={faChevronCircleUp} size={"lg"} /> + </span> + </Flyout> + </div>; } } @@ -117,32 +102,31 @@ export class ButtonSelector extends React.Component<{ Document: Doc, Stack: any @observable hover = false; @action - onMouseLeave = () => { - this.hover = false; + onPointerDown = (e: React.PointerEvent) => { + this.hover = !this.hover; + e.stopPropagation(); } - - @action - onMouseEnter = () => { - this.hover = true; + customStylesheet(styles: any) { + return { + ...styles, + panel: { + ...styles.panel, + minWidth: "100px" + }, + }; } render() { - let flyout; - if (this.hover) { - const view = DocumentManager.Instance.getDocumentView(this.props.Document); - flyout = !view ? (null) : ( - <div className="PDS-flyout" title=" " onMouseLeave={this.onMouseLeave}> - <DocumentButtonBar views={[view]} stack={this.props.Stack} /> - </div> - ); - } - return ( - <span className="buttonSelector" - onMouseEnter={this.onMouseEnter} - onMouseLeave={this.onMouseLeave}> - {this.hover ? (null) : <FontAwesomeIcon icon={faEdit} size={"sm"} />} - {flyout} - </span> + const view = DocumentManager.Instance.getDocumentView(this.props.Document); + const flyout = ( + <div className="ParentDocumentSelector-flyout" title=" "> + <DocumentButtonBar views={[view]} stack={this.props.Stack} /> + </div> ); + return <span title="Tap for menu" onPointerDown={e => e.stopPropagation()} className="buttonSelector"> + <Flyout anchorPoint={anchorPoints.LEFT_TOP} content={flyout} stylesheet={this.customStylesheet}> + <FontAwesomeIcon icon={faEdit} size={"sm"} /> + </Flyout> + </span>; } } diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx index 012115b1f..8c8da63cc 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx @@ -44,7 +44,7 @@ function toLabel(target: FieldResult<Field>) { return String(target); } -export function computePivotLayout(poolData: ObservableMap<string, any>, pivotDoc: Doc, childDocs: Doc[], childPairs: { layout: Doc, data?: Doc }[], viewDefsToJSX: (views: any) => ViewDefResult[]) { +export function computePivotLayout(poolData: ObservableMap<string, any>, pivotDoc: Doc, childDocs: Doc[], childPairs: { layout: Doc, data?: Doc }[], panelDim: number[], viewDefsToJSX: (views: any) => ViewDefResult[]) { const pivotAxisWidth = NumCast(pivotDoc.pivotWidth, 200); const pivotColumnGroups = new Map<FieldResult<Field>, Doc[]>(); @@ -57,9 +57,14 @@ export function computePivotLayout(poolData: ObservableMap<string, any>, pivotDo } const minSize = Array.from(pivotColumnGroups.entries()).reduce((min, pair) => Math.min(min, pair[1].length), Infinity); - const numCols = NumCast(pivotDoc.pivotNumColumns, Math.ceil(Math.sqrt(minSize))); + let numCols = NumCast(pivotDoc.pivotNumColumns, Math.ceil(Math.sqrt(minSize))); const docMap = new Map<Doc, ViewDefBounds>(); const groupNames: PivotData[] = []; + if (panelDim[0] < 2500) numCols = Math.min(5, numCols); + if (panelDim[0] < 2000) numCols = Math.min(4, numCols); + if (panelDim[0] < 1400) numCols = Math.min(3, numCols); + if (panelDim[0] < 1000) numCols = Math.min(2, numCols); + if (panelDim[0] < 600) numCols = 1; const expander = 1.05; const gap = .15; @@ -73,7 +78,7 @@ export function computePivotLayout(poolData: ObservableMap<string, any>, pivotDo x, y: pivotAxisWidth + 50, width: pivotAxisWidth * expander * numCols, - height: 100, + height: NumCast(pivotDoc.pivotFontSize, 10), fontSize: NumCast(pivotDoc.pivotFontSize, 10) }); for (const doc of val) { @@ -85,14 +90,14 @@ export function computePivotLayout(poolData: ObservableMap<string, any>, pivotDo wid = layoutDoc.nativeHeight ? (NumCast(layoutDoc.nativeWidth) / NumCast(layoutDoc.nativeHeight)) * pivotAxisWidth : pivotAxisWidth; } docMap.set(doc, { - x: x + xCount * pivotAxisWidth * expander + (pivotAxisWidth - wid) / 2, + x: x + xCount * pivotAxisWidth * expander + (pivotAxisWidth - wid) / 2 + (val.length < numCols ? (numCols - val.length) * pivotAxisWidth / 2 : 0), y: -y, width: wid, height: hgt }); xCount++; if (xCount >= numCols) { - xCount = (pivotAxisWidth - wid) / 2; + xCount = 0; y += pivotAxisWidth * expander; } } diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx index 5e4b4fd27..b8fbaef5c 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx @@ -8,6 +8,7 @@ import v5 = require("uuid/v5"); import { DocumentType } from "../../../documents/DocumentTypes"; import { observable, action, reaction, IReactionDisposer } from "mobx"; import { StrCast } from "../../../../new_fields/Types"; +import { Id } from "../../../../new_fields/FieldSymbols"; export interface CollectionFreeFormLinkViewProps { A: DocumentView; @@ -21,7 +22,7 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo _anchorDisposer: IReactionDisposer | undefined; @action componentDidMount() { - this._anchorDisposer = reaction(() => [this.props.A.props.ScreenToLocalTransform(), this.props.B.props.ScreenToLocalTransform()], + this._anchorDisposer = reaction(() => [this.props.A.props.ScreenToLocalTransform(), this.props.B.props.ScreenToLocalTransform(), this.props.A.isSelected() || Doc.IsBrushed(this.props.A.props.Document), this.props.A.isSelected() || Doc.IsBrushed(this.props.A.props.Document)], action(() => { setTimeout(action(() => this._opacity = 1), 0); // since the render code depends on querying the Dom through getBoudndingClientRect, we need to delay triggering render() setTimeout(action(() => this._opacity = 0.05), 750); // this will unhighlight the link line. @@ -41,10 +42,36 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo apt.point.x, apt.point.y); const afield = StrCast(this.props.A.props.Document[StrCast(this.props.A.props.layoutKey, "layout")]).indexOf("anchor1") === -1 ? "anchor2" : "anchor1"; const bfield = afield === "anchor1" ? "anchor2" : "anchor1"; - this.props.A.props.Document[afield + "_x"] = (apt.point.x - abounds.left) / abounds.width * 100; - this.props.A.props.Document[afield + "_y"] = (apt.point.y - abounds.top) / abounds.height * 100; - this.props.A.props.Document[bfield + "_x"] = (bpt.point.x - bbounds.left) / bbounds.width * 100; - this.props.A.props.Document[bfield + "_y"] = (bpt.point.y - bbounds.top) / bbounds.height * 100; + + // really hacky stuff to make the DocuLinkBox display where we want it to: + // if there's an element in the DOM with the id of the opposite anchor, then that DOM element is a hyperlink source for the current anchor and we want to place our link box at it's top right + // otherwise, we just use the computed nearest point on the document boundary to the target Document + const targetAhyperlink = window.document.getElementById((this.props.LinkDocs[0][afield] as Doc)[Id]); + const targetBhyperlink = window.document.getElementById((this.props.LinkDocs[0][bfield] as Doc)[Id]); + if (!targetBhyperlink) { + this.props.A.props.Document[afield + "_x"] = (apt.point.x - abounds.left) / abounds.width * 100; + this.props.A.props.Document[afield + "_y"] = (apt.point.y - abounds.top) / abounds.height * 100; + } else { + setTimeout(() => { + (this.props.A.props.Document[(this.props.A.props as any).fieldKey] as Doc); + const m = targetBhyperlink.getBoundingClientRect(); + const mp = this.props.A.props.ScreenToLocalTransform().transformPoint(m.right, m.top + 5); + this.props.A.props.Document[afield + "_x"] = mp[0] / this.props.A.props.PanelWidth() * 100; + this.props.A.props.Document[afield + "_y"] = mp[1] / this.props.A.props.PanelHeight() * 100; + }, 0); + } + if (!targetAhyperlink) { + this.props.A.props.Document[bfield + "_x"] = (bpt.point.x - bbounds.left) / bbounds.width * 100; + this.props.A.props.Document[bfield + "_y"] = (bpt.point.y - bbounds.top) / bbounds.height * 100; + } else { + setTimeout(() => { + (this.props.B.props.Document[(this.props.B.props as any).fieldKey] as Doc); + const m = targetAhyperlink.getBoundingClientRect(); + const mp = this.props.B.props.ScreenToLocalTransform().transformPoint(m.right, m.top + 5); + this.props.B.props.Document[afield + "_x"] = mp[0] / this.props.B.props.PanelWidth() * 100; + this.props.B.props.Document[afield + "_y"] = mp[1] / this.props.B.props.PanelHeight() * 100; + }, 0); + } }) , { fireImmediately: true }); } @@ -66,9 +93,12 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo apt.point.x, apt.point.y); const pt1 = [apt.point.x, apt.point.y]; const pt2 = [bpt.point.x, bpt.point.y]; - return (<line key="linkLine" className="collectionfreeformlinkview-linkLine" - style={{ opacity: this._opacity }} - x1={`${pt1[0]}`} y1={`${pt1[1]}`} - x2={`${pt2[0]}`} y2={`${pt2[1]}`} />); + const aActive = this.props.A.isSelected() || Doc.IsBrushed(this.props.A.props.Document); + const bActive = this.props.A.isSelected() || Doc.IsBrushed(this.props.A.props.Document); + return !aActive && !bActive ? (null) : + <line key="linkLine" className="collectionfreeformlinkview-linkLine" + style={{ opacity: this._opacity, strokeDasharray: "2 2" }} + x1={`${pt1[0]}`} y1={`${pt1[1]}`} + x2={`${pt2[0]}`} y2={`${pt2[1]}`} />; } }
\ No newline at end of file diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx index 218012fe1..044d35eca 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx @@ -91,13 +91,11 @@ export class CollectionFreeFormLinksView extends React.Component { } render() { - return ( - <div className="collectionfreeformlinksview-container"> - <svg className="collectionfreeformlinksview-svgCanvas"> - {SelectionManager.GetIsDragging() ? (null) : this.uniqueConnections} - </svg> - {this.props.children} - </div> - ); + return <div className="collectionfreeformlinksview-container"> + <svg className="collectionfreeformlinksview-svgCanvas"> + {SelectionManager.GetIsDragging() ? (null) : this.uniqueConnections} + </svg> + {this.props.children} + </div>; } }
\ No newline at end of file diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss index 070d4aa65..58fb81453 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss @@ -28,6 +28,21 @@ // touch action none means that the browser will handle none of the touch actions. this allows us to implement our own actions. touch-action: none; + .collectionfreeformview-placeholder { + background: gray; + width: 100%; + height: 100%; + display: flex; + align-items: center; + .collectionfreeformview-placeholderSpan { + font-size: 32; + display: flex; + text-align: center; + margin: auto; + background: #80808069; + } + } + .collectionfreeformview>.jsx-parser { position: inherit; height: 100%; @@ -52,6 +67,8 @@ left: 0; width: 100%; height: 100%; + align-items: center; + display: flex; } // selection border...? diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index c69b264c4..7985e541f 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -9,11 +9,11 @@ import { Id } from "../../../../new_fields/FieldSymbols"; import { InkTool, InkField, InkData } from "../../../../new_fields/InkField"; import { createSchema, makeInterface } from "../../../../new_fields/Schema"; import { ScriptField } from "../../../../new_fields/ScriptField"; -import { BoolCast, Cast, DateCast, NumCast, StrCast } from "../../../../new_fields/Types"; +import { BoolCast, Cast, DateCast, NumCast, StrCast, ScriptCast } from "../../../../new_fields/Types"; import { CurrentUserUtils } from "../../../../server/authentication/models/current_user_utils"; import { aggregateBounds, emptyFunction, intersectRect, returnOne, Utils } from "../../../../Utils"; import { DocServer } from "../../../DocServer"; -import { Docs } from "../../../documents/Documents"; +import { Docs, DocUtils } from "../../../documents/Documents"; import { DocumentType } from "../../../documents/DocumentTypes"; import { DocumentManager } from "../../../util/DocumentManager"; import { DragManager } from "../../../util/DragManager"; @@ -41,6 +41,8 @@ import { MarqueeView } from "./MarqueeView"; import React = require("react"); import { computedFn } from "mobx-utils"; import { TraceMobx } from "../../../../new_fields/util"; +import { GestureUtils } from "../../../../pen-gestures/GestureUtils"; +import { LinkManager } from "../../../util/LinkManager"; import { CognitiveServices } from "../../../cognitive_services/CognitiveServices"; library.add(faEye as any, faTable, faPaintBrush, faExpandArrowsAlt, faCompressArrowsAlt, faCompass, faUpload, faBraille, faChalkboard, faFileUpload); @@ -54,6 +56,8 @@ export const panZoomSchema = createSchema({ useClusters: "boolean", isRuleProvider: "boolean", fitToBox: "boolean", + xPadding: "number", // pixels of padding on left/right of collectionfreeformview contents when fitToBox is set + yPadding: "number", // pixels of padding on left/right of collectionfreeformview contents when fitToBox is set panTransformType: "string", scrollHeight: "number", fitX: "number", @@ -80,7 +84,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { @computed get fitToContent() { return (this.props.fitToBox || this.Document.fitToBox) && !this.isAnnotationOverlay; } @computed get parentScaling() { return this.props.ContentScaling && this.fitToContent && !this.isAnnotationOverlay ? this.props.ContentScaling() : 1; } - @computed get contentBounds() { return aggregateBounds(this._layoutElements.filter(e => e.bounds && !e.bounds.z).map(e => e.bounds!)); } + @computed get contentBounds() { return aggregateBounds(this._layoutElements.filter(e => e.bounds && !e.bounds.z).map(e => e.bounds!), NumCast(this.layoutDoc.xPadding, 10), NumCast(this.layoutDoc.yPadding, 10)); } @computed get nativeWidth() { return this.Document.fitToContent ? 0 : this.Document.nativeWidth || 0; } @computed get nativeHeight() { return this.fitToContent ? 0 : this.Document.nativeHeight || 0; } private get isAnnotationOverlay() { return this.props.isAnnotationOverlay; } @@ -139,15 +143,15 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { const [xp, yp] = xf.transformPoint(de.x, de.y); const [xpo, ypo] = xfo.transformPoint(de.x, de.y); if (super.drop(e, de)) { - if (de.data instanceof DragManager.DocumentDragData) { - if (de.data.droppedDocuments.length) { - const firstDoc = de.data.droppedDocuments[0]; + if (de.complete.docDragData) { + if (de.complete.docDragData.droppedDocuments.length) { + const firstDoc = de.complete.docDragData.droppedDocuments[0]; const z = NumCast(firstDoc.z); - const x = (z ? xpo : xp) - de.data.offset[0]; - const y = (z ? ypo : yp) - de.data.offset[1]; + const x = (z ? xpo : xp) - de.complete.docDragData.offset[0]; + const y = (z ? ypo : yp) - de.complete.docDragData.offset[1]; const dropX = NumCast(firstDoc.x); const dropY = NumCast(firstDoc.y); - de.data.droppedDocuments.forEach(action((d: Doc) => { + de.complete.docDragData.droppedDocuments.forEach(action((d: Doc) => { const layoutDoc = Doc.Layout(d); d.x = x + NumCast(d.x) - dropX; d.y = y + NumCast(d.y) - dropY; @@ -162,19 +166,19 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { this.bringToFront(d); })); - de.data.droppedDocuments.length === 1 && this.updateCluster(de.data.droppedDocuments[0]); + de.complete.docDragData.droppedDocuments.length === 1 && this.updateCluster(de.complete.docDragData.droppedDocuments[0]); } } - else if (de.data instanceof DragManager.AnnotationDragData) { - if (de.data.dropDocument) { - const dragDoc = de.data.dropDocument; - const x = xp - de.data.offset[0]; - const y = yp - de.data.offset[1]; + else if (de.complete.annoDragData) { + if (de.complete.annoDragData.dropDocument) { + const dragDoc = de.complete.annoDragData.dropDocument; + const x = xp - de.complete.annoDragData.offset[0]; + const y = yp - de.complete.annoDragData.offset[1]; const dropX = NumCast(dragDoc.x); const dropY = NumCast(dragDoc.y); dragDoc.x = x + NumCast(dragDoc.x) - dropX; dragDoc.y = y + NumCast(dragDoc.y) - dropY; - de.data.targetContext = this.props.Document; // dropped a PDF annotation, so we need to set the targetContext on the dragData which the PDF view uses at the end of the drop operation + de.complete.annoDragData.targetContext = this.props.Document; // dropped a PDF annotation, so we need to set the targetContext on the dragData which the PDF view uses at the end of the drop operation this.bringToFront(dragDoc); } } @@ -205,10 +209,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { const [left, top] = clusterDocs[0].props.ScreenToLocalTransform().scale(clusterDocs[0].props.ContentScaling()).inverse().transformPoint(0, 0); de.offset = this.getTransform().transformDirection(ptsParent.clientX - left, ptsParent.clientY - top); de.dropAction = e.ctrlKey || e.altKey ? "alias" : undefined; - DragManager.StartDocumentDrag(clusterDocs.map(v => v.ContentDiv!), de, ptsParent.clientX, ptsParent.clientY, { - handlers: { dragComplete: action(emptyFunction) }, - hideSource: !de.dropAction - }); + DragManager.StartDocumentDrag(clusterDocs.map(v => v.ContentDiv!), de, ptsParent.clientX, ptsParent.clientY, { hideSource: !de.dropAction }); return true; } } @@ -273,7 +274,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { return clusterColor; } - @observable private _points: { x: number, y: number }[] = []; + @observable private _points: { X: number, Y: number }[] = []; @action onPointerDown = (e: React.PointerEvent): void => { @@ -289,7 +290,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { e.stopPropagation(); e.preventDefault(); const point = this.getTransform().transformPoint(e.pageX, e.pageY); - this._points.push({ x: point[0], y: point[1] }); + this._points.push({ X: point[0], Y: point[1] }); } // if not using a pen and in no ink mode else if (InkingControl.Instance.selectedTool === InkTool.None) { @@ -328,6 +329,28 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { const pt = e.targetTouches.item(0); if (pt) { this._hitCluster = this.props.Document.useCluster ? this.pickCluster(this.getTransform().transformPoint(pt.clientX, pt.clientY)) !== -1 : false; + if (!e.shiftKey && !e.altKey && !e.ctrlKey && this.props.active(true)) { + document.removeEventListener("touchmove", this.onTouch); + document.addEventListener("touchmove", this.onTouch); + document.removeEventListener("touchend", this.onTouchEnd); + document.addEventListener("touchend", this.onTouchEnd); + if (InkingControl.Instance.selectedTool === InkTool.Highlighter || InkingControl.Instance.selectedTool === InkTool.Pen) { + e.stopPropagation(); + e.preventDefault(); + const point = this.getTransform().transformPoint(pt.pageX, pt.pageY); + this._points.push({ X: point[0], Y: point[1] }); + } + else if (InkingControl.Instance.selectedTool === InkTool.None) { + this._lastX = pt.pageX; + this._lastY = pt.pageY; + e.stopPropagation(); + e.preventDefault(); + } + else { + e.stopPropagation(); + e.preventDefault(); + } + } } } @@ -337,10 +360,65 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { if (this._points.length > 1) { const B = this.svgBounds; - const points = this._points.map(p => ({ x: p.x - B.left, y: p.y - B.top })); - const inkDoc = Docs.Create.InkDocument(InkingControl.Instance.selectedColor, InkingControl.Instance.selectedTool, parseInt(InkingControl.Instance.selectedWidth), points, { width: B.width, height: B.height, x: B.left, y: B.top }); - this.addDocument(inkDoc); - this._points = []; + const points = this._points.map(p => ({ X: p.X - B.left, Y: p.Y - B.top })); + + const result = GestureUtils.GestureRecognizer.Recognize(new Array(points)); + let actionPerformed = false; + if (result && result.Score > 0.7) { + switch (result.Name) { + case GestureUtils.Gestures.Box: + const bounds = { x: Math.min(...this._points.map(p => p.X)), r: Math.max(...this._points.map(p => p.X)), y: Math.min(...this._points.map(p => p.Y)), b: Math.max(...this._points.map(p => p.Y)) }; + const sel = this.getActiveDocuments().filter(doc => { + const l = NumCast(doc.x); + const r = l + doc[WidthSym](); + const t = NumCast(doc.y); + const b = t + doc[HeightSym](); + const pass = !(bounds.x > r || bounds.r < l || bounds.y > b || bounds.b < t); + if (pass) { + doc.x = l - B.left - B.width / 2; + doc.y = t - B.top - B.height / 2; + } + return pass; + }); + this.addDocument(Docs.Create.FreeformDocument(sel, { x: B.left, y: B.top, width: B.width, height: B.height, panX: 0, panY: 0 })); + sel.forEach(d => this.props.removeDocument(d)); + actionPerformed = true; + break; + case GestureUtils.Gestures.Line: + const ep1 = this._points[0]; + const ep2 = this._points[this._points.length - 1]; + let d1: Doc | undefined; + let d2: Doc | undefined; + this.getActiveDocuments().map(doc => { + const l = NumCast(doc.x); + const r = l + doc[WidthSym](); + const t = NumCast(doc.y); + const b = t + doc[HeightSym](); + if (!d1 && l < ep1.X && r > ep1.X && t < ep1.Y && b > ep1.Y) { + d1 = doc; + } + else if (!d2 && l < ep2.X && r > ep2.X && t < ep2.Y && b > ep2.Y) { + d2 = doc; + } + }); + if (d1 && d2) { + if (!LinkManager.Instance.doesLinkExist(d1, d2)) { + DocUtils.MakeLink({ doc: d1 }, { doc: d2 }); + actionPerformed = true; + } + } + break; + } + if (actionPerformed) { + this._points = []; + } + } + + if (!actionPerformed) { + const inkDoc = Docs.Create.InkDocument(InkingControl.Instance.selectedColor, InkingControl.Instance.selectedTool, parseInt(InkingControl.Instance.selectedWidth), points, { width: B.width, height: B.height, x: B.left, y: B.top }); + this.addDocument(inkDoc); + this._points = []; + } } document.removeEventListener("pointermove", this.onPointerMove); @@ -399,7 +477,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { const selectedTool = InkingControl.Instance.selectedTool; if (selectedTool === InkTool.Highlighter || selectedTool === InkTool.Pen || InteractionUtils.IsType(e, InteractionUtils.PENTYPE)) { const point = this.getTransform().transformPoint(e.clientX, e.clientY); - this._points.push({ x: point[0], y: point[1] }); + this._points.push({ X: point[0], Y: point[1] }); } else if (selectedTool === InkTool.None) { if (this._hitCluster && this.tryDragCluster(e)) { @@ -419,7 +497,8 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { handle1PointerMove = (e: TouchEvent) => { // panning a workspace if (!e.cancelBubble) { - const pt = e.targetTouches.item(0); + const myTouches = InteractionUtils.GetMyTargetTouches(e, this.prevPoints); + const pt = myTouches[0]; if (pt) { if (InkingControl.Instance.selectedTool === InkTool.None) { if (this._hitCluster && this.tryDragCluster(e)) { @@ -433,7 +512,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } else if (InkingControl.Instance.selectedTool !== InkTool.Eraser && InkingControl.Instance.selectedTool !== InkTool.Scrubber) { const point = this.getTransform().transformPoint(pt.clientX, pt.clientY); - this._points.push({ x: point[0], y: point[1] }); + this._points.push({ X: point[0], Y: point[1] }); } } e.stopPropagation(); @@ -444,9 +523,9 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { handle2PointersMove = (e: TouchEvent) => { // pinch zooming if (!e.cancelBubble) { - const pt1: Touch | null = e.targetTouches.item(0); - const pt2: Touch | null = e.targetTouches.item(1); - if (!pt1 || !pt2) return; + const myTouches = InteractionUtils.GetMyTargetTouches(e, this.prevPoints); + const pt1 = myTouches[0]; + const pt2 = myTouches[1]; if (this.prevPoints.size === 2) { const oldPoint1 = this.prevPoints.get(pt1.identifier); @@ -465,8 +544,8 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { const rawDelta = (dir * (d1 + d2)); // this floors and ceils the delta value to prevent jitteriness - const delta = Math.sign(rawDelta) * Math.min(Math.abs(rawDelta), 16); - this.zoom(centerX, centerY, delta); + const delta = Math.sign(rawDelta) * Math.min(Math.abs(rawDelta), 8); + this.zoom(centerX, centerY, delta * window.devicePixelRatio); this.prevPoints.set(pt1.identifier, pt1); this.prevPoints.set(pt2.identifier, pt2); } @@ -481,20 +560,28 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } } } + e.stopPropagation(); + e.preventDefault(); } - e.stopPropagation(); - e.preventDefault(); } + @action handle2PointersDown = (e: React.TouchEvent) => { - const pt1: React.Touch | null = e.targetTouches.item(0); - const pt2: React.Touch | null = e.targetTouches.item(1); - if (!pt1 || !pt2) return; + if (!e.nativeEvent.cancelBubble && this.props.active(true)) { + const pt1: React.Touch | null = e.targetTouches.item(0); + const pt2: React.Touch | null = e.targetTouches.item(1); + if (!pt1 || !pt2) return; - const centerX = Math.min(pt1.clientX, pt2.clientX) + Math.abs(pt2.clientX - pt1.clientX) / 2; - const centerY = Math.min(pt1.clientY, pt2.clientY) + Math.abs(pt2.clientY - pt1.clientY) / 2; - this._lastX = centerX; - this._lastY = centerY; + const centerX = Math.min(pt1.clientX, pt2.clientX) + Math.abs(pt2.clientX - pt1.clientX) / 2; + const centerY = Math.min(pt1.clientY, pt2.clientY) + Math.abs(pt2.clientY - pt1.clientY) / 2; + this._lastX = centerX; + this._lastY = centerY; + document.removeEventListener("touchmove", this.onTouch); + document.addEventListener("touchmove", this.onTouch); + document.removeEventListener("touchend", this.onTouchEnd); + document.addEventListener("touchend", this.onTouchEnd); + e.stopPropagation(); + } } cleanUpInteractions = () => { @@ -562,6 +649,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { focusDocument = (doc: Doc, willZoom: boolean, scale?: number, afterFocus?: () => boolean) => { const state = HistoryUtil.getState(); + // TODO This technically isn't correct if type !== "doc", as // currently nothing is done, but we should probably push a new state if (state.type === "doc" && this.Document.panX !== undefined && this.Document.panY !== undefined) { @@ -595,10 +683,11 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { const savedState = { px: this.Document.panX, py: this.Document.panY, s: this.Document.scale, pt: this.Document.panTransformType }; - this.setPan(newPanX, newPanY, "Ease"); + if (!doc.z) this.setPan(newPanX, newPanY, "Ease"); // docs that are floating in their collection can't be panned to from their collection -- need to propagate the pan to a parent freeform somehow Doc.BrushDoc(this.props.Document); this.props.focus(this.props.Document); willZoom && this.setScaleToZoom(layoutdoc, scale); + Doc.linkFollowHighlight(doc); afterFocus && setTimeout(() => { if (afterFocus && afterFocus()) { @@ -622,14 +711,19 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { getScale = () => this.Document.scale || 1; + @computed get libraryPath() { return this.props.LibraryPath ? [...this.props.LibraryPath, this.props.Document] : []; } + @computed get onChildClickHandler() { return ScriptCast(this.Document.onChildClick); } + getChildDocumentViewProps(childLayout: Doc, childData?: Doc): DocumentViewProps { return { ...this.props, DataDoc: childData, Document: childLayout, + LibraryPath: this.libraryPath, layoutKey: undefined, ruleProvider: this.Document.isRuleProvider && childLayout.type !== DocumentType.TEXT ? this.props.Document : this.props.ruleProvider, //bcz: hack! - currently ruleProviders apply to documents in nested colleciton, not direct children of themselves - onClick: undefined, // this.props.onClick, // bcz: check this out -- I don't think we want to inherit click handlers, or we at least need a way to ignore them + //onClick: undefined, // this.props.onClick, // bcz: check this out -- I don't think we want to inherit click handlers, or we at least need a way to ignore them + onClick: this.onChildClickHandler, ScreenToLocalTransform: childLayout.z ? this.getTransformOverlay : this.getTransform, renderDepth: this.props.renderDepth + 1, PanelWidth: childLayout[WidthSym], @@ -682,7 +776,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { doPivotLayout(poolData: ObservableMap<string, any>) { return computePivotLayout(poolData, this.props.Document, this.childDocs, - this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)), this.viewDefsToJSX); + this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)), [this.props.PanelWidth(), this.props.PanelHeight()], this.viewDefsToJSX); } doFreeformLayout(poolData: ObservableMap<string, any>) { @@ -708,11 +802,13 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { case "pivot": computedElementData = this.doPivotLayout(this._layoutPoolData); break; default: computedElementData = this.doFreeformLayout(this._layoutPoolData); break; } - this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).forEach(pair => + this.childLayoutPairs.filter((pair, i) => this.isCurrent(pair.layout)).forEach(pair => computedElementData.elements.push({ - ele: <CollectionFreeFormDocumentView key={pair.layout[Id]} dataProvider={this.childDataProvider} + ele: <CollectionFreeFormDocumentView key={pair.layout[Id]} {...this.getChildDocumentViewProps(pair.layout, pair.data)} + dataProvider={this.childDataProvider} ruleProvider={this.Document.isRuleProvider ? this.props.Document : this.props.ruleProvider} - jitterRotation={NumCast(this.props.Document.jitterRotation)} {...this.getChildDocumentViewProps(pair.layout, pair.data)} />, + jitterRotation={NumCast(this.props.Document.jitterRotation)} + fitToBox={this.props.fitToBox || this.Document.freeformLayoutEngine === "pivot"} />, bounds: this.childDataProvider(pair.layout) })); @@ -720,6 +816,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } componentDidMount() { + super.componentDidMount(); this._layoutComputeReaction = reaction(() => { TraceMobx(); return this.doLayoutComputation; }, action((computation: { elements: ViewDefResult[] }) => computation && (this._layoutElements = computation.elements)), { fireImmediately: true, name: "doLayout" }); @@ -795,7 +892,8 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { layoutItems.push({ description: "Template Layout Instance", event: () => this.props.addDocTab(Doc.ApplyTemplate(this.props.Document)!, undefined, "onRight"), icon: "project-diagram" }); } layoutItems.push({ description: "reset view", event: () => { this.props.Document.panX = this.props.Document.panY = 0; this.props.Document.scale = 1; }, icon: "compress-arrows-alt" }); - layoutItems.push({ description: `${this.fitToContent ? "Unset" : "Set"} Fit To Container`, event: async () => this.Document.fitToBox = !this.fitToContent, icon: !this.fitToContent ? "expand-arrows-alt" : "compress-arrows-alt" }); + layoutItems.push({ description: `${this.Document.LODdisable ? "Enable LOD" : "Disable LOD"}`, event: () => this.Document.LODdisable = !this.Document.LODdisable, icon: "table" }); + layoutItems.push({ description: `${this.fitToContent ? "Unset" : "Set"} Fit To Container`, event: () => this.Document.fitToBox = !this.fitToContent, icon: !this.fitToContent ? "expand-arrows-alt" : "compress-arrows-alt" }); layoutItems.push({ description: `${this.Document.useClusters ? "Uncluster" : "Use Clusters"}`, event: () => this.updateClusters(!this.Document.useClusters), icon: "braille" }); layoutItems.push({ description: `${this.Document.isRuleProvider ? "Stop Auto Format" : "Auto Format"}`, event: this.autoFormat, icon: "chalkboard" }); layoutItems.push({ description: "Arrange contents in grid", event: this.layoutDocsInGrid, icon: "table" }); @@ -851,8 +949,8 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } @computed get svgBounds() { - const xs = this._points.map(p => p.x); - const ys = this._points.map(p => p.y); + const xs = this._points.map(p => p.X); + const ys = this._points.map(p => p.Y); const right = Math.max(...xs); const left = Math.min(...xs); const bottom = Math.max(...ys); @@ -868,7 +966,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { const B = this.svgBounds; return ( - <svg width={B.width} height={B.height} style={{ transform: `translate(${B.left}px, ${B.top}px)` }}> + <svg width={B.width} height={B.height} style={{ transform: `translate(${B.left}px, ${B.top}px)`, position: "absolute", zIndex: 30000 }}> {CreatePolyline(this._points, B.left, B.top)} </svg> ); @@ -881,6 +979,25 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { eles.push(<CollectionFreeFormRemoteCursors {...this.props} key="remoteCursors" />); return eles; } + @computed get placeholder() { + return <div className="collectionfreeformview-placeholder" style={{ background: this.Document.backgroundColor }}> + <span className="collectionfreeformview-placeholderSpan">{this.props.Document.title}</span> + </div>; + } + @computed get marqueeView() { + return <MarqueeView {...this.props} extensionDoc={this.extensionDoc!} activeDocuments={this.getActiveDocuments} selectDocuments={this.selectDocuments} addDocument={this.addDocument} + addLiveTextDocument={this.addLiveTextBox} getContainerTransform={this.getContainerTransform} getTransform={this.getTransform} isAnnotationOverlay={this.isAnnotationOverlay}> + <CollectionFreeFormViewPannableContents centeringShiftX={this.centeringShiftX} centeringShiftY={this.centeringShiftY} + easing={this.easing} zoomScaling={this.zoomScaling} panX={this.panX} panY={this.panY}> + {this.children} + </CollectionFreeFormViewPannableContents> + </MarqueeView>; + } + @computed get contentScaling() { + const hscale = this.nativeHeight ? this.props.PanelHeight() / this.nativeHeight : 1; + const wscale = this.nativeWidth ? this.props.PanelWidth() / this.nativeWidth : 1; + return wscale < hscale ? wscale : hscale; + } render() { TraceMobx(); // update the actual dimensions of the collection so that they can inquired (e.g., by a minimap) @@ -890,19 +1007,23 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { // this.Document.fitH = this.contentBounds && (this.contentBounds.b - this.contentBounds.y); // if isAnnotationOverlay is set, then children will be stored in the extension document for the fieldKey. // otherwise, they are stored in fieldKey. All annotations to this document are stored in the extension document - return !this.extensionDoc ? (null) : - <div className={"collectionfreeformview-container"} ref={this.createDropTarget} onWheel={this.onPointerWheel}//pointerEvents: SelectionManager.GetIsDragging() ? "all" : undefined, - style={{ height: this.isAnnotationOverlay ? (this.props.Document.scrollHeight ? this.Document.scrollHeight : "100%") : this.props.PanelHeight() }} - onPointerDown={this.onPointerDown} onPointerMove={this.onCursorMove} onDrop={this.onDrop.bind(this)} onContextMenu={this.onContextMenu} onTouchStart={this.onTouchStart}> - <MarqueeView {...this.props} extensionDoc={this.extensionDoc} activeDocuments={this.getActiveDocuments} selectDocuments={this.selectDocuments} addDocument={this.addDocument} - addLiveTextDocument={this.addLiveTextBox} getContainerTransform={this.getContainerTransform} getTransform={this.getTransform} isAnnotationOverlay={this.isAnnotationOverlay}> - <CollectionFreeFormViewPannableContents centeringShiftX={this.centeringShiftX} centeringShiftY={this.centeringShiftY} - easing={this.easing} zoomScaling={this.zoomScaling} panX={this.panX} panY={this.panY}> - {this.children} - </CollectionFreeFormViewPannableContents> - </MarqueeView> - <CollectionFreeFormOverlayView elements={this.elementFunc} /> - </div>; + if (!this.extensionDoc) return (null); + // let lodarea = this.Document[WidthSym]() * this.Document[HeightSym]() / this.props.ScreenToLocalTransform().Scale / this.props.ScreenToLocalTransform().Scale; + return <div className={"collectionfreeformview-container"} + ref={this.createDropTarget} + onWheel={this.onPointerWheel}//pointerEvents: SelectionManager.GetIsDragging() ? "all" : undefined, + onPointerDown={this.onPointerDown} onPointerMove={this.onCursorMove} onDrop={this.onDrop.bind(this)} onContextMenu={this.onContextMenu} onTouchStart={this.onTouchStart} + style={{ + pointerEvents: SelectionManager.GetIsDragging() ? "all" : undefined, + transform: this.contentScaling ? `scale(${this.contentScaling})` : "", + transformOrigin: this.contentScaling ? "left top" : "", + width: this.contentScaling ? `${100 / this.contentScaling}%` : "", + height: this.contentScaling ? `${100 / this.contentScaling}%` : this.isAnnotationOverlay ? (this.props.Document.scrollHeight ? this.Document.scrollHeight : "100%") : this.props.PanelHeight() + }}> + {!this.Document.LODdisable && !this.props.active() && !this.props.isAnnotationOverlay && !this.props.annotationsKey && this.props.renderDepth > 0 ? // && this.props.CollectionView && lodarea < NumCast(this.Document.LODarea, 100000) ? + this.placeholder : this.marqueeView} + <CollectionFreeFormOverlayView elements={this.elementFunc} /> + </div>; } } @@ -936,7 +1057,7 @@ class CollectionFreeFormViewPannableContents extends React.Component<CollectionF const panx = -this.props.panX(); const pany = -this.props.panY(); const zoom = this.props.zoomScaling(); - return <div className={freeformclass} style={{ transform: `translate(${cenx}px, ${ceny}px) scale(${zoom}) translate(${panx}px, ${pany}px)` }}> + return <div className={freeformclass} style={{ touchAction: "none", borderRadius: "inherit", transform: `translate(${cenx}px, ${ceny}px) scale(${zoom}) translate(${panx}px, ${pany}px)` }}> {this.props.children()} </div>; } diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.scss b/src/client/views/collections/collectionFreeForm/MarqueeView.scss index d14495626..18d6da0da 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.scss +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.scss @@ -5,10 +5,9 @@ left:0; width:100%; height:100%; -} -.marqueeView { overflow: hidden; pointer-events: inherit; + border-radius: inherit; } .marqueeView:focus-within { diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index dc68ae81c..523edb918 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -520,7 +520,7 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque } render() { - return <div className="marqueeView" onScroll={(e) => e.currentTarget.scrollTop = e.currentTarget.scrollLeft = 0} style={{ borderRadius: "inherit" }} onClick={this.onClick} onPointerDown={this.onPointerDown}> + return <div className="marqueeView" onScroll={(e) => e.currentTarget.scrollTop = e.currentTarget.scrollLeft = 0} onClick={this.onClick} onPointerDown={this.onPointerDown}> {this._visible ? this.marqueeDiv : null} {this.props.children} </div>; diff --git a/src/client/views/linking/LinkMenu.scss b/src/client/views/linking/LinkMenu.scss index a4018bd2d..7dee22f66 100644 --- a/src/client/views/linking/LinkMenu.scss +++ b/src/client/views/linking/LinkMenu.scss @@ -48,90 +48,5 @@ } } -.linkMenu-item { - // border-top: 0.5px solid $main-accent; - position: relative; - display: flex; - font-size: 12px; - - - .link-name { - position: relative; - - p { - padding: 4px 6px; - line-height: 12px; - border-radius: 5px; - overflow-wrap: break-word; - } - } - - .linkMenu-item-content { - width: 100%; - } - - .link-metadata { - padding: 0 10px 0 16px; - margin-bottom: 4px; - color: $main-accent; - font-style: italic; - font-size: 10.5px; - } - - &:hover { - .linkMenu-item-buttons { - display: flex; - } - .linkMenu-item-content { - &.expand-two p { - width: calc(100% - 52px); - background-color: lightgray; - } - &.expand-three p { - width: calc(100% - 84px); - background-color: lightgray; - } - } - } -} - -.linkMenu-item-buttons { - display: none; - position: absolute; - top: 50%; - right: 0; - transform: translateY(-50%); - - .button { - width: 20px; - height: 20px; - margin: 0; - margin-right: 6px; - border-radius: 50%; - cursor: pointer; - pointer-events: auto; - background-color: $dark-color; - color: $light-color; - font-size: 65%; - transition: transform 0.2s; - text-align: center; - position: relative; - - .fa-icon { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } - - &:last-child { - margin-right: 0; - } - &:hover { - background: $main-accent; - } - } -} - diff --git a/src/client/views/linking/LinkMenuGroup.tsx b/src/client/views/linking/LinkMenuGroup.tsx index 15aacbbc9..ace9a9e4c 100644 --- a/src/client/views/linking/LinkMenuGroup.tsx +++ b/src/client/views/linking/LinkMenuGroup.tsx @@ -4,11 +4,9 @@ import { observer } from "mobx-react"; import { Doc } from "../../../new_fields/Doc"; import { Id } from "../../../new_fields/FieldSymbols"; import { SchemaHeaderField } from "../../../new_fields/SchemaHeaderField"; -import { emptyFunction } from "../../../Utils"; import { Docs } from "../../documents/Documents"; import { DragManager, SetupDrag } from "../../util/DragManager"; import { LinkManager } from "../../util/LinkManager"; -import { UndoManager } from "../../util/UndoManager"; import { DocumentView } from "../nodes/DocumentView"; import './LinkMenu.scss'; import { LinkMenuItem } from "./LinkMenuItem"; @@ -21,7 +19,6 @@ interface LinkMenuGroupProps { showEditor: (linkDoc: Doc) => void; addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => boolean; docView: DocumentView; - } @observer @@ -44,27 +41,14 @@ export class LinkMenuGroup extends React.Component<LinkMenuGroupProps> { e.stopPropagation(); } - onLinkButtonMoved = async (e: PointerEvent) => { - UndoManager.RunInBatch(() => { - if (this._drag.current !== null && (e.movementX > 1 || e.movementY > 1)) { - document.removeEventListener("pointermove", this.onLinkButtonMoved); - document.removeEventListener("pointerup", this.onLinkButtonUp); + if (this._drag.current && (e.movementX > 1 || e.movementY > 1)) { + document.removeEventListener("pointermove", this.onLinkButtonMoved); + document.removeEventListener("pointerup", this.onLinkButtonUp); - const draggedDocs = this.props.group.map(linkDoc => { - const opp = LinkManager.Instance.getOppositeAnchor(linkDoc, this.props.sourceDoc); - if (opp) return opp; - }) as Doc[]; - const dragData = new DragManager.DocumentDragData(draggedDocs); - - DragManager.StartLinkedDocumentDrag([this._drag.current], dragData, e.x, e.y, { - handlers: { - dragComplete: action(emptyFunction), - }, - hideSource: false - }); - } - }, "drag links"); + const targets = this.props.group.map(l => LinkManager.Instance.getOppositeAnchor(l, this.props.sourceDoc)).filter(d => d) as Doc[]; + DragManager.StartLinkTargetsDrag(this._drag.current, e.x, e.y, this.props.sourceDoc, targets); + } e.stopPropagation(); } diff --git a/src/client/views/linking/LinkMenuItem.scss b/src/client/views/linking/LinkMenuItem.scss new file mode 100644 index 000000000..fd0954f65 --- /dev/null +++ b/src/client/views/linking/LinkMenuItem.scss @@ -0,0 +1,87 @@ +@import "../globalCssVariables"; + +.linkMenu-item { + // border-top: 0.5px solid $main-accent; + position: relative; + display: flex; + font-size: 12px; + + + .linkMenu-name { + position: relative; + + p { + padding: 4px 6px; + line-height: 12px; + border-radius: 5px; + overflow-wrap: break-word; + user-select: none; + } + } + + .linkMenu-item-content { + width: 100%; + } + + .link-metadata { + padding: 0 10px 0 16px; + margin-bottom: 4px; + color: $main-accent; + font-style: italic; + font-size: 10.5px; + } + + &:hover { + .linkMenu-item-buttons { + display: flex; + } + .linkMenu-item-content { + &.expand-two p { + width: calc(100% - 52px); + background-color: lightgray; + } + &.expand-three p { + width: calc(100% - 84px); + background-color: lightgray; + } + } + } +} + +.linkMenu-item-buttons { + display: none; + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + + .button { + width: 20px; + height: 20px; + margin: 0; + margin-right: 6px; + border-radius: 50%; + cursor: pointer; + pointer-events: auto; + background-color: $dark-color; + color: $light-color; + font-size: 65%; + transition: transform 0.2s; + text-align: center; + position: relative; + + .fa-icon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + &:last-child { + margin-right: 0; + } + &:hover { + background: $main-accent; + } + } +}
\ No newline at end of file diff --git a/src/client/views/linking/LinkMenuItem.tsx b/src/client/views/linking/LinkMenuItem.tsx index edf5e9c26..b7d27ee30 100644 --- a/src/client/views/linking/LinkMenuItem.tsx +++ b/src/client/views/linking/LinkMenuItem.tsx @@ -5,11 +5,11 @@ import { action, observable } from 'mobx'; import { observer } from "mobx-react"; import { Doc } from '../../../new_fields/Doc'; import { Cast, StrCast } from '../../../new_fields/Types'; -import { DragLinkAsDocument } from '../../util/DragManager'; +import { DragManager } from '../../util/DragManager'; import { LinkManager } from '../../util/LinkManager'; import { ContextMenu } from '../ContextMenu'; import { LinkFollowBox } from './LinkFollowBox'; -import './LinkMenu.scss'; +import './LinkMenuItem.scss'; import React = require("react"); library.add(faEye, faEdit, faTimes, faArrowRight, faChevronDown, faChevronUp); @@ -26,6 +26,9 @@ interface LinkMenuItemProps { @observer export class LinkMenuItem extends React.Component<LinkMenuItemProps> { private _drag = React.createRef<HTMLDivElement>(); + private _downX = 0; + private _downY = 0; + private _eleClone: any; @observable private _showMore: boolean = false; @action toggleShowMore() { this._showMore = !this._showMore; } @@ -55,6 +58,9 @@ export class LinkMenuItem extends React.Component<LinkMenuItemProps> { } onLinkButtonDown = (e: React.PointerEvent): void => { + this._downX = e.clientX; + this._downY = e.clientY; + this._eleClone = this._drag.current!.cloneNode(true); e.stopPropagation(); document.removeEventListener("pointermove", this.onLinkButtonMoved); document.addEventListener("pointermove", this.onLinkButtonMoved); @@ -75,11 +81,12 @@ export class LinkMenuItem extends React.Component<LinkMenuItemProps> { } onLinkButtonMoved = async (e: PointerEvent) => { - if (this._drag.current !== null && (e.movementX > 1 || e.movementY > 1)) { + if (this._drag.current !== null && Math.abs((e.clientX - this._downX) * (e.clientX - this._downX) + (e.clientY - this._downY) * (e.clientY - this._downY)) > 5) { document.removeEventListener("pointermove", this.onLinkButtonMoved); document.removeEventListener("pointerup", this.onLinkButtonUp); - DragLinkAsDocument(this._drag.current, e.x, e.y, this.props.linkDoc, this.props.sourceDoc); + this._eleClone.style.transform = `translate(${e.x}px, ${e.y}px)`; + DragManager.StartLinkTargetsDrag(this._eleClone, e.x, e.y, this.props.sourceDoc, [this.props.linkDoc]); } e.stopPropagation(); } @@ -109,20 +116,21 @@ export class LinkMenuItem extends React.Component<LinkMenuItemProps> { } render() { - const keys = LinkManager.Instance.getMetadataKeysInGroup(this.props.groupType);//groupMetadataKeys.get(this.props.groupType); const canExpand = keys ? keys.length > 0 : false; return ( <div className="linkMenu-item"> <div className={canExpand ? "linkMenu-item-content expand-three" : "linkMenu-item-content expand-two"}> - <div className="link-name"> - <p ref={this._drag} onPointerDown={this.onLinkButtonDown}>{StrCast(this.props.destinationDoc.title)}</p> + <div ref={this._drag} className="linkMenu-name" title="drag to view target. click to customize." onPointerDown={this.onLinkButtonDown}> + <p >{StrCast(this.props.destinationDoc.title)}</p> <div className="linkMenu-item-buttons"> {canExpand ? <div title="Show more" className="button" onPointerDown={() => this.toggleShowMore()}> <FontAwesomeIcon className="fa-icon" icon={this._showMore ? "chevron-up" : "chevron-down"} size="sm" /></div> : <></>} <div title="Edit link" className="button" onPointerDown={this.onEdit}><FontAwesomeIcon className="fa-icon" icon="edit" size="sm" /></div> - <div title="Follow link" className="button" onClick={this.followDefault} onContextMenu={this.onContextMenu}><FontAwesomeIcon className="fa-icon" icon="arrow-right" size="sm" /></div> + <div title="Follow link" className="button" onClick={this.followDefault} onContextMenu={this.onContextMenu}> + <FontAwesomeIcon className="fa-icon" icon="arrow-right" size="sm" /> + </div> </div> </div> {this._showMore ? this.renderMetadata() : <></>} diff --git a/src/client/views/nodes/ButtonBox.tsx b/src/client/views/nodes/ButtonBox.tsx index 34151a311..d1272c266 100644 --- a/src/client/views/nodes/ButtonBox.tsx +++ b/src/client/views/nodes/ButtonBox.tsx @@ -46,7 +46,7 @@ export class ButtonBox extends DocComponent<FieldViewProps, ButtonDocument>(Butt this.dropDisposer(); } if (ele) { - this.dropDisposer = DragManager.MakeDropTarget(ele, { handlers: { drop: this.drop.bind(this) } }); + this.dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this)); } } @@ -65,9 +65,10 @@ export class ButtonBox extends DocComponent<FieldViewProps, ButtonDocument>(Butt @undoBatch @action drop = (e: Event, de: DragManager.DropEvent) => { - if (de.data instanceof DragManager.DocumentDragData && e.target) { - this.props.Document[(e.target as any).textContent] = new List<Doc>(de.data.droppedDocuments.map((d, i) => - d.onDragStart ? de.data.draggedDocuments[i] : d)); + const docDragData = de.complete.docDragData; + if (docDragData && e.target) { + this.props.Document[(e.target as any).textContent] = new List<Doc>(docDragData.droppedDocuments.map((d, i) => + d.onDragStart ? docDragData.draggedDocuments[i] : d)); e.stopPropagation(); } } diff --git a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx index d599de56e..614a68e7a 100644 --- a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx +++ b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx @@ -1,4 +1,4 @@ -import { random } from "animejs"; +import anime from "animejs"; import { computed, IReactionDisposer, observable, reaction, trace } from "mobx"; import { observer } from "mobx-react"; import { Doc, HeightSym, WidthSym } from "../../../new_fields/Doc"; @@ -11,6 +11,8 @@ import { DocumentView, DocumentViewProps } from "./DocumentView"; import React = require("react"); import { PositionDocument } from "../../../new_fields/documentSchemas"; import { TraceMobx } from "../../../new_fields/util"; +import { returnFalse } from "../../../Utils"; +import { ContentFittingDocumentView } from "./ContentFittingDocumentView"; export interface CollectionFreeFormDocumentViewProps extends DocumentViewProps { dataProvider?: (doc: Doc) => { x: number, y: number, width: number, height: number, z: number, transition?: string } | undefined; @@ -20,13 +22,14 @@ export interface CollectionFreeFormDocumentViewProps extends DocumentViewProps { height?: number; jitterRotation: number; transition?: string; + fitToBox?: boolean; } @observer export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeFormDocumentViewProps, PositionDocument>(PositionDocument) { _disposer: IReactionDisposer | undefined = undefined; get displayName() { return "CollectionFreeFormDocumentView(" + this.props.Document.title + ")"; } // this makes mobx trace() statements more descriptive - get transform() { return `scale(${this.props.ContentScaling()}) translate(${this.X}px, ${this.Y}px) rotate(${random(-1, 1) * this.props.jitterRotation}deg)`; } + get transform() { return `scale(${this.props.ContentScaling()}) translate(${this.X}px, ${this.Y}px) rotate(${anime.random(-1, 1) * this.props.jitterRotation}deg)`; } get X() { return this._animPos !== undefined ? this._animPos[0] : this.renderScriptDim ? this.renderScriptDim.x : this.props.x !== undefined ? this.props.x : this.dataProvider ? this.dataProvider.x : (this.Document.x || 0); } get Y() { return this._animPos !== undefined ? this._animPos[1] : this.renderScriptDim ? this.renderScriptDim.y : this.props.y !== undefined ? this.props.y : this.dataProvider ? this.dataProvider.y : (this.Document.y || 0); } get width() { return this.renderScriptDim ? this.renderScriptDim.width : this.props.width !== undefined ? this.props.width : this.props.dataProvider && this.dataProvider ? this.dataProvider.width : this.layoutDoc[WidthSym](); } @@ -83,8 +86,8 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF @observable _animPos: number[] | undefined = undefined; - finalPanelWidth = () => this.dataProvider ? this.dataProvider.width : this.panelWidth(); - finalPanelHeight = () => this.dataProvider ? this.dataProvider.height : this.panelHeight(); + finalPanelWidth = () => (this.dataProvider ? this.dataProvider.width : this.panelWidth()); + finalPanelHeight = () => (this.dataProvider ? this.dataProvider.height : this.panelHeight()); render() { TraceMobx(); @@ -103,13 +106,23 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF height: this.height, zIndex: this.Document.zIndex || 0, }} > - <DocumentView {...this.props} + + + {!this.props.fitToBox ? <DocumentView {...this.props} + dragDivName={"collectionFreeFormDocumentView-container"} ContentScaling={this.contentScaling} ScreenToLocalTransform={this.getTransform} backgroundColor={this.clusterColorFunc} PanelWidth={this.finalPanelWidth} PanelHeight={this.finalPanelHeight} - /> + /> : <ContentFittingDocumentView {...this.props} + DataDocument={this.props.DataDoc} + getTransform={this.getTransform} + active={returnFalse} + focus={(doc: Doc) => this.props.focus(doc, false)} + PanelWidth={this.finalPanelWidth} + PanelHeight={this.finalPanelHeight} + />} </div>; } -}
\ No newline at end of file +} diff --git a/src/client/views/nodes/ContentFittingDocumentView.scss b/src/client/views/nodes/ContentFittingDocumentView.scss index 796e67269..2801af441 100644 --- a/src/client/views/nodes/ContentFittingDocumentView.scss +++ b/src/client/views/nodes/ContentFittingDocumentView.scss @@ -2,10 +2,11 @@ .contentFittingDocumentView { position: relative; - height: auto !important; + display: flex; + align-items: center; .contentFittingDocumentView-previewDoc { - position: absolute; + position: relative; display: inline; } diff --git a/src/client/views/nodes/ContentFittingDocumentView.tsx b/src/client/views/nodes/ContentFittingDocumentView.tsx index efc907f9b..e97445f27 100644 --- a/src/client/views/nodes/ContentFittingDocumentView.tsx +++ b/src/client/views/nodes/ContentFittingDocumentView.tsx @@ -13,10 +13,12 @@ import '../DocumentDecorations.scss'; import { DocumentView } from "../nodes/DocumentView"; import "./ContentFittingDocumentView.scss"; import { CollectionView } from "../collections/CollectionView"; +import { TraceMobx } from "../../../new_fields/util"; interface ContentFittingDocumentViewProps { Document?: Doc; DataDocument?: Doc; + LibraryPath: Doc[]; childDocs?: Doc[]; renderDepth: number; fitToBox?: boolean; @@ -29,39 +31,39 @@ interface ContentFittingDocumentViewProps { CollectionDoc?: Doc; onClick?: ScriptField; getTransform: () => Transform; - addDocument: (document: Doc) => boolean; - moveDocument: (document: Doc, target: Doc, addDoc: ((doc: Doc) => boolean)) => boolean; - removeDocument: (document: Doc) => boolean; + addDocument?: (document: Doc) => boolean; + moveDocument?: (document: Doc, target: Doc | undefined, addDoc: ((doc: Doc) => boolean)) => boolean; + removeDocument?: (document: Doc) => boolean; active: (outsideReaction: boolean) => boolean; whenActiveChanged: (isActive: boolean) => void; addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => boolean; pinToPres: (document: Doc) => void; dontRegisterView?: boolean; - setPreviewScript: (script: string) => void; - previewScript?: string; } @observer export class ContentFittingDocumentView extends React.Component<ContentFittingDocumentViewProps>{ + public get displayName() { return "DocumentView(" + this.props.Document?.title + ")"; } // this makes mobx trace() statements more descriptive private get layoutDoc() { return this.props.Document && Doc.Layout(this.props.Document); } - private get nativeWidth() { return NumCast(this.layoutDoc!.nativeWidth, this.props.PanelWidth()); } - private get nativeHeight() { return NumCast(this.layoutDoc!.nativeHeight, this.props.PanelHeight()); } + private get nativeWidth() { return NumCast(this.layoutDoc?.nativeWidth, this.props.PanelWidth()); } + private get nativeHeight() { return NumCast(this.layoutDoc?.nativeHeight, this.props.PanelHeight()); } private contentScaling = () => { - const wscale = this.props.PanelWidth() / (this.nativeWidth ? this.nativeWidth : this.props.PanelWidth()); + const wscale = this.props.PanelWidth() / (this.nativeWidth || this.props.PanelWidth() || 1); if (wscale * this.nativeHeight > this.props.PanelHeight()) { - return this.props.PanelHeight() / (this.nativeHeight ? this.nativeHeight : this.props.PanelHeight()); + return (this.props.PanelHeight() / (this.nativeHeight || this.props.PanelHeight() || 1)) || 1; } - return wscale; + return wscale || 1; } @undoBatch @action drop = (e: Event, de: DragManager.DropEvent) => { - if (de.data instanceof DragManager.DocumentDragData) { + const docDragData = de.complete.docDragData; + if (docDragData) { this.props.childDocs && this.props.childDocs.map(otherdoc => { const target = Doc.GetProto(otherdoc); target.layout = ComputedField.MakeFunction("this.image_data[0]"); - target.layoutCustom = Doc.MakeDelegate(de.data.draggedDocuments[0]); + target.layoutCustom = Doc.MakeDelegate(docDragData.draggedDocuments[0]); }); e.stopPropagation(); } @@ -69,24 +71,30 @@ export class ContentFittingDocumentView extends React.Component<ContentFittingDo } private PanelWidth = () => this.nativeWidth && (!this.props.Document || !this.props.Document.fitWidth) ? this.nativeWidth * this.contentScaling() : this.props.PanelWidth(); private PanelHeight = () => this.nativeHeight && (!this.props.Document || !this.props.Document.fitWidth) ? this.nativeHeight * this.contentScaling() : this.props.PanelHeight(); - private getTransform = () => this.props.getTransform().translate(-this.centeringOffset, 0).scale(1 / this.contentScaling()); + private getTransform = () => this.props.getTransform().translate(-this.centeringOffset, -this.centeringYOffset).scale(1 / this.contentScaling()); private get centeringOffset() { return this.nativeWidth && (!this.props.Document || !this.props.Document.fitWidth) ? (this.props.PanelWidth() - this.nativeWidth * this.contentScaling()) / 2 : 0; } + private get centeringYOffset() { return Math.abs(this.centeringOffset) < 0.001 ? (this.props.PanelHeight() - this.nativeHeight * this.contentScaling()) / 2 : 0; } - @computed get borderRounding() { return StrCast(this.props.Document!.borderRounding); } + @computed get borderRounding() { return StrCast(this.props.Document?.borderRounding); } render() { - return (<div className="contentFittingDocumentView" style={{ width: this.props.PanelWidth(), height: this.props.PanelHeight() }}> + TraceMobx(); + return (<div className="contentFittingDocumentView" style={{ + width: Math.abs(this.centeringYOffset) > 0.001 ? "auto" : this.props.PanelWidth(), + height: Math.abs(this.centeringOffset) > 0.0001 ? "auto" : this.props.PanelHeight() + }}> {!this.props.Document || !this.props.PanelWidth ? (null) : ( <div className="contentFittingDocumentView-previewDoc" style={{ transform: `translate(${this.centeringOffset}px, 0px)`, borderRadius: this.borderRounding, - height: this.props.PanelHeight(), - width: `${100 * (this.props.PanelWidth() - this.centeringOffset * 2) / this.props.PanelWidth()}%` + height: Math.abs(this.centeringYOffset) > 0.001 ? `${100 * this.nativeHeight / this.nativeWidth * this.props.PanelWidth() / this.props.PanelHeight()}%` : this.props.PanelHeight(), + width: Math.abs(this.centeringOffset) > 0.001 ? `${100 * (this.props.PanelWidth() - this.centeringOffset * 2) / this.props.PanelWidth()}%` : this.props.PanelWidth() }}> <DocumentView {...this.props} - DataDoc={this.props.DataDocument} Document={this.props.Document} + DataDoc={this.props.DataDocument} + LibraryPath={this.props.LibraryPath} fitToBox={this.props.fitToBox} onClick={this.props.onClick} ruleProvider={this.props.ruleProvider} @@ -101,7 +109,7 @@ export class ContentFittingDocumentView extends React.Component<ContentFittingDo pinToPres={this.props.pinToPres} parentActive={this.props.active} ScreenToLocalTransform={this.getTransform} - renderDepth={this.props.renderDepth + 1} + renderDepth={this.props.renderDepth} ContentScaling={this.contentScaling} PanelWidth={this.PanelWidth} PanelHeight={this.PanelHeight} diff --git a/src/client/views/nodes/DocuLinkBox.tsx b/src/client/views/nodes/DocuLinkBox.tsx index a22472e9e..0d4d50c59 100644 --- a/src/client/views/nodes/DocuLinkBox.tsx +++ b/src/client/views/nodes/DocuLinkBox.tsx @@ -1,17 +1,18 @@ import { action, observable } from "mobx"; import { observer } from "mobx-react"; -import { Doc } from "../../../new_fields/Doc"; +import { Doc, WidthSym, HeightSym } from "../../../new_fields/Doc"; import { makeInterface } from "../../../new_fields/Schema"; import { NumCast, StrCast, Cast } from "../../../new_fields/Types"; import { Utils } from '../../../Utils'; import { DocumentManager } from "../../util/DocumentManager"; -import { DragLinksAsDocuments } from "../../util/DragManager"; +import { DragManager } from "../../util/DragManager"; import { DocComponent } from "../DocComponent"; import "./DocuLinkBox.scss"; import { FieldView, FieldViewProps } from "./FieldView"; import React = require("react"); import { DocumentType } from "../../documents/DocumentTypes"; import { documentSchema } from "../../../new_fields/documentSchemas"; +import { Id } from "../../../new_fields/FieldSymbols"; type DocLinkSchema = makeInterface<[typeof documentSchema]>; const DocLinkDocument = makeInterface(documentSchema); @@ -43,7 +44,7 @@ export class DocuLinkBox extends DocComponent<FieldViewProps, DocLinkSchema>(Doc const separation = Math.sqrt((pt[0] - e.clientX) * (pt[0] - e.clientX) + (pt[1] - e.clientY) * (pt[1] - e.clientY)); const dragdist = Math.sqrt((pt[0] - this._downx) * (pt[0] - this._downx) + (pt[1] - this._downy) * (pt[1] - this._downy)); if (separation > 100) { - DragLinksAsDocuments(this._ref.current!, pt[0], pt[1], Cast(this.props.Document[this.props.fieldKey], Doc) as Doc, this.props.Document); // Containging collection is the document, not a collection... hack. + DragManager.StartLinkTargetsDrag(this._ref.current!, pt[0], pt[1], Cast(this.props.Document[this.props.fieldKey], Doc) as Doc, [this.props.Document]); // Containging collection is the document, not a collection... hack. document.removeEventListener("pointermove", this.onPointerMove); document.removeEventListener("pointerup", this.onPointerUp); } else if (dragdist > separation) { @@ -67,18 +68,18 @@ export class DocuLinkBox extends DocComponent<FieldViewProps, DocLinkSchema>(Doc } render() { - const anchorDoc = Cast(this.props.Document[this.props.fieldKey], Doc); - const hasAnchor = anchorDoc instanceof Doc && anchorDoc.type === DocumentType.PDFANNO; - const y = NumCast(this.props.Document[this.props.fieldKey + "_y"], 100); const x = NumCast(this.props.Document[this.props.fieldKey + "_x"], 100); + const y = NumCast(this.props.Document[this.props.fieldKey + "_y"], 100); const c = StrCast(this.props.Document.backgroundColor, "lightblue"); const anchor = this.props.fieldKey === "anchor1" ? "anchor2" : "anchor1"; + const anchorScale = (x === 0 || x === 100 || y === 0 || y === 100) ? 1 : .15; + const timecode = this.props.Document[anchor + "Timecode"]; const targetTitle = StrCast((this.props.Document[anchor]! as Doc).title) + (timecode !== undefined ? ":" + timecode : ""); return <div className="docuLinkBox-cont" onPointerDown={this.onPointerDown} onClick={this.onClick} title={targetTitle} ref={this._ref} style={{ background: c, left: `calc(${x}% - 12.5px)`, top: `calc(${y}% - 12.5px)`, - transform: `scale(${hasAnchor ? 0.333 : 1 / this.props.ContentScaling()})` + transform: `scale(${anchorScale / this.props.ContentScaling()})` }} />; } } diff --git a/src/client/views/nodes/DocumentBox.scss b/src/client/views/nodes/DocumentBox.scss new file mode 100644 index 000000000..b7d06b364 --- /dev/null +++ b/src/client/views/nodes/DocumentBox.scss @@ -0,0 +1,15 @@ +.documentBox-container { + width: 100%; + height: 100%; + pointer-events: all; + background: gray; + border: #00000021 solid 15px; + border-top: #0000005e inset 15px; + border-bottom: #0000005e outset 15px; + .documentBox-lock { + margin: auto; + color: white; + margin-top: -15px; + position: absolute; + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/DocumentBox.tsx b/src/client/views/nodes/DocumentBox.tsx new file mode 100644 index 000000000..863ea748b --- /dev/null +++ b/src/client/views/nodes/DocumentBox.tsx @@ -0,0 +1,112 @@ +import { IReactionDisposer, reaction } from "mobx"; +import { observer } from "mobx-react"; +import { Doc, Field } from "../../../new_fields/Doc"; +import { documentSchema } from "../../../new_fields/documentSchemas"; +import { List } from "../../../new_fields/List"; +import { makeInterface } from "../../../new_fields/Schema"; +import { ComputedField } from "../../../new_fields/ScriptField"; +import { Cast, StrCast, BoolCast } from "../../../new_fields/Types"; +import { emptyFunction, emptyPath } from "../../../Utils"; +import { ContextMenu } from "../ContextMenu"; +import { ContextMenuProps } from "../ContextMenuItem"; +import { DocComponent } from "../DocComponent"; +import { ContentFittingDocumentView } from "./ContentFittingDocumentView"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import "./DocumentBox.scss"; +import { FieldView, FieldViewProps } from "./FieldView"; +import React = require("react"); + +type DocBoxSchema = makeInterface<[typeof documentSchema]>; +const DocBoxDocument = makeInterface(documentSchema); + +@observer +export class DocumentBox extends DocComponent<FieldViewProps, DocBoxSchema>(DocBoxDocument) { + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(DocumentBox, fieldKey); } + _prevSelectionDisposer: IReactionDisposer | undefined; + _selections: Doc[] = []; + _curSelection = -1; + componentDidMount() { + this._prevSelectionDisposer = reaction(() => Cast(this.props.Document[this.props.fieldKey], Doc) as Doc, (data) => { + if (data && !this._selections.includes(data)) { + this._selections.length = ++this._curSelection; + this._selections.push(data); + } + }); + } + componentWillUnmount() { + this._prevSelectionDisposer && this._prevSelectionDisposer(); + } + specificContextMenu = (e: React.MouseEvent): void => { + const funcs: ContextMenuProps[] = []; + funcs.push({ description: (this.isSelectionLocked() ? "Show" : "Lock") + " Selection", event: () => this.toggleLockSelection, icon: "expand-arrows-alt" }); + funcs.push({ description: `${this.props.Document.forceActive ? "Select" : "Force"} Contents Active`, event: () => this.props.Document.forceActive = !this.props.Document.forceActive, icon: "project-diagram" }); + + ContextMenu.Instance.addItem({ description: "DocumentBox Funcs...", subitems: funcs, icon: "asterisk" }); + } + lockSelection = () => { + Doc.GetProto(this.props.Document)[this.props.fieldKey] = this.props.Document[this.props.fieldKey]; + } + showSelection = () => { + Doc.GetProto(this.props.Document)[this.props.fieldKey] = ComputedField.MakeFunction("selectedDocs(this,true,[_last_])?.[0]"); + } + isSelectionLocked = () => { + const kvpstring = Field.toKeyValueString(this.props.Document, this.props.fieldKey); + return !(kvpstring.startsWith("=") || kvpstring.startsWith(":=")); + } + toggleLockSelection = () => { + !this.isSelectionLocked() ? this.lockSelection() : this.showSelection(); + } + prevSelection = () => { + if (this._curSelection > 0) { + Doc.UserDoc().SelectedDocs = new List([this._selections[--this._curSelection]]); + } + } + nextSelection = () => { + if (this._curSelection < this._selections.length - 1 && this._selections.length) { + Doc.UserDoc().SelectedDocs = new List([this._selections[++this._curSelection]]); + } + } + onPointerDown = (e: React.PointerEvent) => { + } + onClick = (e: React.MouseEvent) => { + if (this._contRef.current!.getBoundingClientRect().top + 15 > e.clientY) this.toggleLockSelection(); + else { + if (this._contRef.current!.getBoundingClientRect().left + 15 > e.clientX) this.prevSelection(); + if (this._contRef.current!.getBoundingClientRect().right - 15 < e.clientX) this.nextSelection(); + } + } + _contRef = React.createRef<HTMLDivElement>(); + pwidth = () => this.props.PanelWidth() - 30; + pheight = () => this.props.PanelHeight() - 30; + getTransform = () => this.props.ScreenToLocalTransform().translate(-15, -15); + render() { + const containedDoc = this.props.Document[this.props.fieldKey] as Doc; + return <div className="documentBox-container" ref={this._contRef} + onContextMenu={this.specificContextMenu} + onPointerDown={this.onPointerDown} onClick={this.onClick} + style={{ background: StrCast(this.props.Document.backgroundColor) }}> + <div className="documentBox-lock"> + <FontAwesomeIcon icon={this.isSelectionLocked() ? "lock" : "unlock"} size="sm" /> + </div> + {!(containedDoc instanceof Doc) ? (null) : <ContentFittingDocumentView + Document={containedDoc} + DataDocument={undefined} + LibraryPath={emptyPath} + fitToBox={this.props.fitToBox} + addDocument={this.props.addDocument} + moveDocument={this.props.moveDocument} + removeDocument={this.props.removeDocument} + ruleProvider={this.props.ruleProvider} + addDocTab={this.props.addDocTab} + pinToPres={this.props.pinToPres} + getTransform={this.getTransform} + renderDepth={this.props.Document.forceActive ? 0 : this.props.renderDepth + 1} // bcz: really need to have an 'alwaysSelected' prop that's not conflated with renderDepth + PanelWidth={this.pwidth} + PanelHeight={this.pheight} + focus={this.props.focus} + active={this.props.active} + whenActiveChanged={this.props.whenActiveChanged} + />} + </div>; + } +} diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx index 13fd3cde1..66886165e 100644 --- a/src/client/views/nodes/DocumentContentsView.tsx +++ b/src/client/views/nodes/DocumentContentsView.tsx @@ -14,6 +14,7 @@ import { LinkFollowBox } from "../linking/LinkFollowBox"; import { YoutubeBox } from "./../../apis/youtube/YoutubeBox"; import { AudioBox } from "./AudioBox"; import { ButtonBox } from "./ButtonBox"; +import { DocumentBox } from "./DocumentBox"; import { DocumentViewProps } from "./DocumentView"; import "./DocumentView.scss"; import { FontIconBox } from "./FontIconBox"; @@ -32,6 +33,7 @@ import { VideoBox } from "./VideoBox"; import { WebBox } from "./WebBox"; import { InkingStroke } from "../InkingStroke"; import React = require("react"); +import { TraceMobx } from "../../../new_fields/util"; const JsxParser = require('react-jsx-parser').default; //TODO Why does this need to be imported like this? type BindingProps = Without<FieldViewProps, 'fieldKey'>; @@ -57,6 +59,7 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & { hideOnLeave?: boolean }> { @computed get layout(): string { + TraceMobx(); if (!this.layoutDoc) return "<p>awaiting layout</p>"; const layout = Cast(this.layoutDoc[this.props.layoutKey], "string"); if (layout === undefined) { @@ -79,7 +82,7 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & { return this.props.DataDoc; } get layoutDoc() { - return this.props.DataDoc === undefined ? Doc.expandTemplateLayout(Doc.Layout(this.props.Document), this.props.Document) : Doc.Layout(this.props.Document); + return Doc.expandTemplateLayout(Doc.Layout(this.props.Document), this.props.Document); } CreateBindings(): JsxBindings { @@ -92,6 +95,7 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & { } render() { + TraceMobx(); return (this.props.renderDepth > 7 || !this.layout) ? (null) : <ObserverJsxParser blacklistedAttrs={[]} @@ -99,7 +103,7 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & { FormattedTextBox, ImageBox, IconBox, DirectoryImportBox, FontIconBox: FontIconBox, ButtonBox, FieldView, CollectionFreeFormView, CollectionDockingView, CollectionSchemaView, CollectionView, WebBox, KeyValueBox, PDFBox, VideoBox, AudioBox, HistogramBox, PresBox, YoutubeBox, LinkFollowBox, PresElementBox, QueryBox, - ColorBox, DocuLinkBox, InkingStroke + ColorBox, DocuLinkBox, InkingStroke, DocumentBox }} bindings={this.CreateBindings()} jsx={this.layout} diff --git a/src/client/views/nodes/DocumentView.scss b/src/client/views/nodes/DocumentView.scss index dfb84ed5c..2ce56c73d 100644 --- a/src/client/views/nodes/DocumentView.scss +++ b/src/client/views/nodes/DocumentView.scss @@ -39,6 +39,7 @@ transform-origin: top left; width: 100%; height: 100%; + z-index: 1; } .documentView-styleWrapper { @@ -54,7 +55,7 @@ position: absolute; } - .documentView-titleWrapper { + .documentView-titleWrapper, .documentView-titleWrapper-hover { overflow: hidden; color: white; transform-origin: top left; @@ -67,6 +68,9 @@ text-overflow: ellipsis; white-space: pre; } + .documentView-titleWrapper-hover { + display:none; + } .documentView-searchHighlight { position: absolute; @@ -84,4 +88,12 @@ } } +} + +.documentView-node:hover, .documentView-node-topmost:hover { + > .documentView-styleWrapper { + > .documentView-titleWrapper-hover { + display:inline-block; + } + } }
\ No newline at end of file diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 63ce6233c..dabe5a7aa 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -4,7 +4,7 @@ import { action, computed, runInAction, trace } from "mobx"; import { observer } from "mobx-react"; import * as rp from "request-promise"; import { Doc, DocListCast, DocListCastAsync, Opt } from "../../../new_fields/Doc"; -import { Document } from '../../../new_fields/documentSchemas'; +import { Document, PositionDocument } from '../../../new_fields/documentSchemas'; import { Id } from '../../../new_fields/FieldSymbols'; import { listSpec } from "../../../new_fields/Schema"; import { ScriptField } from '../../../new_fields/ScriptField'; @@ -19,7 +19,6 @@ import { DocumentType } from '../../documents/DocumentTypes'; import { ClientUtils } from '../../util/ClientUtils'; import { DocumentManager } from "../../util/DocumentManager"; import { DragManager, dropActionType } from "../../util/DragManager"; -import { LinkManager } from '../../util/LinkManager'; import { Scripting } from '../../util/Scripting'; import { SelectionManager } from "../../util/SelectionManager"; import SharingManager from '../../util/SharingManager'; @@ -30,7 +29,6 @@ import { CollectionDockingView } from "../collections/CollectionDockingView"; import { CollectionView } from "../collections/CollectionView"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from '../ContextMenuItem'; -import { DictationOverlay } from '../DictationOverlay'; import { DocComponent } from "../DocComponent"; import { EditableView } from '../EditableView'; import { OverlayView } from '../OverlayView'; @@ -44,6 +42,8 @@ import { InteractionUtils } from '../../util/InteractionUtils'; import { InkingControl } from '../InkingControl'; import { InkTool } from '../../../new_fields/InkField'; import { TraceMobx } from '../../../new_fields/util'; +import { List } from '../../../new_fields/List'; +import { FormattedTextBoxComment } from './FormattedTextBoxComment'; library.add(fa.faEdit, fa.faTrash, fa.faShare, fa.faDownload, fa.faExpandArrowsAlt, fa.faCompressArrowsAlt, fa.faLayerGroup, fa.faExternalLinkAlt, fa.faAlignCenter, fa.faCaretSquareRight, fa.faSquare, fa.faConciergeBell, fa.faWindowRestore, fa.faFolder, fa.faMapPin, fa.faLink, fa.faFingerprint, fa.faCrosshairs, fa.faDesktop, fa.faUnlock, fa.faLock, fa.faLaptopCode, fa.faMale, @@ -54,14 +54,16 @@ export interface DocumentViewProps { ContainingCollectionDoc: Opt<Doc>; Document: Doc; DataDoc?: Doc; + LibraryPath: Doc[]; fitToBox?: boolean; onClick?: ScriptField; + dragDivName?: string; addDocument?: (doc: Doc) => boolean; removeDocument?: (doc: Doc) => boolean; - moveDocument?: (doc: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean) => boolean; + moveDocument?: (doc: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean) => boolean; ScreenToLocalTransform: () => Transform; renderDepth: number; - showOverlays?: (doc: Doc) => { title?: string, caption?: string }; + showOverlays?: (doc: Doc) => { title?: string, titleHover?: string, caption?: string }; ContentScaling: () => number; ruleProvider: Doc | undefined; PanelWidth: () => number; @@ -70,7 +72,7 @@ export interface DocumentViewProps { parentActive: (outsideReaction: boolean) => boolean; whenActiveChanged: (isActive: boolean) => void; bringToFront: (doc: Doc, sendToBack?: boolean) => void; - addDocTab: (doc: Doc, dataDoc: Doc | undefined, where: string) => boolean; + addDocTab: (doc: Doc, dataDoc: Doc | undefined, where: string, libraryPath?: Doc[]) => boolean; pinToPres: (document: Doc) => void; zoomToScale: (scale: number) => void; backgroundColor: (doc: Doc) => string | undefined; @@ -91,6 +93,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu private _hitTemplateDrag = false; private _mainCont = React.createRef<HTMLDivElement>(); private _dropDisposer?: DragManager.DragDropDisposer; + private _titleRef = React.createRef<EditableView>(); public get displayName() { return "DocumentView(" + this.props.Document.title + ")"; } // this makes mobx trace() statements more descriptive public get ContentDiv() { return this._mainCont.current; } @@ -102,7 +105,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu @action componentDidMount() { - this._mainCont.current && (this._dropDisposer = DragManager.MakeDropTarget(this._mainCont.current, { handlers: { drop: this.drop.bind(this) } })); + this._mainCont.current && (this._dropDisposer = DragManager.MakeDropTarget(this._mainCont.current, this.drop.bind(this))); !this.props.dontRegisterView && DocumentManager.Instance.DocumentViews.push(this); } @@ -110,7 +113,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu @action componentDidUpdate() { this._dropDisposer && this._dropDisposer(); - this._mainCont.current && (this._dropDisposer = DragManager.MakeDropTarget(this._mainCont.current, { handlers: { drop: this.drop.bind(this) } })); + this._mainCont.current && (this._dropDisposer = DragManager.MakeDropTarget(this._mainCont.current, this.drop.bind(this))); } @action @@ -126,14 +129,50 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu const [left, top] = this.props.ScreenToLocalTransform().scale(this.props.ContentScaling()).inverse().transformPoint(0, 0); dragData.offset = this.props.ScreenToLocalTransform().scale(this.props.ContentScaling()).transformDirection(x - left, y - top); dragData.dropAction = dropAction; - dragData.moveDocument = this.Document.onDragStart ? undefined : this.props.moveDocument; + dragData.moveDocument = this.props.moveDocument;// this.Document.onDragStart ? undefined : this.props.moveDocument; dragData.applyAsTemplate = applyAsTemplate; - DragManager.StartDocumentDrag([this._mainCont.current], dragData, x, y, { - handlers: { - dragComplete: action((emptyFunction)) - }, - hideSource: !dropAction && !this.Document.onDragStart - }); + dragData.dragDivName = this.props.dragDivName; + DragManager.StartDocumentDrag([this._mainCont.current], dragData, x, y, { hideSource: !dropAction && !this.Document.onDragStart }); + } + } + + public static FloatDoc(topDocView: DocumentView, x: number, y: number) { + const topDoc = topDocView.props.Document; + const de = new DragManager.DocumentDragData([topDoc]); + de.dragDivName = topDocView.props.dragDivName; + de.moveDocument = topDocView.props.moveDocument; + undoBatch(action(() => topDoc.z = topDoc.z ? 0 : 1))(); + setTimeout(() => { + const newDocView = DocumentManager.Instance.getDocumentView(topDoc); + if (newDocView) { + const contentDiv = newDocView.ContentDiv!; + const xf = contentDiv.getBoundingClientRect(); + DragManager.StartDocumentDrag([contentDiv], de, x, y, { offsetX: x - xf.left, offsetY: y - xf.top, hideSource: true }); + } + }, 0); + } + + onKeyDown = (e: React.KeyboardEvent) => { + if (e.altKey && !(e.nativeEvent as any).StopPropagationForReal) { + (e.nativeEvent as any).StopPropagationForReal = true; // e.stopPropagation() doesn't seem to work... + e.stopPropagation(); + e.preventDefault(); + if (e.key === "†" || e.key === "t") { + if (!StrCast(this.layoutDoc.showTitle)) this.layoutDoc.showTitle = "title"; + if (!this._titleRef.current) setTimeout(() => this._titleRef.current?.setIsFocused(true), 0); + else if (!this._titleRef.current.setIsFocused(true)) { // if focus didn't change, focus on interior text... + { + this._titleRef.current?.setIsFocused(false); + const any = (this._mainCont.current?.getElementsByClassName("ProseMirror")?.[0] as any); + any.keeplocation = true; + any?.focus(); + } + } + } else if (e.key === "f") { + const ex = (e.nativeEvent.target! as any).getBoundingClientRect().left; + const ey = (e.nativeEvent.target! as any).getBoundingClientRect().top; + DocumentView.FloatDoc(this, ex, ey); + } } } @@ -154,6 +193,9 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu this.onClickHandler.script.run({ this: this.Document.isTemplateField && this.props.DataDoc ? this.props.DataDoc : this.props.Document }, console.log); } else if (this.Document.type === DocumentType.BUTTON) { ScriptBox.EditButtonScript("On Button Clicked ...", this.props.Document, "onClick", e.clientX, e.clientY); + } else if (this.props.Document.isButton === "Selector") { // this should be moved to an OnClick script + FormattedTextBoxComment.Hide(); + this.Document.links?.[0] instanceof Doc && (Doc.UserDoc().SelectedDocs = new List([Doc.LinkOtherAnchor(this.Document.links[0], this.props.Document)])); } else if (this.Document.isButton) { SelectionManager.SelectDoc(this, e.ctrlKey); // don't think this should happen if a button action is actually triggered. this.buttonClick(e.altKey, e.ctrlKey); @@ -168,7 +210,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu buttonClick = async (altKey: boolean, ctrlKey: boolean) => { const maximizedDocs = await DocListCastAsync(this.Document.maximizedDocs); const summarizedDocs = await DocListCastAsync(this.Document.summarizedDocs); - const linkDocs = LinkManager.Instance.getAllRelatedLinks(this.props.Document); + const linkDocs = DocListCast(this.props.Document.links); let expandedDocs: Doc[] = []; expandedDocs = maximizedDocs ? [...maximizedDocs, ...expandedDocs] : expandedDocs; expandedDocs = summarizedDocs ? [...summarizedDocs, ...expandedDocs] : expandedDocs; @@ -193,28 +235,166 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu } } + handle1PointerDown = (e: React.TouchEvent) => { + if (!e.nativeEvent.cancelBubble) { + const touch = InteractionUtils.GetMyTargetTouches(e, this.prevPoints)[0]; + this._downX = touch.clientX; + this._downY = touch.clientY; + this._hitTemplateDrag = false; + for (let element = (e.target as any); element && !this._hitTemplateDrag; element = element.parentElement) { + if (element.className && element.className.toString() === "collectionViewBaseChrome-collapse") { + this._hitTemplateDrag = true; + } + } + if ((this.active || this.Document.onDragStart || this.Document.onClick) && !e.ctrlKey && !this.Document.lockedPosition && !this.Document.inOverlay) e.stopPropagation(); + document.removeEventListener("touchmove", this.onTouch); + document.addEventListener("touchmove", this.onTouch); + document.removeEventListener("touchend", this.onTouchEnd); + document.addEventListener("touchend", this.onTouchEnd); + if ((e.nativeEvent as any).formattedHandled) e.stopPropagation(); + } + } + + handle1PointerMove = (e: TouchEvent) => { + if ((e as any).formattedHandled) { e.stopPropagation; return; } + if (e.cancelBubble && this.active) { + document.removeEventListener("touchmove", this.onTouch); + } + else if (!e.cancelBubble && (SelectionManager.IsSelected(this, true) || this.props.parentActive(true) || this.Document.onDragStart || this.Document.onClick) && !this.Document.lockedPosition && !this.Document.inOverlay) { + const touch = InteractionUtils.GetMyTargetTouches(e, this.prevPoints)[0]; + if (Math.abs(this._downX - touch.clientX) > 3 || Math.abs(this._downY - touch.clientY) > 3) { + if (!e.altKey && (!this.topMost || this.Document.onDragStart || this.Document.onClick)) { + document.removeEventListener("touchmove", this.onTouch); + document.removeEventListener("touchend", this.onTouchEnd); + this.startDragging(this._downX, this._downY, this.Document.dropAction ? this.Document.dropAction as any : e.ctrlKey || e.altKey ? "alias" : undefined, this._hitTemplateDrag); + } + } + e.stopPropagation(); // doesn't actually stop propagation since all our listeners are listening to events on 'document' however it does mark the event as cancelBubble=true which we test for in the move event handlers + e.preventDefault(); + + } + } + + handle2PointersDown = (e: React.TouchEvent) => { + if (!e.nativeEvent.cancelBubble && !this.isSelected()) { + e.stopPropagation(); + e.preventDefault(); + + document.removeEventListener("touchmove", this.onTouch); + document.addEventListener("touchmove", this.onTouch); + document.removeEventListener("touchend", this.onTouchEnd); + document.addEventListener("touchend", this.onTouchEnd); + } + } + + @action + handle2PointersMove = (e: TouchEvent) => { + const myTouches = InteractionUtils.GetMyTargetTouches(e, this.prevPoints); + const pt1 = myTouches[0]; + const pt2 = myTouches[1]; + const oldPoint1 = this.prevPoints.get(pt1.identifier); + const oldPoint2 = this.prevPoints.get(pt2.identifier); + const pinching = InteractionUtils.Pinning(pt1, pt2, oldPoint1!, oldPoint2!); + if (pinching !== 0 && oldPoint1 && oldPoint2) { + // let dX = (Math.min(pt1.clientX, pt2.clientX) - Math.min(oldPoint1.clientX, oldPoint2.clientX)); + // let dY = (Math.min(pt1.clientY, pt2.clientY) - Math.min(oldPoint1.clientY, oldPoint2.clientY)); + // let dX = Math.sign(Math.abs(pt1.clientX - oldPoint1.clientX) - Math.abs(pt2.clientX - oldPoint2.clientX)); + // let dY = Math.sign(Math.abs(pt1.clientY - oldPoint1.clientY) - Math.abs(pt2.clientY - oldPoint2.clientY)); + // let dW = -dX; + // let dH = -dY; + const dW = (Math.abs(pt1.clientX - pt2.clientX) - Math.abs(oldPoint1.clientX - oldPoint2.clientX)); + const dH = (Math.abs(pt1.clientY - pt2.clientY) - Math.abs(oldPoint1.clientY - oldPoint2.clientY)); + const dX = -1 * Math.sign(dW); + const dY = -1 * Math.sign(dH); + + if (dX !== 0 || dY !== 0 || dW !== 0 || dH !== 0) { + const doc = PositionDocument(this.props.Document); + const layoutDoc = PositionDocument(Doc.Layout(this.props.Document)); + let nwidth = layoutDoc.nativeWidth || 0; + let nheight = layoutDoc.nativeHeight || 0; + const width = (layoutDoc.width || 0); + const height = (layoutDoc.height || (nheight / nwidth * width)); + const scale = this.props.ScreenToLocalTransform().Scale * this.props.ContentScaling(); + const actualdW = Math.max(width + (dW * scale), 20); + const actualdH = Math.max(height + (dH * scale), 20); + doc.x = (doc.x || 0) + dX * (actualdW - width); + doc.y = (doc.y || 0) + dY * (actualdH - height); + const fixedAspect = e.ctrlKey || (!layoutDoc.ignoreAspect && nwidth && nheight); + if (fixedAspect && e.ctrlKey && layoutDoc.ignoreAspect) { + layoutDoc.ignoreAspect = false; + layoutDoc.nativeWidth = nwidth = layoutDoc.width || 0; + layoutDoc.nativeHeight = nheight = layoutDoc.height || 0; + } + if (fixedAspect && (!nwidth || !nheight)) { + layoutDoc.nativeWidth = nwidth = layoutDoc.width || 0; + layoutDoc.nativeHeight = nheight = layoutDoc.height || 0; + } + if (nwidth > 0 && nheight > 0 && !layoutDoc.ignoreAspect) { + if (Math.abs(dW) > Math.abs(dH)) { + if (!fixedAspect) { + layoutDoc.nativeWidth = actualdW / (layoutDoc.width || 1) * (layoutDoc.nativeWidth || 0); + } + layoutDoc.width = actualdW; + if (fixedAspect && !layoutDoc.fitWidth) layoutDoc.height = nheight / nwidth * layoutDoc.width; + else layoutDoc.height = actualdH; + } + else { + if (!fixedAspect) { + layoutDoc.nativeHeight = actualdH / (layoutDoc.height || 1) * (doc.nativeHeight || 0); + } + layoutDoc.height = actualdH; + if (fixedAspect && !layoutDoc.fitWidth) layoutDoc.width = nwidth / nheight * layoutDoc.height; + else layoutDoc.width = actualdW; + } + } else { + dW && (layoutDoc.width = actualdW); + dH && (layoutDoc.height = actualdH); + dH && layoutDoc.autoHeight && (layoutDoc.autoHeight = false); + } + } + // let newWidth = Math.max(Math.abs(oldPoint1!.clientX - oldPoint2!.clientX), Math.abs(pt1.clientX - pt2.clientX)) + // this.props.Document.width = newWidth; + e.stopPropagation(); + e.preventDefault(); + } + } + onPointerDown = (e: React.PointerEvent): void => { - if ((e.nativeEvent.cancelBubble && (e.button === 0 || InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE))) - // return if we're inking, and not selecting a button document - || (InkingControl.Instance.selectedTool !== InkTool.None && !this.Document.onClick) - // return if using pen or eraser - || InteractionUtils.IsType(e, InteractionUtils.PENTYPE) || InteractionUtils.IsType(e, InteractionUtils.ERASERTYPE)) return; - this._downX = e.clientX; - this._downY = e.clientY; - this._hitTemplateDrag = false; - // this whole section needs to move somewhere else. We're trying to initiate a special "template" drag where - // this document is the template and we apply it to whatever we drop it on. - for (let element = (e.target as any); element && !this._hitTemplateDrag; element = element.parentElement) { - if (element.className && element.className.toString() === "collectionViewBaseChrome-collapse") { - this._hitTemplateDrag = true; + // console.log(e.button) + // console.log(e.nativeEvent) + // continue if the event hasn't been canceled AND we are using a moues or this is has an onClick or onDragStart function (meaning it is a button document) + if (!InteractionUtils.IsType(e, InteractionUtils.MOUSETYPE)) { + if (!InteractionUtils.IsType(e, InteractionUtils.PENTYPE)) { + e.stopPropagation(); } + return; + } + if ((!e.nativeEvent.cancelBubble || this.Document.onClick || this.Document.onDragStart)) { + // if ((e.nativeEvent.cancelBubble && (e.button === 0 || InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE))) + // // return if we're inking, and not selecting a button document + // || (InkingControl.Instance.selectedTool !== InkTool.None && !this.Document.onClick) + // // return if using pen or eraser + // || InteractionUtils.IsType(e, InteractionUtils.PENTYPE) || InteractionUtils.IsType(e, InteractionUtils.ERASERTYPE)) { + // return; + // } + + this._downX = e.clientX; + this._downY = e.clientY; + this._hitTemplateDrag = false; + // this whole section needs to move somewhere else. We're trying to initiate a special "template" drag where + // this document is the template and we apply it to whatever we drop it on. + for (let element = (e.target as any); element && !this._hitTemplateDrag; element = element.parentElement) { + if (element.className && element.className.toString() === "collectionViewBaseChrome-collapse") { + this._hitTemplateDrag = true; + } + } + if ((this.active || this.Document.onDragStart || this.Document.onClick) && !e.ctrlKey && (e.button === 0 || InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) && !this.Document.lockedPosition && !this.Document.inOverlay) e.stopPropagation(); // events stop at the lowest document that is active. if right dragging, we let it go through though to allow for context menu clicks. PointerMove callbacks should remove themselves if the move event gets stopPropagated by a lower-level handler (e.g, marquee drag); + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); + document.addEventListener("pointermove", this.onPointerMove); + document.addEventListener("pointerup", this.onPointerUp); + if ((e.nativeEvent as any).formattedHandled) { e.stopPropagation(); } } - if ((this.active || this.Document.onDragStart || this.Document.onClick) && !e.ctrlKey && (e.button === 0 || InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) && !this.Document.lockedPosition && !this.Document.inOverlay) e.stopPropagation(); // events stop at the lowest document that is active. if right dragging, we let it go through though to allow for context menu clicks. PointerMove callbacks should remove themselves if the move event gets stopPropagated by a lower-level handler (e.g, marquee drag); - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); - document.addEventListener("pointermove", this.onPointerMove); - document.addEventListener("pointerup", this.onPointerUp); - if ((e.nativeEvent as any).formattedHandled) { e.stopPropagation(); } } onPointerMove = (e: PointerEvent): void => { @@ -300,25 +480,37 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu } @undoBatch + makeSelBtnClicked = (): void => { + if (this.Document.isButton || this.Document.onClick || this.Document.ignoreClick) { + this.Document.isButton = false; + this.Document.ignoreClick = false; + this.Document.onClick = undefined; + } else { + this.props.Document.isButton = "Selector"; + } + } + + @undoBatch @action drop = async (e: Event, de: DragManager.DropEvent) => { - if (de.data instanceof DragManager.AnnotationDragData) { + if (de.complete.annoDragData) { /// this whole section for handling PDF annotations looks weird. Need to rethink this to make it cleaner e.stopPropagation(); - (de.data as any).linkedToDoc = true; + de.complete.annoDragData.linkedToDoc = true; - DocUtils.MakeLink({ doc: de.data.annotationDocument }, { doc: this.props.Document, ctx: this.props.ContainingCollectionDoc }, `Link from ${StrCast(de.data.annotationDocument.title)}`); + DocUtils.MakeLink({ doc: de.complete.annoDragData.annotationDocument }, { doc: this.props.Document, ctx: this.props.ContainingCollectionDoc }, + `Link from ${StrCast(de.complete.annoDragData.annotationDocument.title)}`); } - if (de.data instanceof DragManager.DocumentDragData && de.data.applyAsTemplate) { - Doc.ApplyTemplateTo(de.data.draggedDocuments[0], this.props.Document, "layoutCustom"); + if (de.complete.docDragData && de.complete.docDragData.applyAsTemplate) { + Doc.ApplyTemplateTo(de.complete.docDragData.draggedDocuments[0], this.props.Document, "layoutCustom"); e.stopPropagation(); } - if (de.data instanceof DragManager.LinkDragData) { + if (de.complete.linkDragData) { e.stopPropagation(); // const docs = await SearchUtil.Search(`data_l:"${destDoc[Id]}"`, true); // const views = docs.map(d => DocumentManager.Instance.getDocumentView(d)).filter(d => d).map(d => d as DocumentView); - de.data.linkSourceDocument !== this.props.Document && - (de.data.linkDocument = DocUtils.MakeLink({ doc: de.data.linkSourceDocument }, { doc: this.props.Document, ctx: this.props.ContainingCollectionDoc }, "in-text link being created")); // TODODO this is where in text links get passed + de.complete.linkDragData.linkSourceDocument !== this.props.Document && + (de.complete.linkDragData.linkDocument = DocUtils.MakeLink({ doc: de.complete.linkDragData.linkSourceDocument }, { doc: this.props.Document, ctx: this.props.ContainingCollectionDoc }, "in-text link being created")); // TODODO this is where in text links get passed } } @@ -390,6 +582,11 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu @action onContextMenu = async (e: React.MouseEvent): Promise<void> => { + // the touch onContextMenu is button 0, the pointer onContextMenu is button 2 + if (e.button === 0 && !e.ctrlKey) { + e.preventDefault(); + return; + } e.persist(); e.stopPropagation(); if (Math.abs(this._downX - e.clientX) > 3 || Math.abs(this._downY - e.clientY) > 3 || @@ -401,9 +598,9 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu const cm = ContextMenu.Instance; const subitems: ContextMenuProps[] = []; - subitems.push({ description: "Open Full Screen", event: () => CollectionDockingView.Instance && CollectionDockingView.Instance.OpenFullScreen(this), icon: "desktop" }); - subitems.push({ description: "Open Tab ", event: () => this.props.addDocTab(this.props.Document, this.props.DataDoc, "inTab"), icon: "folder" }); - subitems.push({ description: "Open Right ", event: () => this.props.addDocTab(this.props.Document, this.props.DataDoc, "onRight"), icon: "caret-square-right" }); + subitems.push({ description: "Open Full Screen", event: () => CollectionDockingView.Instance && CollectionDockingView.Instance.OpenFullScreen(this, this.props.LibraryPath), icon: "desktop" }); + subitems.push({ description: "Open Tab ", event: () => this.props.addDocTab(this.props.Document, this.props.DataDoc, "inTab", this.props.LibraryPath), icon: "folder" }); + subitems.push({ description: "Open Right ", event: () => this.props.addDocTab(this.props.Document, this.props.DataDoc, "onRight", this.props.LibraryPath), icon: "caret-square-right" }); subitems.push({ description: "Open Alias Tab ", event: () => this.props.addDocTab(Doc.MakeAlias(this.props.Document), this.props.DataDoc, "inTab"), icon: "folder" }); subitems.push({ description: "Open Alias Right", event: () => this.props.addDocTab(Doc.MakeAlias(this.props.Document), this.props.DataDoc, "onRight"), icon: "caret-square-right" }); subitems.push({ description: "Open Fields ", event: () => this.props.addDocTab(Docs.Create.KVPDocument(this.props.Document, { width: 300, height: 300 }), undefined, "onRight"), icon: "layer-group" }); @@ -416,13 +613,8 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu onClicks.push({ description: "Toggle Detail", event: () => this.Document.onClick = ScriptField.MakeScript("toggleDetail(this)"), icon: "window-restore" }); onClicks.push({ description: this.Document.ignoreClick ? "Select" : "Do Nothing", event: () => this.Document.ignoreClick = !this.Document.ignoreClick, icon: this.Document.ignoreClick ? "unlock" : "lock" }); onClicks.push({ description: this.Document.isButton || this.Document.onClick ? "Remove Click Behavior" : "Follow Link", event: this.makeBtnClicked, icon: "concierge-bell" }); + onClicks.push({ description: this.props.Document.isButton ? "Remove Select Link Behavior" : "Select Link", event: this.makeSelBtnClicked, icon: "concierge-bell" }); onClicks.push({ description: "Edit onClick Script", icon: "edit", event: (obj: any) => ScriptBox.EditButtonScript("On Button Clicked ...", this.props.Document, "onClick", obj.x, obj.y) }); - onClicks.push({ - description: "Edit onClick Foreach Doc Script", icon: "edit", event: (obj: any) => { - this.props.Document.collectionContext = this.props.ContainingCollectionDoc; - ScriptBox.EditButtonScript("Foreach Collection Doc (d) => ", this.props.Document, "onClick", obj.x, obj.y, "docList(this.collectionContext.data).map(d => {", "});\n"); - } - }); !existingOnClick && cm.addItem({ description: "OnClick...", subitems: onClicks, icon: "hand-point-right" }); const funcs: ContextMenuProps[] = []; @@ -527,10 +719,17 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu SelectionManager.SelectDoc(this, false); } }); + const path = this.props.LibraryPath.reduce((p: string, d: Doc) => p + "/" + (Doc.AreProtosEqual(d, (Doc.UserDoc().LibraryBtn as Doc).sourcePanel as Doc) ? "" : d.title), ""); + cm.addItem({ + description: `path: ${path}`, event: () => { + this.props.LibraryPath.map(lp => Doc.GetProto(lp).treeViewOpen = lp.treeViewOpen = true); + Doc.linkFollowHighlight(this.props.Document); + }, icon: "check" + }); } // does Document set a layout prop - setsLayoutProp = (prop: string) => this.props.Document[prop] !== this.props.Document["default" + prop[0].toUpperCase() + prop.slice(1)]; + setsLayoutProp = (prop: string) => this.props.Document[prop] !== this.props.Document["default" + prop[0].toUpperCase() + prop.slice(1)] && this.props.Document["default" + prop[0].toUpperCase() + prop.slice(1)]; // get the a layout prop by first choosing the prop from Document, then falling back to the layout doc otherwise. getLayoutPropStr = (prop: string) => StrCast(this.setsLayoutProp(prop) ? this.props.Document[prop] : this.layoutDoc[prop]); getLayoutPropNum = (prop: string) => NumCast(this.setsLayoutProp(prop) ? this.props.Document[prop] : this.layoutDoc[prop]); @@ -540,8 +739,9 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu chromeHeight = () => { const showOverlays = this.props.showOverlays ? this.props.showOverlays(this.Document) : undefined; - const showTitle = showOverlays && "title" in showOverlays ? showOverlays.title : StrCast(this.Document.showTitle); - return (showTitle ? 25 : 0) + 1; + const showTitle = showOverlays && "title" in showOverlays ? showOverlays.title : StrCast(this.layoutDoc.showTitle); + const showTitleHover = showOverlays && "titleHover" in showOverlays ? showOverlays.titleHover : StrCast(this.layoutDoc.showTitleHover); + return (showTitle && !showTitleHover ? 0 : 0) + 1; } @computed get finalLayoutKey() { return this.props.layoutKey || "layout"; } @@ -551,7 +751,9 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu return (<DocumentContentsView ContainingCollectionView={this.props.ContainingCollectionView} ContainingCollectionDoc={this.props.ContainingCollectionDoc} Document={this.props.Document} + DataDoc={this.props.DataDoc} fitToBox={this.props.fitToBox} + LibraryPath={this.props.LibraryPath} addDocument={this.props.addDocument} removeDocument={this.props.removeDocument} moveDocument={this.props.moveDocument} @@ -576,8 +778,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu isSelected={this.isSelected} select={this.select} onClick={this.onClickHandler} - layoutKey={this.finalLayoutKey} - DataDoc={this.props.DataDoc} />); + layoutKey={this.finalLayoutKey} />); } linkEndpoint = (linkDoc: Doc) => Doc.LinkEndpoint(linkDoc, this.props.Document); @@ -593,9 +794,10 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu @computed get innards() { TraceMobx(); const showOverlays = this.props.showOverlays ? this.props.showOverlays(this.Document) : undefined; - const showTitle = showOverlays && "title" in showOverlays ? showOverlays.title : this.getLayoutPropStr("showTitle"); + const showTitle = showOverlays && "title" in showOverlays ? showOverlays.title : StrCast(this.getLayoutPropStr("showTitle")); + const showTitleHover = showOverlays && "titleHover" in showOverlays ? showOverlays.titleHover : StrCast(this.getLayoutPropStr("showTitleHover")); const showCaption = showOverlays && "caption" in showOverlays ? showOverlays.caption : this.getLayoutPropStr("showCaption"); - const showTextTitle = showTitle && StrCast(this.Document.layout).indexOf("FormattedTextBox") !== -1 ? showTitle : undefined; + const showTextTitle = showTitle && StrCast(this.layoutDoc.layout).indexOf("FormattedTextBox") !== -1 ? showTitle : undefined; const searchHighlight = (!this.Document.searchFields ? (null) : <div className="documentView-searchHighlight"> {this.Document.searchFields} @@ -609,15 +811,15 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu /> </div>); const titleView = (!showTitle ? (null) : - <div className="documentView-titleWrapper" style={{ + <div className={`documentView-titleWrapper${showTitleHover ? "-hover" : ""}`} style={{ position: showTextTitle ? "relative" : "absolute", pointerEvents: SelectionManager.GetIsDragging() ? "none" : "all", }}> - <EditableView - contents={this.Document[showTitle]} + <EditableView ref={this._titleRef} + contents={(this.props.DataDoc || this.props.Document)[showTitle]} display={"block"} height={72} fontSize={12} - GetValue={() => StrCast(this.Document[showTitle])} - SetValue={(value: string) => (Doc.GetProto(this.Document)[showTitle] = value) ? true : true} + GetValue={() => StrCast((this.props.DataDoc || this.props.Document)[showTitle])} + SetValue={undoBatch((value: string) => (Doc.GetProto(this.props.DataDoc || this.props.Document)[showTitle] = value) ? true : true)} /> </div>); return <> @@ -649,23 +851,8 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu return (this.Document.isBackground && !this.isSelected()) || (this.Document.type === DocumentType.INK && InkingControl.Instance.selectedTool !== InkTool.None); } - @action - handle2PointersMove = (e: TouchEvent) => { - const pt1 = e.targetTouches.item(0); - const pt2 = e.targetTouches.item(1); - if (pt1 && pt2 && this.prevPoints.has(pt1.identifier) && this.prevPoints.has(pt2.identifier)) { - const oldPoint1 = this.prevPoints.get(pt1.identifier); - const oldPoint2 = this.prevPoints.get(pt2.identifier); - const pinching = InteractionUtils.Pinning(pt1, pt2, oldPoint1!, oldPoint2!); - if (pinching !== 0) { - const newWidth = Math.max(Math.abs(oldPoint1!.clientX - oldPoint2!.clientX), Math.abs(pt1.clientX - pt2.clientX)); - this.props.Document.width = newWidth; - } - } - } - render() { - if (!this.props.Document) return (null); + if (!(this.props.Document instanceof Doc)) return (null); const ruleColor = this.props.ruleProvider ? StrCast(this.props.ruleProvider["ruleColor_" + this.Document.heading]) : undefined; const ruleRounding = this.props.ruleProvider ? StrCast(this.props.ruleProvider["ruleRounding_" + this.Document.heading]) : undefined; const colorSet = this.setsLayoutProp("backgroundColor"); @@ -684,8 +871,9 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu const highlightColors = ["transparent", "maroon", "maroon", "yellow", "magenta", "cyan", "orange"]; const highlightStyles = ["solid", "dashed", "solid", "solid", "solid", "solid", "solid"]; - const highlighting = fullDegree && this.layoutDoc.type !== DocumentType.FONTICON && this.layoutDoc.viewType !== CollectionViewType.Linear; - return <div className={`documentView-node${this.topMost ? "-topmost" : ""}`} ref={this._mainCont} + let highlighting = fullDegree && this.layoutDoc.type !== DocumentType.FONTICON && this.layoutDoc.viewType !== CollectionViewType.Linear; + highlighting = highlighting && this.props.focus !== emptyFunction; // bcz: hack to turn off highlighting onsidebar panel documents. need to flag a document as not highlightable in a more direct way + return <div className={`documentView-node${this.topMost ? "-topmost" : ""}`} ref={this._mainCont} onKeyDown={this.onKeyDown} onDrop={this.onDrop} onContextMenu={this.onContextMenu} onPointerDown={this.onPointerDown} onClick={this.onClick} onPointerEnter={e => Doc.BrushDoc(this.props.Document)} onPointerLeave={e => Doc.UnBrushDoc(this.props.Document)} style={{ diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx index c93746773..c56fde186 100644 --- a/src/client/views/nodes/FieldView.tsx +++ b/src/client/views/nodes/FieldView.tsx @@ -30,6 +30,7 @@ export interface FieldViewProps { ruleProvider: Doc | undefined; Document: Doc; DataDoc?: Doc; + LibraryPath: Doc[]; onClick?: ScriptField; isSelected: (outsideReaction?: boolean) => boolean; select: (isCtrlPressed: boolean) => void; @@ -38,7 +39,7 @@ export interface FieldViewProps { addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => boolean; pinToPres: (document: Doc) => void; removeDocument?: (document: Doc) => boolean; - moveDocument?: (document: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean) => boolean; + moveDocument?: (document: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean) => boolean; ScreenToLocalTransform: () => Transform; active: (outsideReaction?: boolean) => boolean; whenActiveChanged: (isActive: boolean) => void; @@ -53,7 +54,7 @@ export interface FieldViewProps { @observer export class FieldView extends React.Component<FieldViewProps> { public static LayoutString(fieldType: { name: string }, fieldStr: string) { - return `<${fieldType.name} {...props} fieldKey={"${fieldStr}"}/>`; //e.g., "<ImageBox {...props} fieldKey={"dada} />" + return `<${fieldType.name} {...props} fieldKey={'${fieldStr}'}/>`; //e.g., "<ImageBox {...props} fieldKey={"dada} />" } @computed diff --git a/src/client/views/nodes/FormattedTextBox.scss b/src/client/views/nodes/FormattedTextBox.scss index c06f38a6c..c203ca0c3 100644 --- a/src/client/views/nodes/FormattedTextBox.scss +++ b/src/client/views/nodes/FormattedTextBox.scss @@ -11,6 +11,7 @@ } .formattedTextBox-cont { + touch-action: none; cursor: text; background: inherit; padding: 0; @@ -25,7 +26,6 @@ color: initial; height: 100%; pointer-events: all; - overflow-y: auto; max-height: 100%; display: flex; flex-direction: row; @@ -58,6 +58,7 @@ height: 35px; background: lightgray; border-radius: 20px; + cursor:grabbing; } .formattedTextBox-cont>.formattedTextBox-sidebar-handle { @@ -194,7 +195,7 @@ footnote::after { position: relative; width: 40px; height: 20px; - &::after { + &::before { content: "→"; } &:hover { @@ -224,33 +225,43 @@ footnote::after { .ProseMirror { touch-action: none; + span { + font-family: inherit; + } + ol, ul { + counter-reset: deci1 0 multi1 0; + padding-left: 1em; + font-family: inherit; + } ol { - counter-reset: deci1 0; - padding-left: 0px; + margin-left: 1em; + font-family: inherit; } - .decimal1-ol { counter-reset: deci1; p { display: inline }; ul, ol { padding-left: 30px; } } - .decimal2-ol { counter-reset: deci2; p { display: inline }; font-size: smaller; ul, ol { padding-left: 30px; } } - .decimal3-ol { counter-reset: deci3; p { display: inline }; font-size: smaller; ul, ol { padding-left: 30px; } } - .decimal4-ol { counter-reset: deci4; p { display: inline }; font-size: smaller; ul, ol { padding-left: 30px; } } - .decimal5-ol { counter-reset: deci5; p { display: inline }; font-size: smaller; ul, ol { padding-left: 30px; } } - .decimal6-ol { counter-reset: deci6; p { display: inline }; font-size: smaller; ul, ol { padding-left: 30px; } } - .decimal7-ol { counter-reset: deci7; p { display: inline }; font-size: smaller; ul, ol { padding-left: 30px; } } - - .upper-alpha-ol { counter-reset: ualph; p { display: inline}; font-size: smaller; } - .lower-roman-ol { counter-reset: lroman; p { display: inline}; font-size: smaller; } - .lower-alpha-ol { counter-reset: lalpha; p { display: inline}; font-size: smaller; } - - .decimal1:before { counter-increment: deci1; display: inline-block; content: counter(deci1) ". "; } - .decimal2:before { counter-increment: deci2; display: inline-block; content: counter(deci1) "."counter(deci2) ". "; } - .decimal3:before { counter-increment: deci3; display: inline-block; content: counter(deci1) "."counter(deci2) "."counter(deci3) ". "; } - .decimal4:before { counter-increment: deci4; display: inline-block; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) ". "; } - .decimal5:before { counter-increment: deci5; display: inline-block; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) ". "; } - .decimal6:before { counter-increment: deci5; display: inline-block; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) "."counter(deci6) ". "; } - .decimal7:before { counter-increment: deci5; display: inline-block; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) "."counter(deci6) "."counter(deci7) ". "; } + .decimal1-ol { counter-reset: deci1; p {display: inline; font-family: inherit} margin-left: 0; } + .decimal2-ol { counter-reset: deci2; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 1em;} + .decimal3-ol { counter-reset: deci3; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 2em;} + .decimal4-ol { counter-reset: deci4; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 3em;} + .decimal5-ol { counter-reset: deci5; p {display: inline; font-family: inherit} font-size: smaller; } + .decimal6-ol { counter-reset: deci6; p {display: inline; font-family: inherit} font-size: smaller; } + .decimal7-ol { counter-reset: deci7; p {display: inline; font-family: inherit} font-size: smaller; } + + .multi1-ol { counter-reset: multi1; p {display: inline; font-family: inherit} margin-left: 0; padding-left: 1.2em } + .multi2-ol { counter-reset: multi2; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 1.4em;} + .multi3-ol { counter-reset: multi3; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 2em;} + .multi4-ol { counter-reset: multi4; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 3.4em;} + + .decimal1:before { transition: 0.5s;counter-increment: deci1; display: inline-block; margin-left: -1em; width: 1em; content: counter(deci1) ". "; } + .decimal2:before { transition: 0.5s;counter-increment: deci2; display: inline-block; margin-left: -2.1em; width: 2.1em; content: counter(deci1) "."counter(deci2) ". "; } + .decimal3:before { transition: 0.5s;counter-increment: deci3; display: inline-block; margin-left: -2.85em;width: 2.85em; content: counter(deci1) "."counter(deci2) "."counter(deci3) ". "; } + .decimal4:before { transition: 0.5s;counter-increment: deci4; display: inline-block; margin-left: -3.85em;width: 3.85em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) ". "; } + .decimal5:before { transition: 0.5s;counter-increment: deci5; display: inline-block; margin-left: -2em; width: 5em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) ". "; } + .decimal6:before { transition: 0.5s;counter-increment: deci6; display: inline-block; margin-left: -2em; width: 6em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) "."counter(deci6) ". "; } + .decimal7:before { transition: 0.5s;counter-increment: deci7; display: inline-block; margin-left: -2em; width: 7em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) "."counter(deci6) "."counter(deci7) ". "; } - .upper-alpha:before { counter-increment: ualph; display: inline-block; content: counter(deci1) "."counter(ualph, upper-alpha) ". "; } - .lower-roman:before { counter-increment: lroman; display: inline-block; content: counter(deci1) "."counter(ualph, upper-alpha) "."counter(lroman, lower-roman) ". "; } - .lower-alpha:before { counter-increment: lalpha; display: inline-block; content: counter(deci1) "."counter(ualph, upper-alpha) "."counter(lroman, lower-roman) "."counter(lalpha, lower-alpha) ". "; } + .multi1:before { transition: 0.5s;counter-increment: multi1; display: inline-block; margin-left: -1em; width: 1.2em; content: counter(multi1, upper-alpha) ". "; } + .multi2:before { transition: 0.5s;counter-increment: multi2; display: inline-block; margin-left: -2em; width: 2em; content: counter(multi1, upper-alpha) "."counter(multi2, decimal) ". "; } + .multi3:before { transition: 0.5s;counter-increment: multi3; display: inline-block; margin-left: -2.85em; width:2.85em; content: counter(multi1, upper-alpha) "."counter(multi2, decimal) "."counter(multi3, lower-alpha) ". "; } + .multi4:before { transition: 0.5s;counter-increment: multi4; display: inline-block; margin-left: -4.2em; width: 4.2em; content: counter(multi1, upper-alpha) "."counter(multi2, decimal) "."counter(multi3, lower-alpha) "."counter(multi4, lower-roman) ". "; } }
\ No newline at end of file diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index f9b246c10..60842bcb0 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -27,7 +27,7 @@ import { DictationManager } from '../../util/DictationManager'; import { DragManager } from "../../util/DragManager"; import buildKeymap from "../../util/ProsemirrorExampleTransfer"; import { inpRules } from "../../util/RichTextRules"; -import { DashDocCommentView, FootnoteView, ImageResizeView, DashDocView, OrderedListView, schema, SummarizedView } from "../../util/RichTextSchema"; +import { DashDocCommentView, FootnoteView, ImageResizeView, DashDocView, OrderedListView, schema, SummaryView } from "../../util/RichTextSchema"; import { SelectionManager } from "../../util/SelectionManager"; import { TooltipLinkingMenu } from "../../util/TooltipLinkingMenu"; import { TooltipTextMenu } from "../../util/TooltipTextMenu"; @@ -47,6 +47,8 @@ import { AudioBox } from './AudioBox'; import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView'; import { InkTool } from '../../../new_fields/InkField'; import { TraceMobx } from '../../../new_fields/util'; +import RichTextMenu from '../../util/RichTextMenu'; +import { DocumentDecorations } from '../DocumentDecorations'; library.add(faEdit); library.add(faSmile, faTextHeight, faUpload); @@ -76,11 +78,11 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & public static blankState = () => EditorState.create(FormattedTextBox.Instance.config); public static Instance: FormattedTextBox; public static ToolTipTextMenu: TooltipTextMenu | undefined = undefined; + public ProseRef?: HTMLDivElement; private _ref: React.RefObject<HTMLDivElement> = React.createRef(); - private _proseRef?: HTMLDivElement; + private _scrollRef: React.RefObject<HTMLDivElement> = React.createRef(); private _editorView: Opt<EditorView>; private _applyingChange: boolean = false; - private _nodeClicked: any; private _searchIndex = 0; private _sidebarMovement = 0; private _lastX = 0; @@ -101,6 +103,8 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & @observable private _ruleFontFamily = "Arial"; @observable private _fontAlign = ""; @observable private _entered = false; + + public static FocusedBox: FormattedTextBox | undefined; public static SelectOnLoad = ""; public static IsFragment(html: string) { return html.indexOf("data-pm-slice") !== -1; @@ -142,6 +146,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & constructor(props: any) { super(props); FormattedTextBox.Instance = this; + this.updateHighlights(); } public get CurrentDiv(): HTMLDivElement { return this._ref.current!; } @@ -182,7 +187,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & this.linkOnDeselect.set(key, value); const id = Utils.GenerateDeterministicGuid(this.dataDoc[Id] + key); - const link = this._editorView.state.schema.marks.link.create({ href: `http://localhost:1050/doc/${id}`, location: "onRight", title: value }); + const link = this._editorView.state.schema.marks.link.create({ href: Utils.prepend("/doc/" + id), location: "onRight", title: value }); const mval = this._editorView.state.schema.marks.metadataVal.create(); const offset = (tx.selection.to === range!.end - 1 ? -1 : 0); tx = tx.addMark(textEndSelection - value.length + offset, textEndSelection, link).addMark(textEndSelection - value.length + offset, textEndSelection, mval); @@ -191,10 +196,12 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } const state = this._editorView.state.apply(tx); this._editorView.updateState(state); + (tx.storedMarks && !this._editorView.state.storedMarks) && (this._editorView.state.storedMarks = tx.storedMarks); const tsel = this._editorView.state.selection.$from; tsel.marks().filter(m => m.type === this._editorView!.state.schema.marks.user_mark).map(m => AudioBox.SetScrubTime(Math.max(0, m.attrs.modified * 5000 - 1000))); this._applyingChange = true; + this.extensionDoc && !this.extensionDoc.lastModified && (this.extensionDoc.backgroundColor = "lightGray"); this.extensionDoc && (this.extensionDoc.lastModified = new DateField(new Date(Date.now()))); this.dataDoc[this.props.fieldKey] = new RichTextField(JSON.stringify(state.toJSON()), state.doc.textBetween(0, state.doc.content.size, "\n\n")); this._applyingChange = false; @@ -212,10 +219,10 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } public highlightSearchTerms = (terms: string[]) => { - if (this._editorView && (this._editorView as any).docView) { + if (this._editorView && (this._editorView as any).docView && terms.some(t => t)) { const mark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight); const activeMark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight, { selected: true }); - const res = terms.map(term => this.findInNode(this._editorView!, this._editorView!.state.doc, term)); + const res = terms.filter(t => t).map(term => this.findInNode(this._editorView!, this._editorView!.state.doc, term)); let tr = this._editorView.state.tr; const flattened: TextSelection[] = []; res.map(r => r.map(h => flattened.push(h))); @@ -234,42 +241,42 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & this._editorView.dispatch(this._editorView.state.tr.removeMark(0, end, mark).removeMark(0, end, activeMark)); } } - setAnnotation = (start: number, end: number, mark: Mark, opened: boolean, keep: boolean = false) => { + adoptAnnotation = (start: number, end: number, mark: Mark) => { const view = this._editorView!; - const nmark = view.state.schema.marks.user_mark.create({ ...mark.attrs, userid: keep ? Doc.CurrentUserEmail : mark.attrs.userid, opened: opened }); + const nmark = view.state.schema.marks.user_mark.create({ ...mark.attrs, userid: Doc.CurrentUserEmail }); view.dispatch(view.state.tr.removeMark(start, end, nmark).addMark(start, end, nmark)); } protected createDropTarget = (ele: HTMLDivElement) => { - this._proseRef = ele; + this.ProseRef = ele; this.dropDisposer && this.dropDisposer(); - ele && (this.dropDisposer = DragManager.MakeDropTarget(ele, { handlers: { drop: this.drop.bind(this) } })); + ele && (this.dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this))); } @undoBatch @action drop = async (e: Event, de: DragManager.DropEvent) => { - if (de.data instanceof DragManager.DocumentDragData) { - const draggedDoc = de.data.draggedDocuments.length && de.data.draggedDocuments[0]; + if (de.complete.docDragData) { + const draggedDoc = de.complete.docDragData.draggedDocuments.length && de.complete.docDragData.draggedDocuments[0]; // replace text contents whend dragging with Alt - if (draggedDoc && draggedDoc.type === DocumentType.TEXT && !Doc.AreProtosEqual(draggedDoc, this.props.Document) && de.mods === "AltKey") { + if (draggedDoc && draggedDoc.type === DocumentType.TEXT && !Doc.AreProtosEqual(draggedDoc, this.props.Document) && de.altKey) { if (draggedDoc.data instanceof RichTextField) { Doc.GetProto(this.dataDoc)[this.props.fieldKey] = new RichTextField(draggedDoc.data.Data, draggedDoc.data.Text); e.stopPropagation(); } // apply as template when dragging with Meta - } else if (draggedDoc && draggedDoc.type === DocumentType.TEXT && !Doc.AreProtosEqual(draggedDoc, this.props.Document) && de.mods === "MetaKey") { + } else if (draggedDoc && draggedDoc.type === DocumentType.TEXT && !Doc.AreProtosEqual(draggedDoc, this.props.Document) && de.metaKey) { draggedDoc.isTemplateDoc = true; let newLayout = Doc.Layout(draggedDoc); if (typeof (draggedDoc.layout) === "string") { newLayout = Doc.MakeDelegate(draggedDoc); - newLayout.layout = StrCast(newLayout.layout).replace(/fieldKey={"[^"]*"}/, `fieldKey={"${this.props.fieldKey}"}`); + newLayout.layout = StrCast(newLayout.layout).replace(/fieldKey={'[^']*'}/, `fieldKey={'${this.props.fieldKey}'}`); } this.Document.layoutCustom = newLayout; this.Document.layoutKey = "layoutCustom"; e.stopPropagation(); // embed document when dragging with a userDropAction or an embedDoc flag set - } else if (de.data.userDropAction || de.data.embedDoc) { - const target = de.data.droppedDocuments[0]; + } else if (de.complete.docDragData.userDropAction || de.complete.docDragData.embedDoc) { + const target = de.complete.docDragData.droppedDocuments[0]; // const link = DocUtils.MakeLink({ doc: this.dataDoc, ctx: this.props.ContainingCollectionDoc }, { doc: target }, "Embedded Doc:" + target.title); // if (link) { target.fitToBox = true; @@ -328,7 +335,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } return ret; } - static _highlights: string[] = []; + static _highlights: string[] = ["Text from Others", "Todo Items", "Important Items", "Disagree Items", "Ignore Items"]; updateHighlights = () => { clearStyleSheetRules(FormattedTextBox._userStyleSheet); @@ -348,7 +355,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & addStyleSheetRule(FormattedTextBox._userStyleSheet, "userTag-" + "disagree", { "text-decoration": "line-through" }); } if (FormattedTextBox._highlights.indexOf("Ignore Items") !== -1) { - addStyleSheetRule(FormattedTextBox._userStyleSheet, "userTag-" + "ignore", { "font-size": "0" }); + addStyleSheetRule(FormattedTextBox._userStyleSheet, "userTag-" + "ignore", { "font-size": "1" }); } if (FormattedTextBox._highlights.indexOf("By Recent Minute") !== -1) { addStyleSheetRule(FormattedTextBox._userStyleSheet, "userMark-" + Doc.CurrentUserEmail.replace(".", "").replace("@", ""), { opacity: "0.1" }); @@ -370,10 +377,11 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & document.addEventListener("pointermove", this.sidebarMove); document.addEventListener("pointerup", this.sidebarUp); e.stopPropagation(); + e.preventDefault(); // prevents text from being selected during drag } sidebarMove = (e: PointerEvent) => { - let bounds = this.CurrentDiv.getBoundingClientRect(); - this._sidebarMovement += Math.sqrt((e.clientX - this._lastX) * (e.clientX - this._lastX) + (e.clientY - this._lastY) * (e.clientY - this._lastY)) + const bounds = this.CurrentDiv.getBoundingClientRect(); + this._sidebarMovement += Math.sqrt((e.clientX - this._lastX) * (e.clientX - this._lastX) + (e.clientY - this._lastY) * (e.clientY - this._lastY)); this.props.Document.sidebarWidthPercent = "" + 100 * (1 - (e.clientX - bounds.left) / bounds.width) + "%"; } sidebarUp = (e: PointerEvent) => { @@ -545,16 +553,9 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & this.setupEditor(this.config, this.dataDoc, this.props.fieldKey); - this._searchReactionDisposer = reaction(() => { - return StrCast(this.layoutDoc.search_string); - }, searchString => { - if (searchString) { - this.highlightSearchTerms([searchString]); - } - else { - this.unhighlightSearchTerms(); - } - }, { fireImmediately: true }); + this._searchReactionDisposer = reaction(() => this.layoutDoc.searchMatch, + search => search ? this.highlightSearchTerms([Doc.SearchQuery()]) : this.unhighlightSearchTerms(), + { fireImmediately: true }); this._rulesReactionDisposer = reaction(() => { const ruleProvider = this.props.ruleProvider; @@ -573,7 +574,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & this._ruleFontSize = rules ? rules.size : 0; rules && setTimeout(() => { const view = this._editorView!; - if (this._proseRef) { + if (this.ProseRef) { const n = new NodeSelection(view.state.doc.resolve(0)); if (this._editorView!.state.doc.textContent === "") { view.dispatch(view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0), view.state.doc.resolve(2))). @@ -632,7 +633,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & { fireImmediately: true } ); - setTimeout(() => this.tryUpdateHeight(), 0); + setTimeout(() => this.tryUpdateHeight(NumCast(this.layoutDoc.limitHeight, 0))); } pushToGoogleDoc = async () => { @@ -796,10 +797,10 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & startup = NumCast(doc[fieldKey], 99).toString(); } } - if (this._proseRef) { + if (this.ProseRef) { const self = this; this._editorView && this._editorView.destroy(); - this._editorView = new EditorView(this._proseRef, { + this._editorView = new EditorView(this.ProseRef, { state: field && field.Data ? EditorState.fromJSON(config, JSON.parse(field.Data)) : EditorState.create(config), handleScrollToSelection: (editorView) => { const ref = editorView.domAtPos(editorView.state.selection.from); @@ -808,7 +809,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & const r1 = refNode && refNode.getBoundingClientRect(); const r3 = self._ref.current!.getBoundingClientRect(); if (r1.top < r3.top || r1.top > r3.bottom) { - r1 && (self._ref.current!.scrollTop += (r1.top - r3.top) * self.props.ScreenToLocalTransform().Scale); + r1 && (self._scrollRef.current!.scrollTop += (r1.top - r3.top) * self.props.ScreenToLocalTransform().Scale); } return true; }, @@ -817,14 +818,15 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & dashComment(node, view, getPos) { return new DashDocCommentView(node, view, getPos); }, dashDoc(node, view, getPos) { return new DashDocView(node, view, getPos, self); }, image(node, view, getPos) { return new ImageResizeView(node, view, getPos, self.props.addDocTab); }, - star(node, view, getPos) { return new SummarizedView(node, view, getPos); }, + summary(node, view, getPos) { return new SummaryView(node, view, getPos); }, ordered_list(node, view, getPos) { return new OrderedListView(); }, footnote(node, view, getPos) { return new FootnoteView(node, view, getPos); } }, clipboardTextSerializer: this.clipboardTextSerializer, handlePaste: this.handlePaste, }); - if (startup) { + this._editorView.state.schema.Document = this.props.Document; + if (startup && this._editorView) { Doc.GetProto(doc).documentText = undefined; this._editorView.dispatch(this._editorView.state.tr.insertText(startup)); } @@ -865,15 +867,19 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & this._buttonBarReactionDisposer && this._buttonBarReactionDisposer(); this._editorView && this._editorView.destroy(); } + + static _downEvent: any; onPointerDown = (e: React.PointerEvent): void => { + this.doLinkOnDeselect(); + FormattedTextBox._downEvent = true; FormattedTextBoxComment.textBox = this; - const pos = this._editorView!.posAtCoords({ left: e.clientX, top: e.clientY }); - pos && (this._nodeClicked = this._editorView!.state.doc.nodeAt(pos.pos)); if (this.props.onClick && e.button === 0) { e.preventDefault(); } - if (e.button === 0 && this.props.isSelected(true) && !e.altKey && !e.ctrlKey && !e.metaKey) { - e.stopPropagation(); + if (e.button === 0 && this.active(true) && !e.altKey && !e.ctrlKey && !e.metaKey) { + if (e.clientX < this.ProseRef!.getBoundingClientRect().right) { // don't stop propagation if clicking in the sidebar + e.stopPropagation(); + } } if (e.button === 2 || (e.button === 0 && e.ctrlKey)) { e.preventDefault(); @@ -881,6 +887,8 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } onPointerUp = (e: React.PointerEvent): void => { + if (!FormattedTextBox._downEvent) return; + FormattedTextBox._downEvent = false; if (!(e.nativeEvent as any).formattedHandled) { FormattedTextBoxComment.textBox = this; FormattedTextBoxComment.update(this._editorView!); @@ -892,11 +900,25 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } } - static InputBoxOverlay: FormattedTextBox | undefined; @action onFocused = (e: React.FocusEvent): void => { - FormattedTextBox.InputBoxOverlay = this; + FormattedTextBox.FocusedBox = this; this.tryUpdateHeight(); + + // see if we need to preserve the insertion point + const prosediv = this.ProseRef?.children?.[0] as any; + const keeplocation = prosediv?.keeplocation; + prosediv && (prosediv.keeplocation = undefined); + const pos = this._editorView?.state.selection.$from.pos || 1; + keeplocation && setTimeout(() => this._editorView?.dispatch(this._editorView?.state.tr.setSelection(TextSelection.create(this._editorView.state.doc, pos)))); + + // jump rich text menu to this textbox + const { current } = this._ref; + if (current) { + const x = Math.min(Math.max(current.getBoundingClientRect().left, 0), window.innerWidth - RichTextMenu.Instance.width); + const y = this._ref.current!.getBoundingClientRect().top - RichTextMenu.Instance.height - 50; + RichTextMenu.Instance.jumpTo(x, y); + } } onPointerWheel = (e: React.WheelEvent): void => { // if a text note is not selected and scrollable, this prevents us from being able to scroll and zoom out at the same time @@ -909,6 +931,20 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & static _userStyleSheet: any = addStyleSheet(); onClick = (e: React.MouseEvent): void => { + if ((this._editorView!.root as any).getSelection().isCollapsed) { // this is a hack to allow the cursor to be placed at the end of a document when the document ends in an inline dash comment. Apparently Chrome on Windows has a bug/feature which breaks this when clicking after the end of the text. + const pcords = this._editorView!.posAtCoords({ left: e.clientX, top: e.clientY }); + const node = pcords && this._editorView!.state.doc.nodeAt(pcords.pos); // get what prosemirror thinks the clicked node is (if it's null, then we didn't click on any text) + if (pcords && node?.type === this._editorView!.state.schema.nodes.dashComment) { + this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, pcords.pos + 2))); + e.preventDefault(); + } + if (!node && this.ProseRef) { + const lastNode = this.ProseRef.children[this.ProseRef.children.length - 1].children[this.ProseRef.children[this.ProseRef.children.length - 1].children.length - 1]; // get the last prosemirror div + if (e.clientY > lastNode?.getBoundingClientRect().bottom) { // if we clicked below the last prosemirror div, then set the selection to be the end of the document + this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, this._editorView!.state.doc.content.size))); + } + } + } if ((e.nativeEvent as any).formattedHandled) { e.stopPropagation(); return; } (e.nativeEvent as any).formattedHandled = true; // if (e.button === 0 && ((!this.props.isSelected(true) && !e.ctrlKey) || (this.props.isSelected(true) && e.ctrlKey)) && !e.metaKey && e.target) { @@ -944,39 +980,44 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & // } // } - this.hitBulletTargets(e.clientX, e.clientY, e.nativeEvent.offsetX, e.shiftKey); + this.hitBulletTargets(e.clientX, e.clientY, e.shiftKey, false); if (this._recording) setTimeout(() => { this.stopDictation(true); setTimeout(() => this.recordDictation(), 500); }, 500); } // this hackiness handles clicking on the list item bullets to do expand/collapse. the bullets are ::before pseudo elements so there's no real way to hit test against them. - hitBulletTargets(x: number, y: number, offsetX: number, select: boolean, highlightOnly = false) { + hitBulletTargets(x: number, y: number, select: boolean, highlightOnly: boolean) { clearStyleSheetRules(FormattedTextBox._bulletStyleSheet); - if (this.props.isSelected(true) && offsetX < 40) { - const pos = this._editorView!.posAtCoords({ left: x, top: y }); - if (pos && pos.pos > 0) { - const node = this._editorView!.state.doc.nodeAt(pos.pos); - const node2 = node?.type === schema.nodes.paragraph ? this._editorView!.state.doc.nodeAt(pos.pos - 1) : undefined; - if ((node === this._nodeClicked || highlightOnly) && (node2?.type === schema.nodes.ordered_list || node2?.type === schema.nodes.list_item)) { - const hit = this._editorView!.domAtPos(pos.pos).node as any; // let beforeEle = document.querySelector("." + hit.className) as Element; - const before = hit ? window.getComputedStyle(hit, ':before') : undefined; - const beforeWidth = before ? Number(before.getPropertyValue('width').replace("px", "")) : undefined; - if (beforeWidth && offsetX < beforeWidth * .9) { - const ol = this._editorView!.state.doc.nodeAt(pos.pos - 2) ? this._editorView!.state.doc.nodeAt(pos.pos - 2) : undefined; - if (ol?.type === schema.nodes.ordered_list && select) { - if (!highlightOnly) { - this._editorView!.dispatch(this._editorView!.state.tr.setSelection(new NodeSelection(this._editorView!.state.doc.resolve(pos.pos - 2)))); - } - addStyleSheetRule(FormattedTextBox._bulletStyleSheet, hit.className + ":before", { background: "lightgray" }); - } else { - if (highlightOnly) { - addStyleSheetRule(FormattedTextBox._bulletStyleSheet, hit.className + ":before", { background: "lightgray" }); - } else { - this._editorView!.dispatch(this._editorView!.state.tr.setNodeMarkup(pos.pos - 1, node2.type, { ...node2.attrs, visibility: !node2.attrs.visibility })); - } - } + const pos = this._editorView!.posAtCoords({ left: x, top: y }); + if (pos && this.props.isSelected(true)) { + // let beforeEle = document.querySelector("." + hit.className) as Element; // const before = hit ? window.getComputedStyle(hit, ':before') : undefined; + //const node = this._editorView!.state.doc.nodeAt(pos.pos); + const $pos = this._editorView!.state.doc.resolve(pos.pos); + let list_node = $pos.node().type === schema.nodes.list_item ? $pos.node() : undefined; + if ($pos.node().type === schema.nodes.ordered_list) { + for (let off = 1; off < 100; off++) { + const pos = this._editorView!.posAtCoords({ left: x + off, top: y }); + const node = pos && this._editorView!.state.doc.nodeAt(pos.pos); + if (node?.type === schema.nodes.list_item) { + list_node = node; + break; } } } + if (list_node && pos.inside >= 0 && this._editorView!.state.doc.nodeAt(pos.inside)!.attrs.bulletStyle === list_node.attrs.bulletStyle) { + if (select) { + const $olist_pos = this._editorView!.state.doc.resolve($pos.pos - $pos.parentOffset - 1); + if (!highlightOnly) { + this._editorView!.dispatch(this._editorView!.state.tr.setSelection(new NodeSelection($olist_pos))); + } + addStyleSheetRule(FormattedTextBox._bulletStyleSheet, list_node.attrs.mapStyle + list_node.attrs.bulletStyle + ":hover:before", { background: "lightgray" }); + } else if (Math.abs(pos.pos - pos.inside) < 2) { + if (!highlightOnly) { + this._editorView!.dispatch(this._editorView!.state.tr.setNodeMarkup(pos.inside, list_node.type, { ...list_node.attrs, visibility: !list_node.attrs.visibility })); + this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, pos.inside))); + } + addStyleSheetRule(FormattedTextBox._bulletStyleSheet, list_node.attrs.mapStyle + list_node.attrs.bulletStyle + ":hover:before", { background: "lightgray" }); + } + } } } onMouseUp = (e: React.MouseEvent): void => { @@ -1001,7 +1042,9 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & const self = FormattedTextBox; return new Plugin({ view(newView) { - return self.ToolTipTextMenu = FormattedTextBox.getToolTip(newView); + // return self.ToolTipTextMenu = FormattedTextBox.getToolTip(newView); + RichTextMenu.Instance.changeView(newView); + return RichTextMenu.Instance; } }); } @@ -1021,16 +1064,39 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & this._undoTyping = undefined; } this.doLinkOnDeselect(); + + // move the richtextmenu offscreen + if (!RichTextMenu.Instance.Pinned && !RichTextMenu.Instance.overMenu) RichTextMenu.Instance.jumpTo(-300, -300); } + + _lastTimedMark: Mark | undefined = undefined; onKeyPress = (e: React.KeyboardEvent) => { + if (e.altKey) { + e.preventDefault(); + return; + } + const state = this._editorView!.state; + if (!state.selection.empty && e.key === "%") { + state.schema.EnteringStyle = true; + e.preventDefault(); + e.stopPropagation(); + return; + } + + if (state.selection.empty || !state.schema.EnteringStyle) { + state.schema.EnteringStyle = false; + } if (e.key === "Escape") { + this._editorView!.dispatch(state.tr.setSelection(TextSelection.create(state.doc, state.selection.from, state.selection.from))); + (document.activeElement as any).blur?.(); SelectionManager.DeselectAll(); } e.stopPropagation(); if (e.key === "Tab" || e.key === "Enter") { e.preventDefault(); } - const mark = schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.round(Date.now() / 1000 / 5) }); + const mark = e.key !== " " && this._lastTimedMark ? this._lastTimedMark : schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.round(Date.now() / 1000 / 5) }); + this._lastTimedMark = mark; this._editorView!.dispatch(this._editorView!.state.tr.removeStoredMark(schema.marks.user_mark.create({})).addStoredMark(mark)); if (!this._undoTyping) { @@ -1043,14 +1109,22 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } @action - tryUpdateHeight() { - const scrollHeight = this._ref.current?.scrollHeight; + tryUpdateHeight(limitHeight?: number) { + let scrollHeight = this._ref.current?.scrollHeight; if (!this.layoutDoc.animateToPos && this.layoutDoc.autoHeight && scrollHeight && getComputedStyle(this._ref.current!.parentElement!).top === "0px") { // if top === 0, then the text box is growing upward (as the overlay caption) which doesn't contribute to the height computation + if (limitHeight && scrollHeight > limitHeight) { + scrollHeight = limitHeight; + this.layoutDoc.limitHeight = undefined; + this.layoutDoc.autoHeight = false; + } const nh = this.Document.isTemplateField ? 0 : NumCast(this.dataDoc.nativeHeight, 0); const dh = NumCast(this.layoutDoc.height, 0); - this.layoutDoc.height = Math.max(10, (nh ? dh / nh * scrollHeight : scrollHeight) + (this.props.ChromeHeight ? this.props.ChromeHeight() : 0)); - this.dataDoc.nativeHeight = nh ? scrollHeight : undefined; + const newHeight = Math.max(10, (nh ? dh / nh * scrollHeight : scrollHeight) + (this.props.ChromeHeight ? this.props.ChromeHeight() : 0)); + if (Math.abs(newHeight - dh) > 1) { // bcz: Argh! without this, we get into a React crash if the same document is opened in a freeform view and in the treeview. no idea why, but after dragging the freeform document, selecting it, and selecting text, it will compute to 1 pixel higher than the treeview which causes a cycle + this.layoutDoc.height = newHeight; + this.dataDoc.nativeHeight = nh ? scrollHeight : undefined; + } } } @@ -1062,7 +1136,9 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & const rounded = StrCast(this.layoutDoc.borderRounding) === "100%" ? "-rounded" : ""; const interactive = InkingControl.Instance.selectedTool || this.layoutDoc.isBackground; if (this.props.isSelected()) { - FormattedTextBox.ToolTipTextMenu!.updateFromDash(this._editorView!, undefined, this.props); + // TODO: ftong --> update from dash in richtextmenu + RichTextMenu.Instance.updateFromDash(this._editorView!, undefined, this.props); + // FormattedTextBox.ToolTipTextMenu!.updateFromDash(this._editorView!, undefined, this.props); } else if (FormattedTextBoxComment.textBox === this) { FormattedTextBoxComment.Hide(); } @@ -1081,17 +1157,16 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & onKeyDown={this.onKeyPress} onFocus={this.onFocused} onClick={this.onClick} - onPointerMove={e => this.hitBulletTargets(e.clientX, e.clientY, e.nativeEvent.offsetX, e.shiftKey, true)} + onPointerMove={e => this.hitBulletTargets(e.clientX, e.clientY, e.shiftKey, true)} onBlur={this.onBlur} onPointerUp={this.onPointerUp} onPointerDown={this.onPointerDown} onMouseUp={this.onMouseUp} - onTouchStart={this.onTouchStart} onWheel={this.onPointerWheel} onPointerEnter={action(() => this._entered = true)} onPointerLeave={action(() => this._entered = false)} > - <div className={`formattedTextBox-outer`} style={{ width: `calc(100% - ${this.sidebarWidthPercent})`, }}> + <div className={`formattedTextBox-outer`} style={{ width: `calc(100% - ${this.sidebarWidthPercent})`, }} ref={this._scrollRef}> <div className={`formattedTextBox-inner${rounded}`} style={{ whiteSpace: "pre-wrap", pointerEvents: ((this.Document.isButton || this.props.onClick) && !this.props.isSelected()) ? "none" : undefined }} ref={this.createDropTarget} /> </div> {this.props.Document.hideSidebar ? (null) : this.sidebarWidthPercent === "0%" ? @@ -1128,7 +1203,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & e.stopPropagation(); }} > <FontAwesomeIcon className="formattedTExtBox-audioFont" - style={{ color: this._recording ? "red" : "blue", opacity: this._recording ? 1 : 0.5 }} icon={"microphone"} size="sm" /> + style={{ color: this._recording ? "red" : "blue", opacity: this._recording ? 1 : 0.5, display: this.props.isSelected() ? "" : "none" }} icon={"microphone"} size="sm" /> </div> </div> ); diff --git a/src/client/views/nodes/FormattedTextBoxComment.tsx b/src/client/views/nodes/FormattedTextBoxComment.tsx index 2ec30e3b3..f7a530790 100644 --- a/src/client/views/nodes/FormattedTextBoxComment.tsx +++ b/src/client/views/nodes/FormattedTextBoxComment.tsx @@ -4,7 +4,7 @@ import { EditorView } from "prosemirror-view"; import * as ReactDOM from 'react-dom'; import { Doc } from "../../../new_fields/Doc"; import { Cast, FieldValue, NumCast } from "../../../new_fields/Types"; -import { emptyFunction, returnEmptyString, returnFalse, Utils } from "../../../Utils"; +import { emptyFunction, returnEmptyString, returnFalse, Utils, emptyPath } from "../../../Utils"; import { DocServer } from "../../DocServer"; import { DocumentManager } from "../../util/DocumentManager"; import { schema } from "../../util/RichTextSchema"; @@ -57,7 +57,6 @@ export class FormattedTextBoxComment { static start: number; static end: number; static mark: Mark; - static opened: boolean; static textBox: FormattedTextBox | undefined; static linkDoc: Doc | undefined; constructor(view: any) { @@ -89,11 +88,10 @@ export class FormattedTextBoxComment { } else if (textBox && (FormattedTextBoxComment.tooltipText as any).href) { textBox.props.addDocTab(Docs.Create.WebDocument((FormattedTextBoxComment.tooltipText as any).href, { title: (FormattedTextBoxComment.tooltipText as any).href, width: 200, height: 400 }), undefined, "onRight"); } - FormattedTextBoxComment.opened = keep || !FormattedTextBoxComment.opened; - textBox && FormattedTextBoxComment.start !== undefined && textBox.setAnnotation( - FormattedTextBoxComment.start, FormattedTextBoxComment.end, FormattedTextBoxComment.mark, - FormattedTextBoxComment.opened, keep); + keep && textBox && FormattedTextBoxComment.start !== undefined && textBox.adoptAnnotation( + FormattedTextBoxComment.start, FormattedTextBoxComment.end, FormattedTextBoxComment.mark); e.stopPropagation(); + e.preventDefault(); }; root && root.appendChild(FormattedTextBoxComment.tooltip); } @@ -103,12 +101,11 @@ export class FormattedTextBoxComment { FormattedTextBoxComment.textBox = undefined; FormattedTextBoxComment.tooltip && (FormattedTextBoxComment.tooltip.style.display = "none"); } - public static SetState(textBox: any, opened: boolean, start: number, end: number, mark: Mark) { + public static SetState(textBox: any, start: number, end: number, mark: Mark) { FormattedTextBoxComment.textBox = textBox; FormattedTextBoxComment.start = start; FormattedTextBoxComment.end = end; FormattedTextBoxComment.mark = mark; - FormattedTextBoxComment.opened = opened; FormattedTextBoxComment.tooltip && (FormattedTextBoxComment.tooltip.style.display = ""); } @@ -142,7 +139,7 @@ export class FormattedTextBoxComment { state.doc.nodesBetween(state.selection.from, state.selection.to, (node: any, pos: number, parent: any) => !child && node.marks.length && (child = node)); const mark = child && findOtherUserMark(child.marks); if (mark && child && (nbef || naft) && (!mark.attrs.opened || noselection)) { - FormattedTextBoxComment.SetState(FormattedTextBoxComment.textBox, mark.attrs.opened, state.selection.$from.pos - nbef, state.selection.$from.pos + naft, mark); + FormattedTextBoxComment.SetState(FormattedTextBoxComment.textBox, state.selection.$from.pos - nbef, state.selection.$from.pos + naft, mark); } if (mark && child && ((nbef && naft) || !noselection)) { FormattedTextBoxComment.tooltipText.textContent = mark.attrs.userid + " date=" + (new Date(mark.attrs.modified * 5000)).toDateString(); @@ -157,32 +154,35 @@ export class FormattedTextBoxComment { let child: any = null; state.doc.nodesBetween(state.selection.from, state.selection.to, (node: any, pos: number, parent: any) => !child && node.marks.length && (child = node)); const mark = child && findLinkMark(child.marks); - if (mark && child && nbef && naft) { + if (mark && child && nbef && naft && mark.attrs.showPreview) { FormattedTextBoxComment.tooltipText.textContent = "external => " + mark.attrs.href; + (FormattedTextBoxComment.tooltipText as any).href = mark.attrs.href; if (mark.attrs.href.startsWith("https://en.wikipedia.org/wiki/")) { wiki().page(mark.attrs.href.replace("https://en.wikipedia.org/wiki/", "")).then(page => page.summary().then(summary => FormattedTextBoxComment.tooltipText.textContent = summary.substring(0, 500))); } else { FormattedTextBoxComment.tooltipText.style.whiteSpace = "pre"; FormattedTextBoxComment.tooltipText.style.overflow = "hidden"; } - (FormattedTextBoxComment.tooltipText as any).href = mark.attrs.href; if (mark.attrs.href.indexOf(Utils.prepend("/doc/")) === 0) { + FormattedTextBoxComment.tooltipText.textContent = "target not found..."; + (FormattedTextBoxComment.tooltipText as any).href = ""; const docTarget = mark.attrs.href.replace(Utils.prepend("/doc/"), "").split("?")[0]; docTarget && DocServer.GetRefField(docTarget).then(linkDoc => { if (linkDoc instanceof Doc) { + (FormattedTextBoxComment.tooltipText as any).href = mark.attrs.href; FormattedTextBoxComment.linkDoc = linkDoc; - const target = FieldValue(Doc.AreProtosEqual(FieldValue(Cast(linkDoc.anchor1, Doc)), textBox.props.Document) ? Cast(linkDoc.anchor2, Doc) : Cast(linkDoc.anchor1, Doc)); + const target = FieldValue(Doc.AreProtosEqual(FieldValue(Cast(linkDoc.anchor1, Doc)), textBox.props.Document) ? Cast(linkDoc.anchor2, Doc) : (Cast(linkDoc.anchor1, Doc)) || linkDoc); try { ReactDOM.unmountComponentAtNode(FormattedTextBoxComment.tooltipText); } catch (e) { } if (target) { ReactDOM.render(<ContentFittingDocumentView - fitToBox={true} Document={target} + LibraryPath={emptyPath} + fitToBox={true} moveDocument={returnFalse} getTransform={Transform.Identity} active={returnFalse} - setPreviewScript={returnEmptyString} addDocument={returnFalse} removeDocument={returnFalse} ruleProvider={undefined} diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss index 3b42c2352..cf5d999a7 100644 --- a/src/client/views/nodes/ImageBox.scss +++ b/src/client/views/nodes/ImageBox.scss @@ -1,13 +1,22 @@ -.imageBox { +.imageBox, .imageBox-dragging{ pointer-events: all; border-radius: inherit; width:100%; height:100%; position: absolute; transform-origin: top left; + .imageBox-fader { + pointer-events: all; + } } -.imageBox-cont, .imageBox-cont-dragging { +.imageBox-dragging { + .imageBox-fader { + pointer-events: none; + } +} + +.imageBox-cont { padding: 0vw; position: absolute; text-align: center; @@ -22,15 +31,6 @@ width: 100%; pointer-events: all; } - .imageBox-fader { - pointer-events: all; - } -} - -.imageBox-cont-dragging { - .imageBox-fader { - pointer-events: none; - } } .imageBox-dot { @@ -43,7 +43,6 @@ background: gray; } - #google-photos { transition: all 0.5s ease 0s; width: 30px; diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index b4a51657f..634555012 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -18,7 +18,6 @@ import { undoBatch } from '../../util/UndoManager'; import { ContextMenu } from "../../views/ContextMenu"; import { ContextMenuProps } from '../ContextMenuItem'; import { DocAnnotatableComponent } from '../DocComponent'; -import { InkingControl } from '../InkingControl'; import FaceRectangles from './FaceRectangles'; import { FieldView, FieldViewProps } from './FieldView'; import "./ImageBox.scss"; @@ -67,18 +66,18 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum protected createDropTarget = (ele: HTMLDivElement) => { this._dropDisposer && this._dropDisposer(); - ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, { handlers: { drop: this.drop.bind(this) } })); + ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this))); } @undoBatch @action drop = (e: Event, de: DragManager.DropEvent) => { - if (de.data instanceof DragManager.DocumentDragData) { - if (de.mods === "AltKey" && de.data.draggedDocuments.length && de.data.draggedDocuments[0].data instanceof ImageField) { - Doc.GetProto(this.dataDoc)[this.props.fieldKey] = new ImageField(de.data.draggedDocuments[0].data.url); + if (de.complete.docDragData) { + if (de.altKey && de.complete.docDragData.draggedDocuments.length && de.complete.docDragData.draggedDocuments[0].data instanceof ImageField) { + Doc.GetProto(this.dataDoc)[this.props.fieldKey] = new ImageField(de.complete.docDragData.draggedDocuments[0].data.url); e.stopPropagation(); } - de.mods === "MetaKey" && de.data.droppedDocuments.forEach(action((drop: Doc) => { + de.metaKey && de.complete.docDragData.droppedDocuments.forEach(action((drop: Doc) => { this.extensionDoc && Doc.AddDocToList(Doc.GetProto(this.extensionDoc), "Alternates", drop); e.stopPropagation(); })); @@ -215,35 +214,23 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum } _curSuffix = "_m"; - resize = (srcpath: string) => { - requestImageSize(srcpath) + _resized = ""; + resize = (imgPath: string) => { + requestImageSize(imgPath) .then((size: any) => { const rotation = NumCast(this.dataDoc.rotation) % 180; const realsize = rotation === 90 || rotation === 270 ? { height: size.width, width: size.height } : size; const aspect = realsize.height / realsize.width; if (this.Document.width && (Math.abs(1 - NumCast(this.Document.height) / NumCast(this.Document.width) / (realsize.height / realsize.width)) > 0.1)) { setTimeout(action(() => { - this.Document.height = this.Document[WidthSym]() * aspect; - this.Document.nativeHeight = realsize.height; - this.Document.nativeWidth = realsize.width; + if (this.pathInfos.srcpath === imgPath && (!this.layoutDoc.isTemplateDoc || this.dataDoc !== this.layoutDoc)) { + this._resized = imgPath; + this.Document.height = this.Document[WidthSym]() * aspect; + this.Document.nativeHeight = realsize.height; + this.Document.nativeWidth = realsize.width; + } }), 0); - } - }) - .catch((err: any) => console.log(err)); - } - fadesize = (srcpath: string) => { - requestImageSize(srcpath) - .then((size: any) => { - const rotation = NumCast(this.dataDoc.rotation) % 180; - const realsize = rotation === 90 || rotation === 270 ? { height: size.width, width: size.height } : size; - const aspect = realsize.height / realsize.width; - if (this.Document.width && (Math.abs(1 - NumCast(this.Document.height) / NumCast(this.Document.width) / (realsize.height / realsize.width)) > 0.1)) { - setTimeout(action(() => { - this.Document.height = this.Document[WidthSym]() * aspect; - this.Document.nativeHeight = realsize.height; - this.Document.nativeWidth = realsize.width; - }), 0); - } + } else this._resized = imgPath; }) .catch((err: any) => console.log(err)); } @@ -284,18 +271,16 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum return !tags ? (null) : (<img id={"google-tags"} src={"/assets/google_tags.png"} />); } - @computed get content() { - TraceMobx(); - const extensionDoc = this.extensionDoc; - if (!extensionDoc) return (null); - // let transform = this.props.ScreenToLocalTransform().inverse(); + @computed get nativeSize() { const pw = typeof this.props.PanelWidth === "function" ? this.props.PanelWidth() : typeof this.props.PanelWidth === "number" ? (this.props.PanelWidth as any) as number : 50; - // var [sptX, sptY] = transform.transformPoint(0, 0); - // let [bptX, bptY] = transform.transformPoint(pw, this.props.PanelHeight()); - // let w = bptX - sptX; - const nativeWidth = (this.Document.nativeWidth || pw); const nativeHeight = (this.Document.nativeHeight || 1); + return { nativeWidth, nativeHeight }; + } + + @computed get pathInfos() { + const extensionDoc = this.extensionDoc!; + const { nativeWidth, nativeHeight } = this.nativeSize; let paths = [[Utils.CorsProxy("http://www.cs.brown.edu/~bcz/noImage.png"), nativeWidth / nativeHeight]]; // this._curSuffix = ""; // if (w > 20) { @@ -307,18 +292,26 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum // else if (this._largeRetryCount < 10) this._curSuffix = "_l"; if (field instanceof ImageField) paths = [[this.choosePath(field.url), nativeWidth / nativeHeight]]; paths.push(...altpaths); - // } - const dragging = !SelectionManager.GetIsDragging() ? "" : "-dragging"; - const rotation = NumCast(this.Document.rotation, 0); - const aspect = (rotation % 180) ? this.Document[HeightSym]() / this.Document[WidthSym]() : 1; - const shift = (rotation % 180) ? (nativeHeight - nativeWidth / aspect) / 2 : 0; const srcpath = paths[Math.min(paths.length - 1, (this.Document.curPage || 0))][0] as string; const srcaspect = paths[Math.min(paths.length - 1, (this.Document.curPage || 0))][1] as number; const fadepath = paths[Math.min(paths.length - 1, 1)][0] as string; + return { srcpath, srcaspect, fadepath }; + } - !this.Document.ignoreAspect && this.resize(srcpath); + @computed get content() { + TraceMobx(); + const extensionDoc = this.extensionDoc; + if (!extensionDoc) return (null); + + const { srcpath, srcaspect, fadepath } = this.pathInfos; + const { nativeWidth, nativeHeight } = this.nativeSize; + const rotation = NumCast(this.Document.rotation, 0); + const aspect = (rotation % 180) ? this.Document[HeightSym]() / this.Document[WidthSym]() : 1; + const shift = (rotation % 180) ? (nativeHeight - nativeWidth / aspect) / 2 : 0; + + !this.Document.ignoreAspect && this._resized !== srcpath && this.resize(srcpath); - return <div className={`imageBox-cont${dragging}`} key={this.props.Document[Id]} ref={this.createDropTarget} onContextMenu={this.specificContextMenu}> + return <div className="imageBox-cont" key={this.props.Document[Id]} ref={this.createDropTarget} onContextMenu={this.specificContextMenu}> <div className="imageBox-fader" > <img key={this._smallRetryCount + (this._mediumRetryCount << 4) + (this._largeRetryCount << 8)} // force cache to update on retrys src={srcpath} @@ -350,7 +343,9 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum contentFunc = () => [this.content]; render() { - return (<div className="imageBox" onContextMenu={this.specificContextMenu} + TraceMobx(); + const dragging = !SelectionManager.GetIsDragging() ? "" : "-dragging"; + return (<div className={`imageBox${dragging}`} onContextMenu={this.specificContextMenu} style={{ transform: `scale(${this.props.ContentScaling()})`, width: `${100 / this.props.ContentScaling()}%`, diff --git a/src/client/views/nodes/KeyValueBox.tsx b/src/client/views/nodes/KeyValueBox.tsx index fba29e4cd..234a6a9d3 100644 --- a/src/client/views/nodes/KeyValueBox.tsx +++ b/src/client/views/nodes/KeyValueBox.tsx @@ -57,7 +57,7 @@ export class KeyValueBox extends React.Component<FieldViewProps> { value = eq ? value.substr(1) : value; const dubEq = value.startsWith(":=") ? "computed" : value.startsWith(";=") ? "script" : false; value = dubEq ? value.substr(2) : value; - const options: ScriptOptions = { addReturn: true, params: { this: "Doc" } }; + const options: ScriptOptions = { addReturn: true, params: { this: "Doc", _last_: "any" }, editable: false }; if (dubEq) options.typecheck = false; const script = CompileScript(value, options); if (!script.compiled) { @@ -66,10 +66,10 @@ export class KeyValueBox extends React.Component<FieldViewProps> { return { script, type: dubEq, onDelegate: eq }; } - public static ApplyKVPScript(doc: Doc, key: string, kvpScript: KVPScript): boolean { + public static ApplyKVPScript(doc: Doc, key: string, kvpScript: KVPScript, forceOnDelegate?: boolean): boolean { const { script, type, onDelegate } = kvpScript; //const target = onDelegate ? Doc.Layout(doc.layout) : Doc.GetProto(doc); // bcz: TODO need to be able to set fields on layout templates - const target = onDelegate ? doc : Doc.GetProto(doc); + const target = forceOnDelegate || onDelegate ? doc : Doc.GetProto(doc); let field: Field; if (type === "computed") { field = new ComputedField(script); @@ -88,10 +88,10 @@ export class KeyValueBox extends React.Component<FieldViewProps> { } @undoBatch - public static SetField(doc: Doc, key: string, value: string) { + public static SetField(doc: Doc, key: string, value: string, forceOnDelegate?: boolean) { const script = this.CompileKVPScript(value); if (!script) return false; - return this.ApplyKVPScript(doc, key, script); + return this.ApplyKVPScript(doc, key, script, forceOnDelegate); } onPointerDown = (e: React.PointerEvent): void => { diff --git a/src/client/views/nodes/KeyValuePair.tsx b/src/client/views/nodes/KeyValuePair.tsx index bca68cdd9..91f8bb3b0 100644 --- a/src/client/views/nodes/KeyValuePair.tsx +++ b/src/client/views/nodes/KeyValuePair.tsx @@ -5,7 +5,6 @@ import { emptyFunction, returnFalse, returnOne, returnZero } from '../../../Util import { Docs } from '../../documents/Documents'; import { Transform } from '../../util/Transform'; import { undoBatch } from '../../util/UndoManager'; -import { CollectionDockingView } from '../collections/CollectionDockingView'; import { ContextMenu } from '../ContextMenu'; import { EditableView } from "../EditableView"; import { FieldView, FieldViewProps } from './FieldView'; @@ -56,6 +55,7 @@ export class KeyValuePair extends React.Component<KeyValuePairProps> { const props: FieldViewProps = { Document: this.props.doc, DataDoc: this.props.doc, + LibraryPath: [], ContainingCollectionView: undefined, ContainingCollectionDoc: undefined, ruleProvider: undefined, diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx index 0f5cc0fa9..8370df6ba 100644 --- a/src/client/views/nodes/PDFBox.tsx +++ b/src/client/views/nodes/PDFBox.tsx @@ -3,11 +3,11 @@ import { action, observable, runInAction, reaction, IReactionDisposer, trace, un import { observer } from "mobx-react"; import * as Pdfjs from "pdfjs-dist"; import "pdfjs-dist/web/pdf_viewer.css"; -import { Opt, WidthSym, Doc } from "../../../new_fields/Doc"; +import { Opt, WidthSym, Doc, HeightSym } from "../../../new_fields/Doc"; import { makeInterface } from "../../../new_fields/Schema"; import { ScriptField } from '../../../new_fields/ScriptField'; -import { Cast, NumCast } from "../../../new_fields/Types"; -import { PdfField } from "../../../new_fields/URLField"; +import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; +import { PdfField, URLField } from "../../../new_fields/URLField"; import { Utils } from '../../../Utils'; import { KeyCodes } from '../../northstar/utils/KeyCodes'; import { undoBatch } from '../../util/UndoManager'; @@ -21,6 +21,7 @@ import { pageSchema } from "./ImageBox"; import "./PDFBox.scss"; import React = require("react"); import { documentSchema } from '../../../new_fields/documentSchemas'; +import { url } from 'inspector'; type PdfDocument = makeInterface<[typeof documentSchema, typeof panZoomSchema, typeof pageSchema]>; const PdfDocument = makeInterface(documentSchema, panZoomSchema, pageSchema); @@ -49,16 +50,38 @@ export class PDFBox extends DocAnnotatableComponent<FieldViewProps, PdfDocument> constructor(props: any) { super(props); this._initialScale = this.props.ScreenToLocalTransform().Scale; + const nw = this.Document.nativeWidth = NumCast(this.extensionDocSync.nativeWidth, NumCast(this.Document.nativeWidth, 927)); + const nh = this.Document.nativeHeight = NumCast(this.extensionDocSync.nativeHeight, NumCast(this.Document.nativeHeight, 1200)); + !this.Document.fitWidth && !this.Document.ignoreAspect && (this.Document.height = this.Document[WidthSym]() * (nh / nw)); + + const backup = "oldPath"; + const { Document } = this.props; + const { url: { href } } = Cast(Document[this.props.fieldKey], PdfField)!; + const pathCorrectionTest = /upload\_[a-z0-9]{32}.(.*)/g; + const matches = pathCorrectionTest.exec(href); + console.log("\nHere's the { url } being fed into the outer regex:"); + console.log(href); + console.log("And here's the 'properPath' build from the captured filename:\n"); + if (matches !== null && href.startsWith(window.location.origin)) { + const properPath = Utils.prepend(`/files/pdfs/${matches[0]}`); + console.log(properPath); + if (!properPath.includes(href)) { + console.log(`The two (url and proper path) were not equal`); + const proto = Doc.GetProto(Document); + proto[this.props.fieldKey] = new PdfField(properPath); + proto[backup] = href; + } else { + console.log(`The two (url and proper path) were equal`); + } + } else { + console.log("Outer matches was null!"); + } } componentWillUnmount() { this._selectReactionDisposer && this._selectReactionDisposer(); } componentDidMount() { - const pdfUrl = Cast(this.dataDoc[this.props.fieldKey], PdfField); - if (pdfUrl instanceof PdfField) { - Pdfjs.getDocument(pdfUrl.url.pathname).promise.then(pdf => runInAction(() => this._pdf = pdf)); - } this._selectReactionDisposer = reaction(() => this.props.isSelected(), () => { document.removeEventListener("keydown", this.onKeyDown); @@ -67,9 +90,9 @@ export class PDFBox extends DocAnnotatableComponent<FieldViewProps, PdfDocument> } loaded = (nw: number, nh: number, np: number) => { - this.dataDoc.numPages = np; - this.Document.nativeWidth = nw * 96 / 72; - this.Document.nativeHeight = nh * 96 / 72; + this.extensionDocSync.numPages = np; + this.extensionDocSync.nativeWidth = this.Document.nativeWidth = nw * 96 / 72; + this.extensionDocSync.nativeHeight = this.Document.nativeHeight = nh * 96 / 72; !this.Document.fitWidth && !this.Document.ignoreAspect && (this.Document.height = this.Document[WidthSym]() * (nh / nw)); } @@ -197,9 +220,9 @@ export class PDFBox extends DocAnnotatableComponent<FieldViewProps, PdfDocument> @computed get renderTitleBox() { const classname = "pdfBox" + (this.active() ? "-interactive" : ""); return <div className={classname} style={{ - width: !this.props.Document.fitWidth ? NumCast(this.props.Document.nativeWidth) : `${100 / this.contentScaling}%`, - height: !this.props.Document.fitWidth ? NumCast(this.props.Document.nativeHeight) : `${100 / this.contentScaling}%`, - transform: `scale(${this.props.ContentScaling()})` + width: !this.props.Document.fitWidth ? this.Document.nativeWidth || 0 : `${100 / this.contentScaling}%`, + height: !this.props.Document.fitWidth ? this.Document.nativeHeight || 0 : `${100 / this.contentScaling}%`, + transform: `scale(${this.contentScaling})` }} > <div className="pdfBox-title-outer"> <strong className="pdfBox-title" >{this.props.Document.title}</strong> @@ -225,10 +248,19 @@ export class PDFBox extends DocAnnotatableComponent<FieldViewProps, PdfDocument> </div>; } + _pdfjsRequested = false; render() { const pdfUrl = Cast(this.dataDoc[this.props.fieldKey], PdfField, null); if (this.props.isSelected() || this.props.Document.scrollY !== undefined) this._everActive = true; - return !pdfUrl || !this._pdf || !this.extensionDoc || (!this._everActive && this.props.ScreenToLocalTransform().Scale > 2.5) ? - this.renderTitleBox : this.renderPdfView; + if (pdfUrl && this.extensionDoc && (this._everActive || (this.extensionDoc.nativeWidth && this.props.ScreenToLocalTransform().Scale < 2.5))) { + if (pdfUrl instanceof PdfField && this._pdf) { + return this.renderPdfView; + } + if (!this._pdfjsRequested) { + this._pdfjsRequested = true; + Pdfjs.getDocument(pdfUrl.url.href).promise.then(pdf => runInAction(() => this._pdf = pdf)); + } + } + return this.renderTitleBox; } }
\ No newline at end of file diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx index b0a93224d..b35ea0bb0 100644 --- a/src/client/views/nodes/WebBox.tsx +++ b/src/client/views/nodes/WebBox.tsx @@ -194,7 +194,7 @@ export class WebBox extends DocAnnotatableComponent<FieldViewProps, WebDocument> </>); } render() { - return (<div className={"imageBox-container"} > + return (<div className={"webBox-container"} > <CollectionFreeFormView {...this.props} PanelHeight={this.props.PanelHeight} PanelWidth={this.props.PanelWidth} diff --git a/src/client/views/pdf/PDFMenu.tsx b/src/client/views/pdf/PDFMenu.tsx index 38e10e99a..05c70b74a 100644 --- a/src/client/views/pdf/PDFMenu.tsx +++ b/src/client/views/pdf/PDFMenu.tsx @@ -12,19 +12,17 @@ export default class PDFMenu extends AntimodeMenu { static Instance: PDFMenu; private _commentCont = React.createRef<HTMLButtonElement>(); - private _snippetButton: React.RefObject<HTMLButtonElement> = React.createRef(); @observable private _keyValue: string = ""; @observable private _valueValue: string = ""; @observable private _added: boolean = false; @observable public Highlighting: boolean = false; - @observable public Status: "pdf" | "annotation" | "snippet" | "" = ""; + @observable public Status: "pdf" | "annotation" | "" = ""; public StartDrag: (e: PointerEvent, ele: HTMLElement) => void = unimplementedFunction; public Highlight: (color: string) => Opt<Doc> = (color: string) => undefined; public Delete: () => void = unimplementedFunction; - public Snippet: (marquee: { left: number, top: number, width: number, height: number }) => void = unimplementedFunction; public AddTag: (key: string, value: string) => boolean = returnFalse; public PinToPres: () => void = unimplementedFunction; public Marquee: { left: number; top: number; width: number; height: number; } | undefined; @@ -80,34 +78,6 @@ export default class PDFMenu extends AntimodeMenu { this.Delete(); } - snippetStart = (e: React.PointerEvent) => { - document.removeEventListener("pointermove", this.snippetDrag); - document.addEventListener("pointermove", this.snippetDrag); - document.removeEventListener("pointerup", this.snippetEnd); - document.addEventListener("pointerup", this.snippetEnd); - - e.stopPropagation(); - e.preventDefault(); - } - - snippetDrag = (e: PointerEvent) => { - e.stopPropagation(); - e.preventDefault(); - if (!this._dragging) { - this._dragging = true; - - this.Marquee && this.Snippet(this.Marquee); - } - } - - snippetEnd = (e: PointerEvent) => { - this._dragging = false; - document.removeEventListener("pointermove", this.snippetDrag); - document.removeEventListener("pointerup", this.snippetEnd); - e.stopPropagation(); - e.preventDefault(); - } - @action keyChanged = (e: React.ChangeEvent<HTMLInputElement>) => { this._keyValue = e.currentTarget.value; @@ -128,14 +98,12 @@ export default class PDFMenu extends AntimodeMenu { } render() { - const buttons = this.Status === "pdf" || this.Status === "snippet" ? + const buttons = this.Status === "pdf" ? [ <button key="1" className="antimodeMenu-button" title="Click to Highlight" onClick={this.highlightClicked} style={this.Highlighting ? { backgroundColor: "#121212" } : {}}> <FontAwesomeIcon icon="highlighter" size="lg" style={{ transition: "transform 0.1s", transform: this.Highlighting ? "" : "rotate(-45deg)" }} /></button>, <button key="2" className="antimodeMenu-button" title="Drag to Annotate" ref={this._commentCont} onPointerDown={this.pointerDown}> <FontAwesomeIcon icon="comment-alt" size="lg" /></button>, - <button key="3" className="antimodeMenu-button" title="Drag to Snippetize Selection" style={{ display: this.Status === "snippet" ? "" : "none" }} onPointerDown={this.snippetStart} ref={this._snippetButton}> - <FontAwesomeIcon icon="cut" size="lg" /></button>, <button key="4" className="antimodeMenu-button" title="Pin Menu" onClick={this.togglePin} style={this.Pinned ? { backgroundColor: "#121212" } : {}}> <FontAwesomeIcon icon="thumbtack" size="lg" style={{ transition: "transform 0.1s", transform: this.Pinned ? "rotate(45deg)" : "" }} /> </button> ] : [ diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx index 69aacc902..62467ce4d 100644 --- a/src/client/views/pdf/PDFViewer.tsx +++ b/src/client/views/pdf/PDFViewer.tsx @@ -30,6 +30,7 @@ import { DocumentDecorations } from "../DocumentDecorations"; import { InkingControl } from "../InkingControl"; import { InkTool } from "../../../new_fields/InkField"; import { TraceMobx } from "../../../new_fields/util"; +import { PdfField } from "../../../new_fields/URLField"; const PDFJSViewer = require("pdfjs-dist/web/pdf_viewer"); const pdfjsLib = require("pdfjs-dist"); @@ -39,7 +40,7 @@ export const pageSchema = createSchema({ rotation: "number", scrollY: "number", scrollHeight: "number", - search_string: "string" + serachMatch: "boolean" }); pdfjsLib.GlobalWorkerOptions.workerSrc = `/assets/pdf.worker.js`; @@ -125,14 +126,16 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument !this.props.Document.lockedTransform && (this.props.Document.lockedTransform = true); // change the address to be the file address of the PNG version of each page // file address of the pdf - const path = Utils.prepend(`/thumbnail${this.props.url.substring("files/pdfs/".length, this.props.url.length - ".pdf".length)}-${(this.Document.curPage || 1)}.png`); - this._coverPath = JSON.parse(await rp.get(path)); + const { url: { href } } = Cast(this.props.Document[this.props.fieldKey], PdfField)!; + this._coverPath = href.startsWith(window.location.origin) ? + JSON.parse(await rp.get(Utils.prepend(`/thumbnail${this.props.url.substring("files/pdfs/".length, this.props.url.length - ".pdf".length)}-${(this.Document.curPage || 1)}.png`))) : + { width: 100, height: 100, path: "" }; runInAction(() => this._showWaiting = this._showCover = true); this.props.startupLive && this.setupPdfJsViewer(); - this._searchReactionDisposer = reaction(() => this.Document.search_string, searchString => { - if (searchString) { - this.search(searchString, true); - this._lastSearch = searchString; + this._searchReactionDisposer = reaction(() => this.Document.searchMatch, search => { + if (search) { + this.search(Doc.SearchQuery(), true); + this._lastSearch = Doc.SearchQuery(); } else { setTimeout(() => this._lastSearch === "mxytzlaf" && this.search("mxytzlaf", true), 200); // bcz: how do we clear search highlights? @@ -409,13 +412,13 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument if ((this.Document.scale || 1) !== 1) return; if ((e.button !== 0 || e.altKey) && this.active(true)) { this._setPreviewCursor && this._setPreviewCursor(e.clientX, e.clientY, true); + //e.stopPropagation(); } this._marqueeing = false; if (!e.altKey && e.button === 0 && this.active(true)) { // clear out old marquees and initialize menu for new selection PDFMenu.Instance.StartDrag = this.startDrag; PDFMenu.Instance.Highlight = this.highlight; - PDFMenu.Instance.Snippet = this.createSnippet; PDFMenu.Instance.Status = "pdf"; PDFMenu.Instance.fadeOut(true); this._savedAnnotations.values().forEach(v => v.forEach(a => a.remove())); @@ -513,7 +516,6 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument } if (!e.ctrlKey) { - PDFMenu.Instance.Status = "snippet"; PDFMenu.Instance.Marquee = { left: this._marqueeX, top: this._marqueeY, width: this._marqueeWidth, height: this._marqueeHeight }; } PDFMenu.Instance.jumpTo(e.clientX, e.clientY); @@ -559,14 +561,9 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument const targetDoc = Docs.Create.TextDocument({ width: 200, height: 200, title: "Note linked to " + this.props.Document.title }); const annotationDoc = this.highlight("rgba(146, 245, 95, 0.467)"); // yellowish highlight color when dragging out a text selection if (annotationDoc) { - const dragData = new DragManager.AnnotationDragData(this.props.Document, annotationDoc, targetDoc); - DragManager.StartAnnotationDrag([ele], dragData, e.pageX, e.pageY, { - handlers: { - dragComplete: () => !(dragData as any).linkedToDoc && - DocUtils.MakeLink({ doc: annotationDoc }, { doc: dragData.dropDocument, ctx: dragData.targetContext }, `Annotation from ${this.Document.title}`, "link from PDF") - - }, - hideSource: false + DragManager.StartPdfAnnoDrag([ele], new DragManager.PdfAnnoDragData(this.props.Document, annotationDoc, targetDoc), e.pageX, e.pageY, { + dragComplete: e => !e.aborted && e.annoDragData && !e.annoDragData.linkedToDoc && + DocUtils.MakeLink({ doc: annotationDoc }, { doc: e.annoDragData.dropDocument, ctx: e.annoDragData.targetContext }, `Annotation from ${this.Document.title}`, "link from PDF") }); } } @@ -602,12 +599,13 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument if (!this.props.Document[HeightSym]() || !this.props.Document.nativeHeight) { setTimeout((() => { this.Document.height = this.Document[WidthSym]() * this._coverPath.height / this._coverPath.width; - this.Document.nativeHeight = nativeWidth * this._coverPath.height / this._coverPath.width; + this.Document.nativeHeight = (this.Document.nativeWidth || 0) * this._coverPath.height / this._coverPath.width; }).bind(this), 0); } const nativeWidth = (this.Document.nativeWidth || 0); const nativeHeight = (this.Document.nativeHeight || 0); - return <img key={this._coverPath.path} src={this._coverPath.path} onError={action(() => this._coverPath.path = "http://www.cs.brown.edu/~bcz/face.gif")} onLoad={action(() => this._showWaiting = false)} + const resolved = Utils.prepend(this._coverPath.path); + return <img key={resolved} src={resolved} onError={action(() => this._coverPath.path = "http://www.cs.brown.edu/~bcz/face.gif")} onLoad={action(() => this._showWaiting = false)} style={{ position: "absolute", display: "inline-block", top: 0, left: 0, width: `${nativeWidth}px`, height: `${nativeHeight}px` }} />; } @@ -634,6 +632,7 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument @computed get overlayLayer() { return <div className={`pdfViewer-overlay${InkingControl.Instance.selectedTool !== InkTool.None ? "-inking" : ""}`} id="overlay" style={{ transform: `scale(${this._zoomed})` }}> <CollectionFreeFormView {...this.props} + LibraryPath={this.props.ContainingCollectionView?.props.LibraryPath ?? []} annotationsKey={this.annotationsKey} setPreviewCursor={this.setPreviewCursor} PanelHeight={this.panelWidth} diff --git a/src/client/views/presentationview/PresElementBox.tsx b/src/client/views/presentationview/PresElementBox.tsx index 47f876ac2..c02042380 100644 --- a/src/client/views/presentationview/PresElementBox.tsx +++ b/src/client/views/presentationview/PresElementBox.tsx @@ -9,7 +9,7 @@ import { documentSchema } from '../../../new_fields/documentSchemas'; import { Id } from "../../../new_fields/FieldSymbols"; import { createSchema, makeInterface } from '../../../new_fields/Schema'; import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; -import { emptyFunction, returnFalse } from "../../../Utils"; +import { emptyFunction, returnFalse, emptyPath } from "../../../Utils"; import { DocumentType } from "../../documents/DocumentTypes"; import { Transform } from "../../util/Transform"; import { CollectionViewType } from '../collections/CollectionView'; @@ -170,8 +170,9 @@ export class PresElementBox extends DocComponent<FieldViewProps, PresDocument>(P width: propDocWidth === 0 ? "auto" : propDocWidth * scale(), }}> <ContentFittingDocumentView - fitToBox={StrCast(this.targetDoc.type).indexOf(DocumentType.COL) !== -1} Document={this.targetDoc} + LibraryPath={emptyPath} + fitToBox={StrCast(this.targetDoc.type).indexOf(DocumentType.COL) !== -1} addDocument={returnFalse} removeDocument={returnFalse} ruleProvider={undefined} @@ -179,7 +180,6 @@ export class PresElementBox extends DocComponent<FieldViewProps, PresDocument>(P pinToPres={returnFalse} PanelWidth={() => this.props.PanelWidth() - 20} PanelHeight={() => 100} - setPreviewScript={emptyFunction} getTransform={Transform.Identity} active={this.props.active} moveDocument={this.props.moveDocument!} diff --git a/src/client/views/search/FilterBox.tsx b/src/client/views/search/FilterBox.tsx index 12e1bc265..684f50766 100644 --- a/src/client/views/search/FilterBox.tsx +++ b/src/client/views/search/FilterBox.tsx @@ -62,15 +62,6 @@ export class FilterBox extends React.Component { super(props); FilterBox.Instance = this; } - - componentDidMount = () => { - document.addEventListener("pointerdown", (e) => { - if (!e.defaultPrevented && e.timeStamp !== this._pointerTime) { - SearchBox.Instance.closeSearch(); - } - }); - } - setupAccordion() { $('document').ready(function () { const acc = document.getElementsByClassName('filter-header'); diff --git a/src/client/views/search/SearchBox.scss b/src/client/views/search/SearchBox.scss index 4eb992d36..0825580b7 100644 --- a/src/client/views/search/SearchBox.scss +++ b/src/client/views/search/SearchBox.scss @@ -70,8 +70,7 @@ display: flex; flex-direction: column; height: 100%; - overflow: hidden; - overflow-y: auto; + overflow: visible; .no-result { width: 500px; diff --git a/src/client/views/search/SearchBox.tsx b/src/client/views/search/SearchBox.tsx index c45fbe210..dd1ac7421 100644 --- a/src/client/views/search/SearchBox.tsx +++ b/src/client/views/search/SearchBox.tsx @@ -12,13 +12,11 @@ import { Utils } from '../../../Utils'; import { Docs } from '../../documents/Documents'; import { SetupDrag } from '../../util/DragManager'; import { SearchUtil } from '../../util/SearchUtil'; -import { MainView } from '../MainView'; import { FilterBox } from './FilterBox'; import "./FilterBox.scss"; import "./SearchBox.scss"; import { SearchItem } from './SearchItem'; import { IconBar } from './IconBar'; -import { string } from 'prop-types'; library.add(faTimes); @@ -85,7 +83,11 @@ export class SearchBox extends React.Component { this._maxSearchIndex = 0; } - enter = (e: React.KeyboardEvent) => { if (e.key === "Enter") { this.submitSearch(); } }; + enter = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + this.submitSearch(); + } + } public static async convertDataUri(imageUri: string, returnedFilename: string) { try { @@ -232,8 +234,7 @@ export class SearchBox extends React.Component { y += 300; } } - return Docs.Create.FreeformDocument(docs, { width: 400, height: 400, panX: 175, panY: 175, backgroundColor: "grey", title: `Search Docs: "${this._searchString}"` }); - + return Docs.Create.TreeDocument(docs, { width: 200, height: 400, backgroundColor: "grey", title: `Search Docs: "${this._searchString}"` }); } @action.bound @@ -307,7 +308,7 @@ export class SearchBox extends React.Component { this.getResults(this._searchString); if (i < this._results.length) result = this._results[i]; if (result) { - const highlights = Array.from([...Array.from(new Set(result[1]).values())]).filter(v => v !== "search_string"); + const highlights = Array.from([...Array.from(new Set(result[1]).values())]); this._visibleElements[i] = <SearchItem doc={result[0]} query={this._searchString} key={result[0][Id]} lines={result[2]} highlighting={highlights} />; this._isSearch[i] = "search"; } @@ -315,7 +316,7 @@ export class SearchBox extends React.Component { else { result = this._results[i]; if (result) { - const highlights = Array.from([...Array.from(new Set(result[1]).values())]).filter(v => v !== "search_string"); + const highlights = Array.from([...Array.from(new Set(result[1]).values())]); this._visibleElements[i] = <SearchItem doc={result[0]} query={this._searchString} key={result[0][Id]} lines={result[2]} highlighting={highlights} />; this._isSearch[i] = "search"; } @@ -337,9 +338,9 @@ export class SearchBox extends React.Component { render() { return ( - <div className="searchBox-container"> + <div className="searchBox-container" onPointerDown={e => { e.stopPropagation(); e.preventDefault(); }}> <div className="searchBox-bar"> - <span className="searchBox-barChild searchBox-collection" onPointerDown={SetupDrag(this.collectionRef, this.startDragCollection)} ref={this.collectionRef} title="Drag Results as Collection"> + <span className="searchBox-barChild searchBox-collection" onPointerDown={SetupDrag(this.collectionRef, () => this._searchString ? this.startDragCollection() : undefined)} ref={this.collectionRef} title="Drag Results as Collection"> <FontAwesomeIcon icon="object-group" size="lg" /> </span> <input value={this._searchString} onChange={this.onChange} type="text" placeholder="Search..." id="search-input" ref={this.inputRef} @@ -347,13 +348,13 @@ export class SearchBox extends React.Component { style={{ width: this._searchbarOpen ? "500px" : "100px" }} /> <button className="searchBox-barChild searchBox-filter" title="Advanced Filtering Options" onClick={FilterBox.Instance.openFilter} onPointerDown={FilterBox.Instance.stopProp}><FontAwesomeIcon icon="ellipsis-v" color="white" /></button> </div> - {(this._numTotalResults > 0 || !this._searchbarOpen) ? (null) : - (<div className="searchBox-quickFilter" onPointerDown={this.openSearch}> - <div className="filter-panel"><IconBar /></div> - </div>)} + <div className="searchBox-quickFilter" onPointerDown={this.openSearch}> + <div className="filter-panel"><IconBar /></div> + </div> <div className="searchBox-results" onScroll={this.resultsScrolled} style={{ display: this._resultsOpen ? "flex" : "none", - height: this.resFull ? "auto" : this.resultHeight, overflow: this.resFull ? "auto" : "visible" + height: this.resFull ? "auto" : this.resultHeight, + overflow: "visibile" // this.resFull ? "auto" : "visible" }} ref={this.resultsRef}> {this._visibleElements} </div> diff --git a/src/client/views/search/SearchItem.scss b/src/client/views/search/SearchItem.scss index 9f12994c3..469f062b2 100644 --- a/src/client/views/search/SearchItem.scss +++ b/src/client/views/search/SearchItem.scss @@ -1,22 +1,14 @@ @import "../globalCssVariables"; -.search-overview { +.searchItem-overview { display: flex; flex-direction: reverse; justify-content: flex-end; z-index: 0; } -.link-count { - background: black; - border-radius: 20px; - color: white; - width: 15px; - text-align: center; - margin-top: 5px; -} .searchBox-placeholder, -.search-overview .search-item { +.searchItem-overview .searchItem { width: 100%; background: $light-color-secondary; border-color: $intermediate-color; @@ -26,19 +18,19 @@ max-height: 150px; height: auto; z-index: 0; - display: inline-block; - overflow: auto; + display: flex; + overflow: visible; - .main-search-info { + .searchItem-body { display: flex; flex-direction: row; width: 100%; - .search-title-container { + .searchItem-title-container { width: 100%; overflow: hidden; - .search-title { + .searchItem-title { text-transform: uppercase; text-align: left; width: 100%; @@ -46,75 +38,28 @@ } } - .search-info { + .searchItem-info { display: flex; justify-content: flex-end; - .link-container.item { - margin-left: auto; - margin-right: auto; - height: 26px; - width: 26px; - border-radius: 13px; - background: $dark-color; - color: $light-color-secondary; - display: flex; - justify-content: center; - align-items: center; - -webkit-transition: all 0.2s ease-in-out; - -moz-transition: all 0.2s ease-in-out; - -o-transition: all 0.2s ease-in-out; - transition: all 0.2s ease-in-out; - transform-origin: top right; - overflow: hidden; - position: relative; - - - .link-extended { - // display: none; - visibility: hidden; - opacity: 0; - position: relative; - z-index: 500; - overflow: hidden; - -webkit-transition: opacity 0.2s ease-in-out .2s, visibility 0s linear 0s; - -moz-transition: opacity 0.2s ease-in-out .2s, visibility 0s linear 0s; - -o-transition: opacity 0.2s ease-in-out .2s, visibility 0s linear 0s; - transition: opacity 0.2s ease-in-out .2s, visibility 0s linear 0s; - // transition-delay: 1s; - } - - } - - .link-container.item:hover { - width: 70px; - } - - .link-container.item:hover .link-count { - opacity: 0; - } - - .link-container.item:hover .link-extended { - opacity: 1; - visibility: visible; - // display: inline; - } - .icon-icons { width: 50px } .icon-live { width: 175px; + height: 0px; } + .icon-icons { + height:auto; + } .icon-icons, .icon-live { - height: auto; margin: auto; - overflow: hidden; + overflow: visible; - .search-type { + .searchItem-type { display: inline-block; width: 100%; position: absolute; @@ -133,11 +78,11 @@ } } - .search-type:hover+.search-label { + .searchItem-type:hover+.searchItem-label { opacity: 1; } - .search-label { + .searchItem-label { font-size: 10; position: relative; right: 0px; @@ -151,8 +96,6 @@ } .icon-live:hover { - height: 175px; - .pdfBox-cont { img { width: 100% !important; @@ -161,48 +104,51 @@ } } - .search-info:hover { + .searchItem-info:hover { width: 60%; } } } -.search-item:hover~.searchBox-instances, +.searchItem:hover~.searchBox-instances, .searchBox-instances:hover, .searchBox-instances:active { opacity: 1; background: $lighter-alt-accent; - width:150px } -.search-item:hover { +.searchItem:hover { transition: all 0.2s; background: $lighter-alt-accent; } -.search-highlighting { +.searchItem-highlighting { overflow: hidden; text-overflow: ellipsis; white-space: pre; } .searchBox-instances { - float: left; opacity: 1; - width: 0px; + width:40px; + height:40px; + background: gray; transition: all 0.2s ease; color: black; overflow: hidden; + right:-100; + display:inline-block; } -.search-overview:hover { +.searchItem-overview:hover { z-index: 1; } .searchBox-placeholder { min-height: 50px; margin-left: 150px; + width: calc(100% - 150px); text-transform: uppercase; text-align: left; font-weight: bold; diff --git a/src/client/views/search/SearchItem.tsx b/src/client/views/search/SearchItem.tsx index 6ffff0562..88a4d4c50 100644 --- a/src/client/views/search/SearchItem.tsx +++ b/src/client/views/search/SearchItem.tsx @@ -4,24 +4,24 @@ import { faCaretUp, faChartBar, faFile, faFilePdf, faFilm, faFingerprint, faGlob import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { action, computed, observable, runInAction } from "mobx"; import { observer } from "mobx-react"; -import { Doc } from "../../../new_fields/Doc"; +import { Doc, DocListCast } from "../../../new_fields/Doc"; import { Id } from "../../../new_fields/FieldSymbols"; import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; -import { emptyFunction, returnEmptyString, returnFalse, returnOne, Utils } from "../../../Utils"; +import { emptyFunction, returnEmptyString, returnFalse, returnOne, Utils, emptyPath } from "../../../Utils"; import { DocumentType } from "../../documents/DocumentTypes"; import { DocumentManager } from "../../util/DocumentManager"; import { DragManager, SetupDrag } from "../../util/DragManager"; -import { LinkManager } from "../../util/LinkManager"; import { SearchUtil } from "../../util/SearchUtil"; import { Transform } from "../../util/Transform"; import { SEARCH_THUMBNAIL_SIZE } from "../../views/globalCssVariables.scss"; import { CollectionViewType } from "../collections/CollectionView"; import { CollectionDockingView } from "../collections/CollectionDockingView"; import { ContextMenu } from "../ContextMenu"; -import { DocumentView } from "../nodes/DocumentView"; import { SearchBox } from "./SearchBox"; import "./SearchItem.scss"; import "./SelectorContextMenu.scss"; +import { ContentFittingDocumentView } from "../nodes/ContentFittingDocumentView"; +import { ButtonSelector, ParentDocSelector } from "../collections/ParentDocumentSelector"; export interface SearchItemProps { doc: Doc; @@ -135,12 +135,11 @@ export class SearchItem extends React.Component<SearchItemProps> { @observable _displayDim = 50; componentDidMount() { - this.props.doc.search_string = this.props.query; - this.props.doc.search_fields = this.props.highlighting.join(", "); + Doc.SetSearchQuery(this.props.query); + this.props.doc.searchMatch = true; } componentWillUnmount() { - this.props.doc.search_string = undefined; - this.props.doc.search_fields = undefined; + this.props.doc.searchMatch = undefined; } //@computed @@ -150,36 +149,29 @@ export class SearchItem extends React.Component<SearchItemProps> { if (!this._useIcons) { const returnXDimension = () => this._useIcons ? 50 : Number(SEARCH_THUMBNAIL_SIZE); const returnYDimension = () => this._displayDim; - const scale = () => returnXDimension() / NumCast(Doc.Layout(this.props.doc).nativeWidth, returnXDimension()); const docview = <div onPointerDown={action(() => { this._useIcons = !this._useIcons; this._displayDim = this._useIcons ? 50 : Number(SEARCH_THUMBNAIL_SIZE); })} - onPointerEnter={action(() => this._displayDim = this._useIcons ? 50 : Number(SEARCH_THUMBNAIL_SIZE))} - onPointerLeave={action(() => this._displayDim = 50)} > - <DocumentView - fitToBox={StrCast(this.props.doc.type).indexOf(DocumentType.COL) !== -1} + onPointerEnter={action(() => this._displayDim = this._useIcons ? 50 : Number(SEARCH_THUMBNAIL_SIZE))} > + <ContentFittingDocumentView Document={this.props.doc} + LibraryPath={emptyPath} + fitToBox={StrCast(this.props.doc.type).indexOf(DocumentType.COL) !== -1} addDocument={returnFalse} removeDocument={returnFalse} ruleProvider={undefined} - ScreenToLocalTransform={Transform.Identity} addDocTab={returnFalse} pinToPres={returnFalse} + getTransform={Transform.Identity} renderDepth={1} PanelWidth={returnXDimension} PanelHeight={returnYDimension} focus={emptyFunction} - backgroundColor={returnEmptyString} - parentActive={returnFalse} + moveDocument={returnFalse} + active={returnFalse} whenActiveChanged={returnFalse} - bringToFront={emptyFunction} - zoomToScale={emptyFunction} - getScale={returnOne} - ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} - ContentScaling={scale} /> </div>; return docview; @@ -194,33 +186,21 @@ export class SearchItem extends React.Component<SearchItemProps> { layoutresult.indexOf(DocumentType.HIST) !== -1 ? faChartBar : layoutresult.indexOf(DocumentType.WEB) !== -1 ? faGlobeAsia : faCaretUp; - return <div onPointerDown={action(() => { this._useIcons = false; this._displayDim = Number(SEARCH_THUMBNAIL_SIZE); })} > + return <div onClick={action(() => { this._useIcons = false; this._displayDim = Number(SEARCH_THUMBNAIL_SIZE); })} > <FontAwesomeIcon icon={button} size="2x" /> </div>; } collectionRef = React.createRef<HTMLDivElement>(); - startDocDrag = () => { - const doc = this.props.doc; - const isProto = Doc.GetT(doc, "isPrototype", "boolean", true); - if (isProto) { - return Doc.MakeDelegate(doc); - } else { - return Doc.MakeAlias(doc); - } - } - - @computed - get linkCount() { return LinkManager.Instance.getAllRelatedLinks(this.props.doc).length; } @action pointerDown = (e: React.PointerEvent) => { e.preventDefault(); e.button === 0 && SearchBox.Instance.openSearch(e); } nextHighlight = (e: React.PointerEvent) => { - e.preventDefault(); e.button === 0 && SearchBox.Instance.openSearch(e); - const sstring = StrCast(this.props.doc.search_string); - this.props.doc.search_string = ""; - setTimeout(() => this.props.doc.search_string = sstring, 0); + e.preventDefault(); + e.button === 0 && SearchBox.Instance.openSearch(e); + this.props.doc.searchMatch = false; + setTimeout(() => this.props.doc.searchMatch = true, 0); } highlightDoc = (e: React.PointerEvent) => { if (this.props.doc.type === DocumentType.LINK) { @@ -264,46 +244,62 @@ export class SearchItem extends React.Component<SearchItemProps> { ContextMenu.Instance.displayMenu(e.clientX, e.clientY); } + _downX = 0; + _downY = 0; + _target: any; onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => { + this._downX = e.clientX; + this._downY = e.clientY; e.stopPropagation(); - const doc = Doc.IsPrototype(this.props.doc) ? Doc.MakeDelegate(this.props.doc) : this.props.doc; - DragManager.StartDocumentDrag([e.currentTarget], new DragManager.DocumentDragData([doc]), e.clientX, e.clientY, { - handlers: { dragComplete: emptyFunction }, - hideSource: false, - }); + this._target = e.currentTarget; + document.removeEventListener("pointermove", this.onPointerMoved); + document.removeEventListener("pointerup", this.onPointerUp); + document.addEventListener("pointermove", this.onPointerMoved); + document.addEventListener("pointerup", this.onPointerUp); + } + onPointerMoved = (e: PointerEvent) => { + if (Math.abs(e.clientX - this._downX) > Utils.DRAG_THRESHOLD || + Math.abs(e.clientY - this._downY) > Utils.DRAG_THRESHOLD) { + console.log("DRAGGIGNG"); + document.removeEventListener("pointermove", this.onPointerMoved); + document.removeEventListener("pointerup", this.onPointerUp); + const doc = Doc.IsPrototype(this.props.doc) ? Doc.MakeDelegate(this.props.doc) : this.props.doc; + DragManager.StartDocumentDrag([this._target], new DragManager.DocumentDragData([doc]), e.clientX, e.clientY); + } + } + onPointerUp = (e: PointerEvent) => { + document.removeEventListener("pointermove", this.onPointerMoved); + document.removeEventListener("pointerup", this.onPointerUp); + } + + @computed + get contextButton() { + return <ParentDocSelector Views={DocumentManager.Instance.DocumentViews} Document={this.props.doc} addDocTab={(doc, data, where) => CollectionDockingView.AddRightSplit(doc, data)} />; } render() { const doc1 = Cast(this.props.doc.anchor1, Doc); const doc2 = Cast(this.props.doc.anchor2, Doc); - return ( - <div className="search-overview" onPointerDown={this.pointerDown} onContextMenu={this.onContextMenu}> - <div className="search-item" onPointerDown={this.nextHighlight} onPointerEnter={this.highlightDoc} onPointerLeave={this.unHighlightDoc} id="result" - onClick={this.onClick}> - <div className="main-search-info"> - <div title="Drag as document" onPointerDown={this.onPointerDown} style={{ marginRight: "7px" }}> <FontAwesomeIcon icon="file" size="lg" /> - <div className="link-container item"> - <div className="link-count" title={`${this.linkCount + " links"}`}>{this.linkCount}</div> - </div> - </div> - <div className="search-title-container"> - <div className="search-title">{StrCast(this.props.doc.title)}</div> - <div className="search-highlighting">{this.props.highlighting.length ? "Matched fields:" + this.props.highlighting.join(", ") : this.props.lines.length ? this.props.lines[0] : ""}</div> - {this.props.lines.filter((m, i) => i).map((l, i) => <div id={i.toString()} className="search-highlighting">`${l}`</div>)} - </div> - <div className="search-info" style={{ width: this._useIcons ? "15%" : "400px" }}> - <div className={`icon-${this._useIcons ? "icons" : "live"}`}> - <div className="search-type" title="Click to Preview">{this.DocumentIcon()}</div> - <div className="search-label">{this.props.doc.type ? this.props.doc.type : "Other"}</div> - </div> - </div> + return <div className="searchItem-overview" onPointerDown={this.pointerDown} onContextMenu={this.onContextMenu}> + <div className="searchItem" onPointerDown={this.nextHighlight} onPointerEnter={this.highlightDoc} onPointerLeave={this.unHighlightDoc}> + <div className="searchItem-body" onClick={this.onClick}> + <div className="searchItem-title-container"> + <div className="searchItem-title">{StrCast(this.props.doc.title)}</div> + <div className="searchItem-highlighting">{this.props.highlighting.length ? "Matched fields:" + this.props.highlighting.join(", ") : this.props.lines.length ? this.props.lines[0] : ""}</div> + {this.props.lines.filter((m, i) => i).map((l, i) => <div id={i.toString()} className="searchItem-highlighting">`${l}`</div>)} + </div> + </div> + <div className="searchItem-info" style={{ width: this._useIcons ? "30px" : "100%" }}> + <div className={`icon-${this._useIcons ? "icons" : "live"}`}> + <div className="searchItem-type" title="Click to Preview" onPointerDown={this.onPointerDown}>{this.DocumentIcon()}</div> + <div className="searchItem-label">{this.props.doc.type ? this.props.doc.type : "Other"}</div> </div> </div> - <div className="searchBox-instances"> + <div className="searchItem-context" title="Drag as document"> {(doc1 instanceof Doc && doc2 instanceof Doc) && this.props.doc.type === DocumentType.LINK ? <LinkContextMenu doc1={doc1} doc2={doc2} /> : - <SelectorContextMenu {...this.props} />} + this.contextButton} </div> </div> - ); + </div>; } }
\ No newline at end of file diff --git a/src/new_fields/DateField.ts b/src/new_fields/DateField.ts index abec91e06..4f999e5e8 100644 --- a/src/new_fields/DateField.ts +++ b/src/new_fields/DateField.ts @@ -19,6 +19,10 @@ export class DateField extends ObjectField { return new DateField(this.date); } + toString() { + return `${this.date.toISOString()}`; + } + [ToScriptString]() { return `new DateField(new Date(${this.date.toISOString()}))`; } diff --git a/src/new_fields/Doc.ts b/src/new_fields/Doc.ts index cf32fa74a..e0ab5d97c 100644 --- a/src/new_fields/Doc.ts +++ b/src/new_fields/Doc.ts @@ -1,4 +1,4 @@ -import { observable, ObservableMap, runInAction, action } from "mobx"; +import { observable, ObservableMap, runInAction, action, untracked } from "mobx"; import { alias, map, serializable } from "serializr"; import { DocServer } from "../client/DocServer"; import { DocumentType } from "../client/documents/DocumentTypes"; @@ -10,7 +10,7 @@ import { ObjectField } from "./ObjectField"; import { PrefetchProxy, ProxyField } from "./Proxy"; import { FieldId, RefField } from "./RefField"; import { listSpec } from "./Schema"; -import { ComputedField } from "./ScriptField"; +import { ComputedField, ScriptField } from "./ScriptField"; import { BoolCast, Cast, FieldValue, NumCast, PromiseValue, StrCast, ToConstructor } from "./Types"; import { deleteProperty, getField, getter, makeEditable, makeReadOnly, setter, updateFunction } from "./util"; import { intersectRect } from "../Utils"; @@ -290,14 +290,13 @@ export namespace Doc { * @param fields the fields to project onto the target. Its type signature defines a mapping from some string key * to a potentially undefined field, where each entry in this mapping is optional. */ - export function assign<K extends string>(doc: Doc, fields: Partial<Record<K, Opt<Field>>>) { + export function assign<K extends string>(doc: Doc, fields: Partial<Record<K, Opt<Field>>>, skipUndefineds: boolean = false) { for (const key in fields) { if (fields.hasOwnProperty(key)) { const value = fields[key]; - // Do we want to filter out undefineds? - // if (value !== undefined) { - doc[key] = value; - // } + if (!skipUndefineds || value !== undefined) { // Do we want to filter out undefineds? + doc[key] = value; + } } } return doc; @@ -406,8 +405,9 @@ export namespace Doc { } export function MakeAlias(doc: Doc) { const alias = !GetT(doc, "isPrototype", "boolean", true) ? Doc.MakeCopy(doc) : Doc.MakeDelegate(doc); - if (alias.layout instanceof Doc) { - alias.layout = Doc.MakeAlias(alias.layout); + const layout = Doc.Layout(alias); + if (layout instanceof Doc && layout !== alias) { + Doc.SetLayout(alias, Doc.MakeAlias(layout)); } const aliasNumber = Doc.GetProto(doc).aliasNumber = NumCast(Doc.GetProto(doc).aliasNumber) + 1; alias.title = ComputedField.MakeFunction(`renameAlias(this, ${aliasNumber})`); @@ -455,6 +455,7 @@ export namespace Doc { if (resolvedDataDoc && Doc.WillExpandTemplateLayout(childDocLayout, resolvedDataDoc)) { const extensionDoc = fieldExtensionDoc(resolvedDataDoc, StrCast(childDocLayout.templateField, StrCast(childDocLayout.title))); layoutDoc = Doc.expandTemplateLayout(childDocLayout, extensionDoc !== resolvedDataDoc ? extensionDoc : undefined); + setTimeout(() => layoutDoc && (layoutDoc.resolvedDataDoc = resolvedDataDoc), 0); } else layoutDoc = childDocLayout; return { layout: layoutDoc, data: resolvedDataDoc }; } @@ -467,9 +468,14 @@ export namespace Doc { // to store annotations, ink, and other data. // export function fieldExtensionDoc(doc: Doc, fieldKey: string) { - const extension = doc[fieldKey + "_ext"] as Doc; - (extension === undefined) && setTimeout(() => CreateDocumentExtensionForField(doc, fieldKey), 0); - return extension ? extension : undefined; + const extension = doc[fieldKey + "_ext"]; + if (doc instanceof Doc && extension === undefined) { + setTimeout(() => CreateDocumentExtensionForField(doc, fieldKey), 0); + } + return extension ? extension as Doc : undefined; + } + export function fieldExtensionDocSync(doc: Doc, fieldKey: string) { + return (doc[fieldKey + "_ext"] as Doc) || CreateDocumentExtensionForField(doc, fieldKey); } export function CreateDocumentExtensionForField(doc: Doc, fieldKey: string) { @@ -567,13 +573,15 @@ export namespace Doc { return; } - const layoutCustomLayout = Doc.MakeDelegate(templateDoc); + if ((target[targetKey] as Doc)?.proto !== templateDoc) { + const layoutCustomLayout = Doc.MakeDelegate(templateDoc); - titleTarget && (Doc.GetProto(target).title = titleTarget); - Doc.GetProto(target).type = DocumentType.TEMPLATE; - target.onClick = templateDoc.onClick instanceof ObjectField && templateDoc.onClick[Copy](); + titleTarget && (Doc.GetProto(target).title = titleTarget); + Doc.GetProto(target).type = DocumentType.TEMPLATE; + target.onClick = templateDoc.onClick instanceof ObjectField && templateDoc.onClick[Copy](); - Doc.GetProto(target)[targetKey] = layoutCustomLayout; + Doc.GetProto(target)[targetKey] = layoutCustomLayout; + } target.layoutKey = targetKey; return target; } @@ -604,7 +612,7 @@ export namespace Doc { const data = fieldTemplate.data; // setTimeout(action(() => { !templateDataDoc[metadataFieldName] && data instanceof ObjectField && (Doc.GetProto(templateDataDoc)[metadataFieldName] = ObjectField.MakeCopy(data)); - const layout = StrCast(fieldLayoutDoc.layout).replace(/fieldKey={"[^"]*"}/, `fieldKey={"${metadataFieldName}"}`); + const layout = StrCast(fieldLayoutDoc.layout).replace(/fieldKey={'[^']*'}/, `fieldKey={'${metadataFieldName}'}`); const layoutDelegate = Doc.Layout(fieldTemplate); layoutDelegate.layout = layout; fieldTemplate.layout = layoutDelegate !== fieldTemplate ? layoutDelegate : layout; @@ -644,13 +652,17 @@ export namespace Doc { export class DocData { @observable _user_doc: Doc = undefined!; + @observable _searchQuery: string = ""; } // the document containing the view layout information - will be the Document itself unless the Document has // a layout field. In that case, all layout information comes from there unless overriden by Document export function Layout(doc: Doc) { return Doc.LayoutField(doc) instanceof Doc ? doc[StrCast(doc.layoutKey, "layout")] as Doc : doc; } + export function SetLayout(doc: Doc, layout: Doc | string) { doc[StrCast(doc.layoutKey, "layout")] = layout; } export function LayoutField(doc: Doc) { return doc[StrCast(doc.layoutKey, "layout")]; } const manager = new DocData(); + export function SearchQuery(): string { return manager._searchQuery; } + export function SetSearchQuery(query: string) { runInAction(() => manager._searchQuery = query); } export function UserDoc(): Doc { return manager._user_doc; } export function SetUserDoc(doc: Doc) { manager._user_doc = doc; } export function IsBrushed(doc: Doc) { @@ -679,7 +691,9 @@ export namespace Doc { } + export function LinkOtherAnchor(linkDoc: Doc, anchorDoc: Doc) { return Doc.AreProtosEqual(anchorDoc, Cast(linkDoc.anchor1, Doc) as Doc) ? Cast(linkDoc.anchor2, Doc) as Doc : Cast(linkDoc.anchor1, Doc) as Doc; } export function LinkEndpoint(linkDoc: Doc, anchorDoc: Doc) { return Doc.AreProtosEqual(anchorDoc, Cast(linkDoc.anchor1, Doc) as Doc) ? "layoutKey1" : "layoutKey2"; } + export function linkFollowUnhighlight() { Doc.UnhighlightAll(); document.removeEventListener("pointerdown", linkFollowUnhighlight); @@ -700,8 +714,7 @@ export namespace Doc { } const highlightManager = new HighlightBrush(); export function IsHighlighted(doc: Doc) { - const IsHighlighted = highlightManager.HighlightedDoc.get(doc) || highlightManager.HighlightedDoc.get(Doc.GetDataDoc(doc)); - return IsHighlighted; + return highlightManager.HighlightedDoc.get(doc) || highlightManager.HighlightedDoc.get(Doc.GetDataDoc(doc)); } export function HighlightDoc(doc: Doc) { runInAction(() => { @@ -733,8 +746,19 @@ export namespace Doc { source.dragFactory instanceof Doc && source.dragFactory.isTemplateDoc ? source.dragFactory : source && source.layout instanceof Doc && source.layout.isTemplateDoc ? source.layout : undefined; } -} + export function MakeDocFilter(docFilters: string[]) { + let docFilterText = ""; + for (let i = 0; i < docFilters.length; i += 3) { + const key = docFilters[i]; + const value = docFilters[i + 1]; + const modifiers = docFilters[i + 2]; + const scriptText = `${modifiers === "x" ? "!" : ""}matchFieldValue(doc, "${key}", "${value}")`; + docFilterText = docFilterText ? docFilterText + " || " + scriptText : scriptText; + } + return docFilterText ? "(" + docFilterText + ")" : ""; + } +} Scripting.addGlobal(function renameAlias(doc: any, n: any) { return StrCast(Doc.GetProto(doc).title).replace(/\([0-9]*\)/, "") + `(${n})`; }); Scripting.addGlobal(function getProto(doc: any) { return Doc.GetProto(doc); }); @@ -746,4 +770,37 @@ Scripting.addGlobal(function aliasDocs(field: any) { return new List<Doc>(field. Scripting.addGlobal(function docList(field: any) { return DocListCast(field); }); Scripting.addGlobal(function sameDocs(doc1: any, doc2: any) { return Doc.AreProtosEqual(doc1, doc2); }); Scripting.addGlobal(function undo() { return UndoManager.Undo(); }); -Scripting.addGlobal(function redo() { return UndoManager.Redo(); });
\ No newline at end of file +Scripting.addGlobal(function redo() { return UndoManager.Redo(); }); +Scripting.addGlobal(function selectDoc(doc: any) { Doc.UserDoc().SelectedDocs = new List([doc]); }); +Scripting.addGlobal(function selectedDocs(container: Doc, excludeCollections: boolean, prevValue: any) { + const docs = DocListCast(Doc.UserDoc().SelectedDocs).filter(d => !Doc.AreProtosEqual(d, container) && !d.annotationOn && d.type !== DocumentType.DOCUMENT && d.type !== DocumentType.KVP && (!excludeCollections || !Cast(d.data, listSpec(Doc), null))); + return docs.length ? new List(docs) : prevValue; +}); +Scripting.addGlobal(function matchFieldValue(doc: Doc, key: string, value: any) { + const fieldVal = doc[key] ? doc[key] : doc[key + "_ext"]; + if (StrCast(fieldVal, null) !== undefined) return StrCast(fieldVal) === value; + if (NumCast(fieldVal, null) !== undefined) return NumCast(fieldVal) === value; + if (Cast(fieldVal, listSpec("string"), []).length) { + const vals = Cast(fieldVal, listSpec("string"), []); + return vals.some(v => v === value); + } + return false; +}); +Scripting.addGlobal(function setDocFilter(container: Doc, key: string, value: any, modifiers: string) { + const docFilters = Cast(container.docFilter, listSpec("string"), []); + let found = false; + for (let i = 0; i < docFilters.length && !found; i += 3) { + if (docFilters[i] === key && docFilters[i + 1] === value) { + found = true; + docFilters.splice(i, 3); + } + } + if (!found || modifiers !== "none") { + docFilters.push(key); + docFilters.push(value); + docFilters.push(modifiers); + container.docFilter = new List<string>(docFilters); + } + const docFilterText = Doc.MakeDocFilter(docFilters); + container.viewSpecScript = docFilterText ? ScriptField.MakeFunction(docFilterText, { doc: Doc.name }) : undefined; +});
\ No newline at end of file diff --git a/src/new_fields/InkField.ts b/src/new_fields/InkField.ts index 2d8bb582a..e2aa7ee16 100644 --- a/src/new_fields/InkField.ts +++ b/src/new_fields/InkField.ts @@ -2,7 +2,6 @@ import { Deserializable } from "../client/util/SerializationHelper"; import { serializable, custom, createSimpleSchema, list, object, map } from "serializr"; import { ObjectField } from "./ObjectField"; import { Copy, ToScriptString } from "./FieldSymbols"; -import { DeepCopy } from "../Utils"; export enum InkTool { None, @@ -13,14 +12,14 @@ export enum InkTool { } export interface PointData { - x: number; - y: number; + X: number; + Y: number; } export type InkData = Array<PointData>; const pointSchema = createSimpleSchema({ - x: true, y: true + X: true, Y: true }); const strokeDataSchema = createSimpleSchema({ diff --git a/src/new_fields/List.ts b/src/new_fields/List.ts index e9101158b..bb48b1bb3 100644 --- a/src/new_fields/List.ts +++ b/src/new_fields/List.ts @@ -290,8 +290,7 @@ class ListImpl<T extends Field> extends ObjectField { private [SelfProxy]: any; [ToScriptString]() { - return "invalid"; - // return `new List([${(this as any).map((field => Field.toScriptString(field))}])`; + return `new List([${(this as any).map((field: any) => Field.toScriptString(field))}])`; } } export type List<T extends Field> = ListImpl<T> & (T | (T extends RefField ? Promise<T> : never))[]; diff --git a/src/new_fields/ScriptField.ts b/src/new_fields/ScriptField.ts index 6b2723663..b5ad4a7f6 100644 --- a/src/new_fields/ScriptField.ts +++ b/src/new_fields/ScriptField.ts @@ -103,7 +103,7 @@ export class ScriptField extends ObjectField { } public static CompileScript(script: string, params: object = {}, addReturn = false) { const compiled = CompileScript(script, { - params: { this: Doc.name, ...params }, + params: { this: Doc.name, _last_: "any", ...params }, typecheck: false, editable: true, addReturn: addReturn @@ -124,8 +124,9 @@ export class ScriptField extends ObjectField { @scriptingGlobal @Deserializable("computed", deserializeScript) export class ComputedField extends ScriptField { + _lastComputedResult: any; //TODO maybe add an observable cache based on what is passed in for doc, considering there shouldn't really be that many possible values for doc - value = computedFn((doc: Doc) => this.script.run({ this: doc }, console.log).result); + value = computedFn((doc: Doc) => this._lastComputedResult = this.script.run({ this: doc, _last_: this._lastComputedResult }, console.log).result); public static MakeScript(script: string, params: object = {}, ) { const compiled = ScriptField.CompileScript(script, params, false); return compiled.compiled ? new ComputedField(compiled) : undefined; diff --git a/src/new_fields/documentSchemas.ts b/src/new_fields/documentSchemas.ts index 4c2f061a6..909fdc6c3 100644 --- a/src/new_fields/documentSchemas.ts +++ b/src/new_fields/documentSchemas.ts @@ -27,6 +27,9 @@ export const documentSchema = createSchema({ isTemplateField: "boolean", // whether this document acts as a template layout for describing how other documents should be displayed isBackground: "boolean", // whether document is a background element and ignores input events (can only selet with marquee) type: "string", // enumerated type of document + 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 + preventTreeViewOpen: "boolean", // ignores the treeViewOpen flag (for allowing a view to not be slaved to other views of the document) currentTimecode: "number", // current play back time of a temporal document (video / audio) summarizedDocs: listSpec(Doc), // documents that are summarized by this document (and which will typically be opened by clicking this document) maximizedDocs: listSpec(Doc), // documents to maximize when clicking this document (generally this document will be an icon) @@ -38,14 +41,20 @@ export const documentSchema = createSchema({ searchFields: "string", // the search fields to display when this document matches a search in its metadata heading: "number", // the logical layout 'heading' of this document (used by rule provider to stylize h1 header elements, from h2, etc) showCaption: "string", // whether editable caption text is overlayed at the bottom of the document - showTitle: "string", // whether an editable title banner is displayed at tht top of the document + showTitle: "string", // the fieldkey whose contents should be displayed at the top of the document + showTitleHover: "string", // the showTitle should be shown only on hover isButton: "boolean", // whether document functions as a button (overiding native interactions of its content) ignoreClick: "boolean", // whether documents ignores input clicks (but does not ignore manipulation and other events) isAnimating: "string", // whether the document is in the midst of animating between two layouts (used by icons to de/iconify documents). value is undefined|"min"|"max" animateToDimensions: listSpec("number"), // layout information about the target rectangle a document is animating towards scrollToLinkID: "string", // id of link being traversed. allows this doc to scroll/highlight/etc its link anchor. scrollToLinkID should be set to undefined by this doc after it sets up its scroll,etc. strokeWidth: "number", - fontSize: "string" + fontSize: "string", + fitToBox: "boolean", // whether freeform view contents should be zoomed/panned to fill the area of the document view + xPadding: "number", // pixels of padding on left/right of collectionfreeformview contents when fitToBox is set + yPadding: "number", // pixels of padding on left/right of collectionfreeformview contents when fitToBox is set + LODarea: "number", // area (width*height) where CollectionFreeFormViews switch from a label to rendering contents + LODdisable: "boolean", // whether to disbale LOD switching for CollectionFreeFormViews }); export const positionSchema = createSchema({ diff --git a/src/pen-gestures/GestureUtils.ts b/src/pen-gestures/GestureUtils.ts new file mode 100644 index 000000000..59a85b66b --- /dev/null +++ b/src/pen-gestures/GestureUtils.ts @@ -0,0 +1,28 @@ +import { NDollarRecognizer } from "./ndollar"; +import { Type } from "typescript"; +import { InkField } from "../new_fields/InkField"; +import { Docs } from "../client/documents/Documents"; +import { Doc, WidthSym, HeightSym } from "../new_fields/Doc"; +import { NumCast } from "../new_fields/Types"; +import { CollectionFreeFormView } from "../client/views/collections/collectionFreeForm/CollectionFreeFormView"; + +export namespace GestureUtils { + namespace GestureDataTypes { + export type BoxData = Array<Doc>; + } + + export enum Gestures { + Box = "box", + Line = "line" + } + + export const GestureRecognizer = new NDollarRecognizer(false); + + export function GestureOptions(name: string, gestureData?: any): (params: {}) => any { + switch (name) { + case Gestures.Box: + break; + } + throw new Error("This means that you're trying to do something with the gesture that hasn't been defined yet. Define it in GestureUtils.ts"); + } +}
\ No newline at end of file diff --git a/src/pen-gestures/ndollar.ts b/src/pen-gestures/ndollar.ts new file mode 100644 index 000000000..ef5ca38c6 --- /dev/null +++ b/src/pen-gestures/ndollar.ts @@ -0,0 +1,533 @@ +import { GestureUtils } from "./GestureUtils"; + +/** + * The $N Multistroke Recognizer (JavaScript version) + * Converted to TypeScript -syip2 + * + * Lisa Anthony, Ph.D. + * UMBC + * Information Systems Department + * 1000 Hilltop Circle + * Baltimore, MD 21250 + * lanthony@umbc.edu + * + * Jacob O. Wobbrock, Ph.D. + * The Information School + * University of Washington + * Seattle, WA 98195-2840 + * wobbrock@uw.edu + * + * The academic publications for the $N recognizer, and what should be + * used to cite it, are: + * + * Anthony, L. and Wobbrock, J.O. (2010). A lightweight multistroke + * recognizer for user interface prototypes. Proceedings of Graphics + * Interface (GI '10). Ottawa, Ontario (May 31-June 2, 2010). Toronto, + * Ontario: Canadian Information Processing Society, pp. 245-252. + * https://dl.acm.org/citation.cfm?id=1839258 + * + * Anthony, L. and Wobbrock, J.O. (2012). $N-Protractor: A fast and + * accurate multistroke recognizer. Proceedings of Graphics Interface + * (GI '12). Toronto, Ontario (May 28-30, 2012). Toronto, Ontario: + * Canadian Information Processing Society, pp. 117-120. + * https://dl.acm.org/citation.cfm?id=2305296 + * + * The Protractor enhancement was separately published by Yang Li and programmed + * here by Jacob O. Wobbrock and Lisa Anthony: + * + * Li, Y. (2010). Protractor: A fast and accurate gesture + * recognizer. Proceedings of the ACM Conference on Human + * Factors in Computing Systems (CHI '10). Atlanta, Georgia + * (April 10-15, 2010). New York: ACM Press, pp. 2169-2172. + * https://dl.acm.org/citation.cfm?id=1753654 + * + * This software is distributed under the "New BSD License" agreement: + * + * Copyright (C) 2007-2011, Jacob O. Wobbrock and Lisa Anthony. + * All rights reserved. Last updated July 14, 2018. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * * Neither the names of UMBC nor the University of Washington, + * nor the names of its contributors may be used to endorse or promote + * products derived from this software without specific prior written + * permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS + * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL Lisa Anthony OR Jacob O. Wobbrock + * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE + * GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + * SUCH DAMAGE. +**/ + +// +// Point class +// +export class Point { + constructor(public X: number, public Y: number) { } +} + +// +// Rectangle class +// +export class Rectangle { + constructor(public X: number, public Y: number, public Width: number, public Height: number) { } +} + +// +// Unistroke class: a unistroke template +// +export class Unistroke { + public Points: Point[]; + public StartUnitVector: Point; + public Vector: number[]; + + constructor(public Name: string, useBoundedRotationInvariance: boolean, points: Point[]) { + this.Points = Resample(points, NumPoints); + const radians = IndicativeAngle(this.Points); + this.Points = RotateBy(this.Points, -radians); + this.Points = ScaleDimTo(this.Points, SquareSize, OneDThreshold); + if (useBoundedRotationInvariance) { + this.Points = RotateBy(this.Points, +radians); // restore + } + this.Points = TranslateTo(this.Points, Origin); + this.StartUnitVector = CalcStartUnitVector(this.Points, StartAngleIndex); + this.Vector = Vectorize(this.Points, useBoundedRotationInvariance); // for Protractor + } +} +// +// Multistroke class: a container for unistrokes +// +export class Multistroke { + public NumStrokes: number; + public Unistrokes: Unistroke[]; + + constructor(public Name: string, useBoundedRotationInvariance: boolean, strokes: any[]) // constructor + { + this.NumStrokes = strokes.length; // number of individual strokes + + const order = new Array(strokes.length); // array of integer indices + for (var i = 0; i < strokes.length; i++) { + order[i] = i; // initialize + } + const orders = new Array(); // array of integer arrays + HeapPermute(strokes.length, order, /*out*/ orders); + + const unistrokes = MakeUnistrokes(strokes, orders); // returns array of point arrays + this.Unistrokes = new Array(unistrokes.length); // unistrokes for this multistroke + for (var j = 0; j < unistrokes.length; j++) { + this.Unistrokes[j] = new Unistroke(this.Name, useBoundedRotationInvariance, unistrokes[j]); + } + } +} + +// +// Result class +// +export class Result { + constructor(public Name: string, public Score: any, public Time: any) { } +} + +// +// NDollarRecognizer constants +// +const NumMultistrokes = 2; +const NumPoints = 96; +const SquareSize = 250.0; +const OneDThreshold = 0.25; // customize to desired gesture set (usually 0.20 - 0.35) +const Origin = new Point(0, 0); +const Diagonal = Math.sqrt(SquareSize * SquareSize + SquareSize * SquareSize); +const HalfDiagonal = 0.5 * Diagonal; +const AngleRange = Deg2Rad(45.0); +const AnglePrecision = Deg2Rad(2.0); +const Phi = 0.5 * (-1.0 + Math.sqrt(5.0)); // Golden Ratio +const StartAngleIndex = (NumPoints / 8); // eighth of gesture length +const AngleSimilarityThreshold = Deg2Rad(30.0); + +// +// NDollarRecognizer class +// +export class NDollarRecognizer { + public Multistrokes: Multistroke[]; + + constructor(useBoundedRotationInvariance: boolean) // constructor + { + // + // one predefined multistroke for each multistroke type + // + this.Multistrokes = new Array(NumMultistrokes); + this.Multistrokes[0] = new Multistroke(GestureUtils.Gestures.Box, useBoundedRotationInvariance, new Array( + new Array(new Point(30, 146), new Point(30, 222), new Point(106, 225), new Point(106, 146), new Point(30, 146)) + )); + this.Multistrokes[1] = new Multistroke(GestureUtils.Gestures.Line, useBoundedRotationInvariance, new Array( + new Array(new Point(12, 347), new Point(119, 347)) + )); + + // + // PREDEFINED STROKES + // + + // this.Multistrokes[0] = new Multistroke("T", useBoundedRotationInvariance, new Array( + // new Array(new Point(30, 7), new Point(103, 7)), + // new Array(new Point(66, 7), new Point(66, 87)) + // )); + // this.Multistrokes[1] = new Multistroke("N", useBoundedRotationInvariance, new Array( + // new Array(new Point(177, 92), new Point(177, 2)), + // new Array(new Point(182, 1), new Point(246, 95)), + // new Array(new Point(247, 87), new Point(247, 1)) + // )); + // this.Multistrokes[2] = new Multistroke("D", useBoundedRotationInvariance, new Array( + // new Array(new Point(345, 9), new Point(345, 87)), + // new Array(new Point(351, 8), new Point(363, 8), new Point(372, 9), new Point(380, 11), new Point(386, 14), new Point(391, 17), new Point(394, 22), new Point(397, 28), new Point(399, 34), new Point(400, 42), new Point(400, 50), new Point(400, 56), new Point(399, 61), new Point(397, 66), new Point(394, 70), new Point(391, 74), new Point(386, 78), new Point(382, 81), new Point(377, 83), new Point(372, 85), new Point(367, 87), new Point(360, 87), new Point(355, 88), new Point(349, 87)) + // )); + // this.Multistrokes[3] = new Multistroke("P", useBoundedRotationInvariance, new Array( + // new Array(new Point(507, 8), new Point(507, 87)), + // new Array(new Point(513, 7), new Point(528, 7), new Point(537, 8), new Point(544, 10), new Point(550, 12), new Point(555, 15), new Point(558, 18), new Point(560, 22), new Point(561, 27), new Point(562, 33), new Point(561, 37), new Point(559, 42), new Point(556, 45), new Point(550, 48), new Point(544, 51), new Point(538, 53), new Point(532, 54), new Point(525, 55), new Point(519, 55), new Point(513, 55), new Point(510, 55)) + // )); + // this.Multistrokes[4] = new Multistroke("X", useBoundedRotationInvariance, new Array( + // new Array(new Point(30, 146), new Point(106, 222)), + // new Array(new Point(30, 225), new Point(106, 146)) + // )); + // this.Multistrokes[5] = new Multistroke("H", useBoundedRotationInvariance, new Array( + // new Array(new Point(188, 137), new Point(188, 225)), + // new Array(new Point(188, 180), new Point(241, 180)), + // new Array(new Point(241, 137), new Point(241, 225)) + // )); + // this.Multistrokes[6] = new Multistroke("I", useBoundedRotationInvariance, new Array( + // new Array(new Point(371, 149), new Point(371, 221)), + // new Array(new Point(341, 149), new Point(401, 149)), + // new Array(new Point(341, 221), new Point(401, 221)) + // )); + // this.Multistrokes[7] = new Multistroke("exclamation", useBoundedRotationInvariance, new Array( + // new Array(new Point(526, 142), new Point(526, 204)), + // new Array(new Point(526, 221)) + // )); + // this.Multistrokes[9] = new Multistroke("five-point star", useBoundedRotationInvariance, new Array( + // new Array(new Point(177, 396), new Point(223, 299), new Point(262, 396), new Point(168, 332), new Point(278, 332), new Point(184, 397)) + // )); + // this.Multistrokes[10] = new Multistroke("null", useBoundedRotationInvariance, new Array( + // new Array(new Point(382, 310), new Point(377, 308), new Point(373, 307), new Point(366, 307), new Point(360, 310), new Point(356, 313), new Point(353, 316), new Point(349, 321), new Point(347, 326), new Point(344, 331), new Point(342, 337), new Point(341, 343), new Point(341, 350), new Point(341, 358), new Point(342, 362), new Point(344, 366), new Point(347, 370), new Point(351, 374), new Point(356, 379), new Point(361, 382), new Point(368, 385), new Point(374, 387), new Point(381, 387), new Point(390, 387), new Point(397, 385), new Point(404, 382), new Point(408, 378), new Point(412, 373), new Point(416, 367), new Point(418, 361), new Point(419, 353), new Point(418, 346), new Point(417, 341), new Point(416, 336), new Point(413, 331), new Point(410, 326), new Point(404, 320), new Point(400, 317), new Point(393, 313), new Point(392, 312)), + // new Array(new Point(418, 309), new Point(337, 390)) + // )); + // this.Multistrokes[11] = new Multistroke("arrowhead", useBoundedRotationInvariance, new Array( + // new Array(new Point(506, 349), new Point(574, 349)), + // new Array(new Point(525, 306), new Point(584, 349), new Point(525, 388)) + // )); + // this.Multistrokes[12] = new Multistroke("pitchfork", useBoundedRotationInvariance, new Array( + // new Array(new Point(38, 470), new Point(36, 476), new Point(36, 482), new Point(37, 489), new Point(39, 496), new Point(42, 500), new Point(46, 503), new Point(50, 507), new Point(56, 509), new Point(63, 509), new Point(70, 508), new Point(75, 506), new Point(79, 503), new Point(82, 499), new Point(85, 493), new Point(87, 487), new Point(88, 480), new Point(88, 474), new Point(87, 468)), + // new Array(new Point(62, 464), new Point(62, 571)) + // )); + // this.Multistrokes[13] = new Multistroke("six-point star", useBoundedRotationInvariance, new Array( + // new Array(new Point(177, 554), new Point(223, 476), new Point(268, 554), new Point(183, 554)), + // new Array(new Point(177, 490), new Point(223, 568), new Point(268, 490), new Point(183, 490)) + // )); + // this.Multistrokes[14] = new Multistroke("asterisk", useBoundedRotationInvariance, new Array( + // new Array(new Point(325, 499), new Point(417, 557)), + // new Array(new Point(417, 499), new Point(325, 557)), + // new Array(new Point(371, 486), new Point(371, 571)) + // )); + // this.Multistrokes[15] = new Multistroke("half-note", useBoundedRotationInvariance, new Array( + // new Array(new Point(546, 465), new Point(546, 531)), + // new Array(new Point(540, 530), new Point(536, 529), new Point(533, 528), new Point(529, 529), new Point(524, 530), new Point(520, 532), new Point(515, 535), new Point(511, 539), new Point(508, 545), new Point(506, 548), new Point(506, 554), new Point(509, 558), new Point(512, 561), new Point(517, 564), new Point(521, 564), new Point(527, 563), new Point(531, 560), new Point(535, 557), new Point(538, 553), new Point(542, 548), new Point(544, 544), new Point(546, 540), new Point(546, 536)) + // )); + // + // The $N Gesture Recognizer API begins here -- 3 methods: Recognize(), AddGesture(), and DeleteUserGestures() + // + } + + Recognize = (strokes: any[], useBoundedRotationInvariance: boolean = false, requireSameNoOfStrokes: boolean = false, useProtractor: boolean = true) => { + const t0 = Date.now(); + const points = CombineStrokes(strokes); // make one connected unistroke from the given strokes + const candidate = new Unistroke("", useBoundedRotationInvariance, points); + + var u = -1; + var b = +Infinity; + for (var i = 0; i < this.Multistrokes.length; i++) // for each multistroke template + { + if (!requireSameNoOfStrokes || strokes.length === this.Multistrokes[i].NumStrokes) // optional -- only attempt match when same # of component strokes + { + for (const unistroke of this.Multistrokes[i].Unistrokes) // for each unistroke within this multistroke + { + if (AngleBetweenUnitVectors(candidate.StartUnitVector, unistroke.StartUnitVector) <= AngleSimilarityThreshold) // strokes start in the same direction + { + var d; + if (useProtractor) { + d = OptimalCosineDistance(unistroke.Vector, candidate.Vector); // Protractor + } + else { + d = DistanceAtBestAngle(candidate.Points, unistroke, -AngleRange, +AngleRange, AnglePrecision); // Golden Section Search (original $N) + } + if (d < b) { + b = d; // best (least) distance + u = i; // multistroke owner of unistroke + } + } + } + } + } + const t1 = Date.now(); + return (u === -1) ? null : new Result(this.Multistrokes[u].Name, useProtractor ? (1.0 - b) : (1.0 - b / HalfDiagonal), t1 - t0); + } + + AddGesture = (name: string, useBoundedRotationInvariance: boolean, strokes: any[]) => { + this.Multistrokes[this.Multistrokes.length] = new Multistroke(name, useBoundedRotationInvariance, strokes); + var num = 0; + for (const multistroke of this.Multistrokes) { + if (multistroke.Name === name) { + num++; + } + } + return num; + } + + DeleteUserGestures = () => { + this.Multistrokes.length = NumMultistrokes; // clear any beyond the original set + return NumMultistrokes; + } +} + + +// +// Private helper functions from here on down +// +function HeapPermute(n: number, order: any[], /*out*/ orders: any[]) { + if (n === 1) { + orders[orders.length] = order.slice(); // append copy + } else { + for (var i = 0; i < n; i++) { + HeapPermute(n - 1, order, orders); + if (n % 2 === 1) { // swap 0, n-1 + const tmp = order[0]; + order[0] = order[n - 1]; + order[n - 1] = tmp; + } else { // swap i, n-1 + const tmp = order[i]; + order[i] = order[n - 1]; + order[n - 1] = tmp; + } + } + } +} + +function MakeUnistrokes(strokes: any, orders: any) { + const unistrokes = new Array(); // array of point arrays + for (const order of orders) { + for (var b = 0; b < Math.pow(2, order.length); b++) // use b's bits for directions + { + const unistroke = new Array(); // array of points + for (var i = 0; i < order.length; i++) { + var pts; + if (((b >> i) & 1) === 1) {// is b's bit at index i on? + pts = strokes[order[i]].slice().reverse(); // copy and reverse + } + else { + pts = strokes[order[i]].slice(); // copy + } + for (const point of pts) { + unistroke[unistroke.length] = point; // append points + } + } + unistrokes[unistrokes.length] = unistroke; // add one unistroke to set + } + } + return unistrokes; +} + +function CombineStrokes(strokes: any) { + const points = new Array(); + for (const stroke of strokes) { + for (const { X, Y } of stroke) { + points[points.length] = new Point(X, Y); + } + } + return points; +} +function Resample(points: any, n: any) { + const I = PathLength(points) / (n - 1); // interval length + var D = 0.0; + const newpoints = new Array(points[0]); + for (var i = 1; i < points.length; i++) { + const d = Distance(points[i - 1], points[i]); + if ((D + d) >= I) { + const qx = points[i - 1].X + ((I - D) / d) * (points[i].X - points[i - 1].X); + const qy = points[i - 1].Y + ((I - D) / d) * (points[i].Y - points[i - 1].Y); + const q = new Point(qx, qy); + newpoints[newpoints.length] = q; // append new point 'q' + points.splice(i, 0, q); // insert 'q' at position i in points s.t. 'q' will be the next i + D = 0.0; + } + else D += d; + } + if (newpoints.length === n - 1) {// sometimes we fall a rounding-error short of adding the last point, so add it if so + newpoints[newpoints.length] = new Point(points[points.length - 1].X, points[points.length - 1].Y); + } + return newpoints; +} +function IndicativeAngle(points: any) { + const c = Centroid(points); + return Math.atan2(c.Y - points[0].Y, c.X - points[0].X); +} +function RotateBy(points: any, radians: any) // rotates points around centroid +{ + const c = Centroid(points); + const cos = Math.cos(radians); + const sin = Math.sin(radians); + const newpoints = new Array(); + for (const point of points) { + const qx = (point.X - c.X) * cos - (point.Y - c.Y) * sin + c.X; + const qy = (point.X - c.X) * sin + (point.Y - c.Y) * cos + c.Y; + newpoints[newpoints.length] = new Point(qx, qy); + } + return newpoints; +} +function ScaleDimTo(points: any, size: any, ratio1D: any) // scales bbox uniformly for 1D, non-uniformly for 2D +{ + const B = BoundingBox(points); + const uniformly = Math.min(B.Width / B.Height, B.Height / B.Width) <= ratio1D; // 1D or 2D gesture test + const newpoints = new Array(); + for (const { X, Y } of points) { + const qx = uniformly ? X * (size / Math.max(B.Width, B.Height)) : X * (size / B.Width); + const qy = uniformly ? Y * (size / Math.max(B.Width, B.Height)) : Y * (size / B.Height); + newpoints[newpoints.length] = new Point(qx, qy); + } + return newpoints; +} +function TranslateTo(points: any, pt: any) // translates points' centroid +{ + const c = Centroid(points); + const newpoints = new Array(); + for (const { X, Y } of points) { + const qx = X + pt.X - c.X; + const qy = Y + pt.Y - c.Y; + newpoints[newpoints.length] = new Point(qx, qy); + } + return newpoints; +} +function Vectorize(points: any, useBoundedRotationInvariance: any) // for Protractor +{ + var cos = 1.0; + var sin = 0.0; + if (useBoundedRotationInvariance) { + const iAngle = Math.atan2(points[0].Y, points[0].X); + const baseOrientation = (Math.PI / 4.0) * Math.floor((iAngle + Math.PI / 8.0) / (Math.PI / 4.0)); + cos = Math.cos(baseOrientation - iAngle); + sin = Math.sin(baseOrientation - iAngle); + } + var sum = 0.0; + const vector = new Array<number>(); + for (var i = 0; i < points.length; i++) { + const newX = points[i].X * cos - points[i].Y * sin; + const newY = points[i].Y * cos + points[i].X * sin; + vector[vector.length] = newX; + vector[vector.length] = newY; + sum += newX * newX + newY * newY; + } + const magnitude = Math.sqrt(sum); + for (var i = 0; i < vector.length; i++) { + vector[i] /= magnitude; + } + return vector; +} +function OptimalCosineDistance(v1: any, v2: any) // for Protractor +{ + var a = 0.0; + var b = 0.0; + for (var i = 0; i < v1.length; i += 2) { + a += v1[i] * v2[i] + v1[i + 1] * v2[i + 1]; + b += v1[i] * v2[i + 1] - v1[i + 1] * v2[i]; + } + const angle = Math.atan(b / a); + return Math.acos(a * Math.cos(angle) + b * Math.sin(angle)); +} +function DistanceAtBestAngle(points: any, T: any, a: any, b: any, threshold: any) { + var x1 = Phi * a + (1.0 - Phi) * b; + var f1 = DistanceAtAngle(points, T, x1); + var x2 = (1.0 - Phi) * a + Phi * b; + var f2 = DistanceAtAngle(points, T, x2); + while (Math.abs(b - a) > threshold) { + if (f1 < f2) { + b = x2; + x2 = x1; + f2 = f1; + x1 = Phi * a + (1.0 - Phi) * b; + f1 = DistanceAtAngle(points, T, x1); + } else { + a = x1; + x1 = x2; + f1 = f2; + x2 = (1.0 - Phi) * a + Phi * b; + f2 = DistanceAtAngle(points, T, x2); + } + } + return Math.min(f1, f2); +} +function DistanceAtAngle(points: any, T: any, radians: any) { + const newpoints = RotateBy(points, radians); + return PathDistance(newpoints, T.Points); +} +function Centroid(points: any) { + var x = 0.0, y = 0.0; + for (const point of points) { + x += point.X; + y += point.Y; + } + x /= points.length; + y /= points.length; + return new Point(x, y); +} +function BoundingBox(points: any) { + var minX = +Infinity, maxX = -Infinity, minY = +Infinity, maxY = -Infinity; + for (const { X, Y } of points) { + minX = Math.min(minX, X); + minY = Math.min(minY, Y); + maxX = Math.max(maxX, X); + maxY = Math.max(maxY, Y); + } + return new Rectangle(minX, minY, maxX - minX, maxY - minY); +} +function PathDistance(pts1: any, pts2: any) // average distance between corresponding points in two paths +{ + var d = 0.0; + for (var i = 0; i < pts1.length; i++) {// assumes pts1.length == pts2.length + d += Distance(pts1[i], pts2[i]); + } + return d / pts1.length; +} +function PathLength(points: any) // length traversed by a point path +{ + var d = 0.0; + for (var i = 1; i < points.length; i++) { + d += Distance(points[i - 1], points[i]); + } + return d; +} +function Distance(p1: any, p2: any) // distance between two points +{ + const dx = p2.X - p1.X; + const dy = p2.Y - p1.Y; + return Math.sqrt(dx * dx + dy * dy); +} +function CalcStartUnitVector(points: any, index: any) // start angle from points[0] to points[index] normalized as a unit vector +{ + const v = new Point(points[index].X - points[0].X, points[index].Y - points[0].Y); + const len = Math.sqrt(v.X * v.X + v.Y * v.Y); + return new Point(v.X / len, v.Y / len); +} +function AngleBetweenUnitVectors(v1: any, v2: any) // gives acute angle between unit vectors from (0,0) to v1, and (0,0) to v2 +{ + const n = (v1.X * v2.X + v1.Y * v2.Y); + const c = Math.max(-1.0, Math.min(1.0, n)); // ensure [-1,+1] + return Math.acos(c); // arc cosine of the vector dot product +} +function Deg2Rad(d: any) { return (d * Math.PI / 180.0); }
\ No newline at end of file diff --git a/src/server/ActionUtilities.ts b/src/server/ActionUtilities.ts index 4fe7374d1..60f66c878 100644 --- a/src/server/ActionUtilities.ts +++ b/src/server/ActionUtilities.ts @@ -1,46 +1,81 @@ -import * as fs from 'fs'; +import { readFile, writeFile, exists, mkdir, unlink, createWriteStream } from 'fs'; import { ExecOptions } from 'shelljs'; import { exec } from 'child_process'; import * as path from 'path'; import * as rimraf from "rimraf"; -import { yellow } from 'colors'; +import { yellow, Color } from 'colors'; +import * as nodemailer from "nodemailer"; +import { MailOptions } from "nodemailer/lib/json-transport"; +import Mail = require('nodemailer/lib/mailer'); + +const projectRoot = path.resolve(__dirname, "../../"); +export function pathFromRoot(relative?: string) { + if (!relative) { + return projectRoot; + } + return path.resolve(projectRoot, relative); +} + +export async function fileDescriptorFromStream(path: string) { + const logStream = createWriteStream(path); + return new Promise<number>(resolve => logStream.on("open", resolve)); +} export const command_line = (command: string, fromDirectory?: string) => { return new Promise<string>((resolve, reject) => { const options: ExecOptions = {}; if (fromDirectory) { - options.cwd = path.join(__dirname, fromDirectory); + options.cwd = fromDirectory ? path.resolve(projectRoot, fromDirectory) : projectRoot; } exec(command, options, (err, stdout) => err ? reject(err) : resolve(stdout)); }); }; export const read_text_file = (relativePath: string) => { - const target = path.join(__dirname, relativePath); + const target = path.resolve(__dirname, relativePath); return new Promise<string>((resolve, reject) => { - fs.readFile(target, (err, data) => err ? reject(err) : resolve(data.toString())); + readFile(target, (err, data) => err ? reject(err) : resolve(data.toString())); }); }; export const write_text_file = (relativePath: string, contents: any) => { - const target = path.join(__dirname, relativePath); + const target = path.resolve(__dirname, relativePath); return new Promise<void>((resolve, reject) => { - fs.writeFile(target, contents, (err) => err ? reject(err) : resolve()); + writeFile(target, contents, (err) => err ? reject(err) : resolve()); }); }; -export interface LogData { +export type Messager<T> = (outcome: { result: T | undefined, error: Error | null }) => string; + +export interface LogData<T> { startMessage: string; - endMessage: string; - action: () => void | Promise<void>; + // if you care about the execution informing your log, you can pass in a function that takes in the result and a potential error and decides what to write + endMessage: string | Messager<T>; + action: () => T | Promise<T>; + color?: Color; } let current = Math.ceil(Math.random() * 20); -export async function log_execution({ startMessage, endMessage, action }: LogData) { - const color = `\x1b[${31 + current++ % 6}m%s\x1b[0m`; - console.log(color, `${startMessage}...`); - await action(); - console.log(color, endMessage); +export async function log_execution<T>({ startMessage, endMessage, action, color }: LogData<T>): Promise<T | undefined> { + let result: T | undefined = undefined, error: Error | null = null; + const resolvedColor = color || `\x1b[${31 + ++current % 6}m%s\x1b[0m`; + log_helper(`${startMessage}...`, resolvedColor); + try { + result = await action(); + } catch (e) { + error = e; + } finally { + log_helper(typeof endMessage === "string" ? endMessage : endMessage({ result, error }), resolvedColor); + } + return result; +} + +function log_helper(content: string, color: Color | string) { + if (typeof color === "string") { + console.log(color, content); + } else { + console.log(color(content)); + } } export function logPort(listener: string, port: number) { @@ -61,10 +96,10 @@ export function msToTime(duration: number) { } export const createIfNotExists = async (path: string) => { - if (await new Promise<boolean>(resolve => fs.exists(path, resolve))) { + if (await new Promise<boolean>(resolve => exists(path, resolve))) { return true; } - return new Promise<boolean>(resolve => fs.mkdir(path, error => resolve(error === null))); + return new Promise<boolean>(resolve => mkdir(path, error => resolve(error === null))); }; export async function Prune(rootDirectory: string): Promise<boolean> { @@ -72,8 +107,54 @@ export async function Prune(rootDirectory: string): Promise<boolean> { return error === null; } -export const Destroy = (mediaPath: string) => new Promise<boolean>(resolve => fs.unlink(mediaPath, error => resolve(error === null))); +export const Destroy = (mediaPath: string) => new Promise<boolean>(resolve => unlink(mediaPath, error => resolve(error === null))); -export function addBeforeExitHandler(handler: NodeJS.BeforeExitListener) { - process.on("beforeExit", handler); -} +export namespace Email { + + const smtpTransport = nodemailer.createTransport({ + service: 'Gmail', + auth: { + user: 'brownptcdash@gmail.com', + pass: 'browngfx1' + } + }); + + export interface DispatchOptions<T extends string | string[]> { + to: T; + subject: string; + content: string; + attachments?: Mail.Attachment | Mail.Attachment[]; + } + + export interface DispatchFailure { + recipient: string; + error: Error; + } + + export async function dispatchAll({ to, subject, content, attachments }: DispatchOptions<string[]>) { + const failures: DispatchFailure[] = []; + await Promise.all(to.map(async recipient => { + let error: Error | null; + const resolved = attachments ? "length" in attachments ? attachments : [attachments] : undefined; + if ((error = await Email.dispatch({ to: recipient, subject, content, attachments: resolved })) !== null) { + failures.push({ + recipient, + error + }); + } + })); + return failures.length ? failures : undefined; + } + + export async function dispatch({ to, subject, content, attachments }: DispatchOptions<string>): Promise<Error | null> { + const mailOptions = { + to, + from: 'brownptcdash@gmail.com', + subject, + text: `Hello ${to.split("@")[0]},\n\n${content}`, + attachments + } as MailOptions; + return new Promise<Error | null>(resolve => smtpTransport.sendMail(mailOptions, resolve)); + } + +}
\ No newline at end of file diff --git a/src/server/ApiManagers/DeleteManager.ts b/src/server/ApiManagers/DeleteManager.ts index 1fdc7cc36..be452c0ff 100644 --- a/src/server/ApiManagers/DeleteManager.ts +++ b/src/server/ApiManagers/DeleteManager.ts @@ -1,5 +1,5 @@ import ApiManager, { Registration } from "./ApiManager"; -import { Method, _permission_denied, OnUnauthenticated } from "../RouteManager"; +import { Method, _permission_denied, PublicHandler } from "../RouteManager"; import { WebSocket } from "../Websocket/Websocket"; import { Database } from "../database"; @@ -10,7 +10,7 @@ export default class DeleteManager extends ApiManager { register({ method: Method.GET, subscription: "/delete", - onValidation: async ({ res, isRelease }) => { + secureHandler: async ({ res, isRelease }) => { if (isRelease) { return _permission_denied(res, deletionPermissionError); } @@ -22,7 +22,7 @@ export default class DeleteManager extends ApiManager { register({ method: Method.GET, subscription: "/deleteAll", - onValidation: async ({ res, isRelease }) => { + secureHandler: async ({ res, isRelease }) => { if (isRelease) { return _permission_denied(res, deletionPermissionError); } @@ -31,7 +31,7 @@ export default class DeleteManager extends ApiManager { } }); - const hi: OnUnauthenticated = async ({ res, isRelease }) => { + const hi: PublicHandler = async ({ res, isRelease }) => { if (isRelease) { return _permission_denied(res, deletionPermissionError); } @@ -50,7 +50,7 @@ export default class DeleteManager extends ApiManager { register({ method: Method.GET, subscription: "/deleteWithAux", - onValidation: async ({ res, isRelease }) => { + secureHandler: async ({ res, isRelease }) => { if (isRelease) { return _permission_denied(res, deletionPermissionError); } @@ -62,7 +62,7 @@ export default class DeleteManager extends ApiManager { register({ method: Method.GET, subscription: "/deleteWithGoogleCredentials", - onValidation: async ({ res, isRelease }) => { + secureHandler: async ({ res, isRelease }) => { if (isRelease) { return _permission_denied(res, deletionPermissionError); } diff --git a/src/server/ApiManagers/DiagnosticManager.ts b/src/server/ApiManagers/DiagnosticManager.ts deleted file mode 100644 index 104985481..000000000 --- a/src/server/ApiManagers/DiagnosticManager.ts +++ /dev/null @@ -1,30 +0,0 @@ -import ApiManager, { Registration } from "./ApiManager"; -import { Method } from "../RouteManager"; -import request = require('request-promise'); - -export default class DiagnosticManager extends ApiManager { - - protected initialize(register: Registration): void { - - register({ - method: Method.GET, - subscription: "/serverHeartbeat", - onValidation: ({ res }) => res.send(true) - }); - - register({ - method: Method.GET, - subscription: "/solrHeartbeat", - onValidation: async ({ res }) => { - try { - await request("http://localhost:8983"); - res.send({ running: true }); - } catch (e) { - res.send({ running: false }); - } - } - }); - - } - -}
\ No newline at end of file diff --git a/src/server/ApiManagers/DownloadManager.ts b/src/server/ApiManagers/DownloadManager.ts index 2280739fc..1bb84f374 100644 --- a/src/server/ApiManagers/DownloadManager.ts +++ b/src/server/ApiManagers/DownloadManager.ts @@ -7,6 +7,7 @@ import { Database } from "../database"; import * as path from "path"; import { DashUploadUtils, SizeSuffix } from "../DashUploadUtils"; import { publicDirectory } from ".."; +import { serverPathToFile, Directory } from "./UploadManager"; export type Hierarchy = { [id: string]: string | Hierarchy }; export type ZipMutator = (file: Archiver.Archiver) => void | Promise<void>; @@ -32,7 +33,7 @@ export default class DownloadManager extends ApiManager { register({ method: Method.GET, subscription: new RouteSubscriber("imageHierarchyExport").add('docId'), - onValidation: async ({ req, res }) => { + secureHandler: async ({ req, res }) => { const id = req.params.docId; const hierarchy: Hierarchy = {}; await buildHierarchyRecursive(id, hierarchy); @@ -43,7 +44,7 @@ export default class DownloadManager extends ApiManager { register({ method: Method.GET, subscription: new RouteSubscriber("downloadId").add("docId"), - onValidation: async ({ req, res }) => { + secureHandler: async ({ req, res }) => { return BuildAndDispatchZip(res, async zip => { const { id, docs, files } = await getDocs(req.params.docId); const docString = JSON.stringify({ id, docs }); @@ -58,7 +59,7 @@ export default class DownloadManager extends ApiManager { register({ method: Method.GET, subscription: new RouteSubscriber("serializeDoc").add("docId"), - onValidation: async ({ req, res }) => { + secureHandler: async ({ req, res }) => { const { docs, files } = await getDocs(req.params.docId); res.send({ docs, files: Array.from(files) }); } @@ -245,9 +246,9 @@ async function writeHierarchyRecursive(file: Archiver.Archiver, hierarchy: Hiera if (typeof result === "string") { let path: string; let matches: RegExpExecArray | null; - if ((matches = /\:1050\/files\/(upload\_[\da-z]{32}.*)/g.exec(result)) !== null) { + if ((matches = /\:1050\/files\/images\/(upload\_[\da-z]{32}.*)/g.exec(result)) !== null) { // image already exists on our server - path = `${__dirname}/public/files/${matches[1]}`; + path = serverPathToFile(Directory.images, matches[1]); } else { // the image doesn't already exist on our server (may have been dragged // and dropped in the browser and thus hosted remotely) so we upload it diff --git a/src/server/ApiManagers/GeneralGoogleManager.ts b/src/server/ApiManagers/GeneralGoogleManager.ts index 3617779d5..a5240edbc 100644 --- a/src/server/ApiManagers/GeneralGoogleManager.ts +++ b/src/server/ApiManagers/GeneralGoogleManager.ts @@ -19,7 +19,7 @@ export default class GeneralGoogleManager extends ApiManager { register({ method: Method.GET, subscription: "/readGoogleAccessToken", - onValidation: async ({ user, res }) => { + secureHandler: async ({ user, res }) => { const token = await GoogleApiServerUtils.retrieveAccessToken(user.id); if (!token) { return res.send(GoogleApiServerUtils.generateAuthenticationUrl()); @@ -31,7 +31,7 @@ export default class GeneralGoogleManager extends ApiManager { register({ method: Method.POST, subscription: "/writeGoogleAccessToken", - onValidation: async ({ user, req, res }) => { + secureHandler: async ({ user, req, res }) => { res.send(await GoogleApiServerUtils.processNewUser(user.id, req.body.authenticationCode)); } }); @@ -39,7 +39,7 @@ export default class GeneralGoogleManager extends ApiManager { register({ method: Method.POST, subscription: new RouteSubscriber("googleDocs").add("sector", "action"), - onValidation: async ({ req, res, user }) => { + secureHandler: async ({ req, res, user }) => { const sector: GoogleApiServerUtils.Service = req.params.sector as GoogleApiServerUtils.Service; const action: GoogleApiServerUtils.Action = req.params.action as GoogleApiServerUtils.Action; const endpoint = await GoogleApiServerUtils.GetEndpoint(GoogleApiServerUtils.Service[sector], user.id); diff --git a/src/server/ApiManagers/GooglePhotosManager.ts b/src/server/ApiManagers/GooglePhotosManager.ts index e2539f120..107542ce2 100644 --- a/src/server/ApiManagers/GooglePhotosManager.ts +++ b/src/server/ApiManagers/GooglePhotosManager.ts @@ -41,7 +41,7 @@ export default class GooglePhotosManager extends ApiManager { register({ method: Method.POST, subscription: "/googlePhotosMediaUpload", - onValidation: async ({ user, req, res }) => { + secureHandler: async ({ user, req, res }) => { const { media } = req.body; const token = await GoogleApiServerUtils.retrieveAccessToken(user.id); if (!token) { @@ -82,7 +82,7 @@ export default class GooglePhotosManager extends ApiManager { register({ method: Method.POST, subscription: "/googlePhotosMediaDownload", - onValidation: async ({ req, res }) => { + secureHandler: async ({ req, res }) => { const contents: { mediaItems: MediaItem[] } = req.body; let failed = 0; if (contents) { diff --git a/src/server/ApiManagers/PDFManager.ts b/src/server/ApiManagers/PDFManager.ts index a190ab0cb..0136b758e 100644 --- a/src/server/ApiManagers/PDFManager.ts +++ b/src/server/ApiManagers/PDFManager.ts @@ -1,7 +1,7 @@ import ApiManager, { Registration } from "./ApiManager"; import { Method } from "../RouteManager"; import RouteSubscriber from "../RouteSubscriber"; -import { exists, createReadStream, createWriteStream } from "fs"; +import { existsSync, createReadStream, createWriteStream } from "fs"; import * as Pdfjs from 'pdfjs-dist'; import { createCanvas } from "canvas"; const imageSize = require("probe-image-size"); @@ -17,42 +17,37 @@ export default class PDFManager extends ApiManager { register({ method: Method.GET, subscription: new RouteSubscriber("thumbnail").add("filename"), - onValidation: ({ req, res }) => getOrCreateThumbnail(req.params.filename, res) + secureHandler: ({ req, res }) => getOrCreateThumbnail(req.params.filename, res) }); } } -function getOrCreateThumbnail(thumbnailName: string, res: express.Response) { +async function getOrCreateThumbnail(thumbnailName: string, res: express.Response): Promise<void> { const noExtension = thumbnailName.substring(0, thumbnailName.length - ".png".length); const pageString = noExtension.split('-')[1]; const pageNumber = parseInt(pageString); - return new Promise<void>(resolve => { + return new Promise<void>(async resolve => { const path = serverPathToFile(Directory.pdf_thumbnails, thumbnailName); - exists(path, (exists: boolean) => { - if (exists) { - const existingThumbnail = createReadStream(path); - imageSize(existingThumbnail, (err: any, { width, height }: any) => { - if (err) { - console.log(red(`In PDF thumbnail response, unable to determine dimensions of ${thumbnailName}:`)); - console.log(err); - return; - } - res.send({ - path: clientPathToFile(Directory.pdf_thumbnails, thumbnailName), - width, - height - }); - }); - } else { - const offset = thumbnailName.length - pageString.length - 5; - const name = thumbnailName.substring(0, offset) + ".pdf"; - const path = serverPathToFile(Directory.pdfs, name); - CreateThumbnail(path, pageNumber, res); + if (existsSync(path)) { + const existingThumbnail = createReadStream(path); + const { err, viewport } = await new Promise<any>(resolve => { + imageSize(existingThumbnail, (err: any, viewport: any) => resolve({ err, viewport })); + }); + if (err) { + console.log(red(`In PDF thumbnail response, unable to determine dimensions of ${thumbnailName}:`)); + console.log(err); + return; } - resolve(); - }); + dispatchThumbnail(res, viewport, thumbnailName); + } else { + const offset = thumbnailName.length - pageString.length - 5; + const name = thumbnailName.substring(0, offset) + ".pdf"; + const path = serverPathToFile(Directory.pdfs, name); + await CreateThumbnail(path, pageNumber, res); + } + resolve(); }); } @@ -70,19 +65,28 @@ async function CreateThumbnail(file: string, pageNumber: number, res: express.Re await page.render(renderContext).promise; const pngStream = canvas.createPNGStream(); const filenames = path.basename(file).split("."); - const pngFile = serverPathToFile(Directory.pdf_thumbnails, `${filenames[0]}-${pageNumber}.png`); + const thumbnailName = `${filenames[0]}-${pageNumber}.png`; + const pngFile = serverPathToFile(Directory.pdf_thumbnails, thumbnailName); const out = createWriteStream(pngFile); pngStream.pipe(out); - out.on("finish", () => { - res.send({ - path: pngFile, - width: viewport.width, - height: viewport.height + return new Promise<void>((resolve, reject) => { + out.on("finish", () => { + dispatchThumbnail(res, viewport, thumbnailName); + resolve(); + }); + out.on("error", error => { + console.log(red(`In PDF thumbnail creation, encountered the following error when piping ${pngFile}:`)); + console.log(error); + reject(); }); }); - out.on("error", error => { - console.log(red(`In PDF thumbnail creation, encountered the following error when piping ${pngFile}:`)); - console.log(error); +} + +function dispatchThumbnail(res: express.Response, { width, height }: Pdfjs.PDFPageViewport, thumbnailName: string) { + res.send({ + path: clientPathToFile(Directory.pdf_thumbnails, thumbnailName), + width, + height }); } diff --git a/src/server/ApiManagers/SearchManager.ts b/src/server/ApiManagers/SearchManager.ts index 7afecbb18..4ce12f9f3 100644 --- a/src/server/ApiManagers/SearchManager.ts +++ b/src/server/ApiManagers/SearchManager.ts @@ -4,15 +4,34 @@ import { Search } from "../Search"; const findInFiles = require('find-in-files'); import * as path from 'path'; import { pathToDirectory, Directory } from "./UploadManager"; +import { red, cyan, yellow } from "colors"; +import RouteSubscriber from "../RouteSubscriber"; +import { exec } from "child_process"; +import { onWindows } from ".."; +import { get } from "request-promise"; -export default class SearchManager extends ApiManager { +export class SearchManager extends ApiManager { protected initialize(register: Registration): void { register({ method: Method.GET, + subscription: new RouteSubscriber("solr").add("action"), + secureHandler: async ({ req, res }) => { + const { action } = req.params; + if (["start", "stop"].includes(action)) { + const status = req.params.action === "start"; + const success = await SolrManager.SetRunning(status); + console.log(success ? `Successfully ${status ? "started" : "stopped"} Solr!` : `Uh oh! Check the console for the error that occurred while ${status ? "starting" : "stopping"} Solr`); + } + res.redirect("/home"); + } + }); + + register({ + method: Method.GET, subscription: "/textsearch", - onValidation: async ({ req, res }) => { + secureHandler: async ({ req, res }) => { const q = req.query.q; if (q === undefined) { res.send([]); @@ -32,18 +51,43 @@ export default class SearchManager extends ApiManager { register({ method: Method.GET, subscription: "/search", - onValidation: async ({ req, res }) => { + secureHandler: async ({ req, res }) => { const solrQuery: any = {}; ["q", "fq", "start", "rows", "hl", "hl.fl"].forEach(key => solrQuery[key] = req.query[key]); if (solrQuery.q === undefined) { res.send([]); return; } - const results = await Search.Instance.search(solrQuery); + const results = await Search.search(solrQuery); res.send(results); } }); } +} + +export namespace SolrManager { + + const command = onWindows ? "solr.cmd" : "solr"; + + export async function SetRunning(status: boolean): Promise<boolean> { + const args = status ? "start" : "stop -p 8983"; + console.log(`solr management: trying to ${args}`); + exec(`${command} ${args}`, { cwd: "./solr-8.3.1/bin" }, (error, stdout, stderr) => { + if (error) { + console.log(red(`solr management error: unable to ${args} server`)); + console.log(red(error.message)); + } + console.log(cyan(stdout)); + console.log(yellow(stderr)); + }); + try { + await get("http://localhost:8983"); + return true; + } catch { + return false; + } + } + }
\ No newline at end of file diff --git a/src/server/ApiManagers/SessionManager.ts b/src/server/ApiManagers/SessionManager.ts new file mode 100644 index 000000000..a99aa05e0 --- /dev/null +++ b/src/server/ApiManagers/SessionManager.ts @@ -0,0 +1,59 @@ +import ApiManager, { Registration } from "./ApiManager"; +import { Method, _permission_denied, AuthorizedCore, SecureHandler } from "../RouteManager"; +import RouteSubscriber from "../RouteSubscriber"; +import { sessionAgent } from ".."; +import { DashSessionAgent } from "../DashSession/DashSessionAgent"; + +const permissionError = "You are not authorized!"; + +export default class SessionManager extends ApiManager { + + private secureSubscriber = (root: string, ...params: string[]) => new RouteSubscriber(root).add("sessionKey", ...params); + + private authorizedAction = (handler: SecureHandler) => { + return (core: AuthorizedCore) => { + const { req, res, isRelease } = core; + const { sessionKey } = req.params; + if (!isRelease) { + return res.send("This can be run only on the release server."); + } + if (sessionKey !== process.env.session_key) { + return _permission_denied(res, permissionError); + } + return handler(core); + }; + } + + protected initialize(register: Registration): void { + + register({ + method: Method.GET, + subscription: this.secureSubscriber("debug", "to?"), + secureHandler: this.authorizedAction(async ({ req: { params }, res }) => { + const to = params.to || DashSessionAgent.notificationRecipient; + const { error } = await sessionAgent.serverWorker.emit("debug", { to }); + res.send(error ? error.message : `Your request was successful: the server captured and compressed (but did not save) a new back up. It was sent to ${to}.`); + }) + }); + + register({ + method: Method.GET, + subscription: this.secureSubscriber("backup"), + secureHandler: this.authorizedAction(async ({ res }) => { + const { error } = await sessionAgent.serverWorker.emit("backup"); + res.send(error ? error.message : "Your request was successful: the server successfully created a new back up."); + }) + }); + + register({ + method: Method.GET, + subscription: this.secureSubscriber("kill"), + secureHandler: this.authorizedAction(({ res }) => { + res.send("Your request was successful: the server and its session have been killed."); + sessionAgent.killSession("an authorized user has manually ended the server session via the /kill route"); + }) + }); + + } + +}
\ No newline at end of file diff --git a/src/server/ApiManagers/UploadManager.ts b/src/server/ApiManagers/UploadManager.ts index da1f83b75..74f45ae62 100644 --- a/src/server/ApiManagers/UploadManager.ts +++ b/src/server/ApiManagers/UploadManager.ts @@ -41,7 +41,7 @@ export default class UploadManager extends ApiManager { register({ method: Method.POST, subscription: "/upload", - onValidation: async ({ req, res }) => { + secureHandler: async ({ req, res }) => { const form = new formidable.IncomingForm(); form.uploadDir = pathToDirectory(Directory.parsed_files); form.keepExtensions = true; @@ -62,7 +62,7 @@ export default class UploadManager extends ApiManager { register({ method: Method.POST, subscription: "/uploadDoc", - onValidation: ({ req, res }) => { + secureHandler: ({ req, res }) => { const form = new formidable.IncomingForm(); form.keepExtensions = true; // let path = req.body.path; @@ -166,7 +166,7 @@ export default class UploadManager extends ApiManager { register({ method: Method.POST, subscription: "/inspectImage", - onValidation: async ({ req, res }) => { + secureHandler: async ({ req, res }) => { const { source } = req.body; if (typeof source === "string") { const { serverAccessPaths } = await DashUploadUtils.UploadImage(source); @@ -179,7 +179,7 @@ export default class UploadManager extends ApiManager { register({ method: Method.POST, subscription: "/uploadURI", - onValidation: ({ req, res }) => { + secureHandler: ({ req, res }) => { const uri = req.body.uri; const filename = req.body.name; if (!uri || !filename) { diff --git a/src/server/ApiManagers/UserManager.ts b/src/server/ApiManagers/UserManager.ts index 4556e01ea..36d48e366 100644 --- a/src/server/ApiManagers/UserManager.ts +++ b/src/server/ApiManagers/UserManager.ts @@ -18,7 +18,7 @@ export default class UserManager extends ApiManager { register({ method: Method.GET, subscription: "/getUsers", - onValidation: async ({ res }) => { + secureHandler: async ({ res }) => { const cursor = await Database.Instance.query({}, { email: 1, userDocumentId: 1 }, "users"); const results = await cursor.toArray(); res.send(results.map(user => ({ email: user.email, userDocumentId: user.userDocumentId }))); @@ -28,14 +28,14 @@ export default class UserManager extends ApiManager { register({ method: Method.GET, subscription: "/getUserDocumentId", - onValidation: ({ res, user }) => res.send(user.userDocumentId) + secureHandler: ({ res, user }) => res.send(user.userDocumentId) }); register({ method: Method.GET, subscription: "/getCurrentUser", - onValidation: ({ res, user }) => res.send(JSON.stringify(user)), - onUnauthenticated: ({ res }) => res.send(JSON.stringify({ id: "__guest__", email: "" })) + secureHandler: ({ res, user }) => res.send(JSON.stringify(user)), + publicHandler: ({ res }) => res.send(JSON.stringify({ id: "__guest__", email: "" })) }); register({ @@ -94,7 +94,7 @@ export default class UserManager extends ApiManager { register({ method: Method.GET, subscription: "/activity", - onValidation: ({ res }) => { + secureHandler: ({ res }) => { const now = Date.now(); const activeTimes: ActivityUnit[] = []; diff --git a/src/server/ApiManagers/UtilManager.ts b/src/server/ApiManagers/UtilManager.ts index 601a7d0d0..a0d0d0f4b 100644 --- a/src/server/ApiManagers/UtilManager.ts +++ b/src/server/ApiManagers/UtilManager.ts @@ -3,6 +3,7 @@ import { Method } from "../RouteManager"; import { exec } from 'child_process'; import { command_line } from "../ActionUtilities"; import RouteSubscriber from "../RouteSubscriber"; +import { red } from "colors"; export default class UtilManager extends ApiManager { @@ -11,13 +12,20 @@ export default class UtilManager extends ApiManager { register({ method: Method.GET, subscription: new RouteSubscriber("environment").add("key"), - onValidation: ({ req, res }) => res.send(process.env[req.params.key]) + secureHandler: ({ req, res }) => { + const { key } = req.params; + const value = process.env[key]; + if (!value) { + console.log(red(`process.env.${key} is not defined.`)); + } + return res.send(value); + } }); register({ method: Method.GET, subscription: "/pull", - onValidation: async ({ res }) => { + secureHandler: async ({ res }) => { return new Promise<void>(resolve => { exec('"C:\\Program Files\\Git\\git-bash.exe" -c "git pull"', err => { if (err) { @@ -34,8 +42,8 @@ export default class UtilManager extends ApiManager { register({ method: Method.GET, subscription: "/buxton", - onValidation: async ({ res }) => { - const cwd = '../scraping/buxton'; + secureHandler: async ({ res }) => { + const cwd = './src/scraping/buxton'; const onResolved = (stdout: string) => { console.log(stdout); res.redirect("/"); }; const onRejected = (err: any) => { console.error(err.message); res.send(err); }; @@ -48,7 +56,7 @@ export default class UtilManager extends ApiManager { register({ method: Method.GET, subscription: "/version", - onValidation: ({ res }) => { + secureHandler: ({ res }) => { return new Promise<void>(resolve => { exec('"C:\\Program Files\\Git\\bin\\git.exe" rev-parse HEAD', (err, stdout) => { if (err) { diff --git a/src/server/DashSession/DashSessionAgent.ts b/src/server/DashSession/DashSessionAgent.ts new file mode 100644 index 000000000..c55e01243 --- /dev/null +++ b/src/server/DashSession/DashSessionAgent.ts @@ -0,0 +1,223 @@ +import { Email, pathFromRoot } from "../ActionUtilities"; +import { red, yellow, green, cyan } from "colors"; +import { get } from "request-promise"; +import { Utils } from "../../Utils"; +import { WebSocket } from "../Websocket/Websocket"; +import { MessageStore } from "../Message"; +import { launchServer, onWindows } from ".."; +import { readdirSync, statSync, createWriteStream, readFileSync, unlinkSync } from "fs"; +import * as Archiver from "archiver"; +import { resolve } from "path"; +import { AppliedSessionAgent, MessageHandler, ExitHandler, Monitor, ServerWorker } from "resilient-server-session"; +import rimraf = require("rimraf"); + +/** + * If we're the monitor (master) thread, we should launch the monitor logic for the session. + * Otherwise, we must be on a worker thread that was spawned *by* the monitor (master) thread, and thus + * our job should be to run the server. + */ +export class DashSessionAgent extends AppliedSessionAgent { + + private readonly signature = "-Dash Server Session Manager"; + private readonly releaseDesktop = pathFromRoot("../../Desktop"); + + /** + * The core method invoked when the single master thread is initialized. + * Installs event hooks, repl commands and additional IPC listeners. + */ + protected async initializeMonitor(monitor: Monitor, sessionKey: string): Promise<void> { + await this.dispatchSessionPassword(sessionKey); + monitor.addReplCommand("pull", [], () => monitor.exec("git pull")); + monitor.addReplCommand("solr", [/start|stop|index/], this.executeSolrCommand); + monitor.addReplCommand("backup", [], this.backup); + monitor.addReplCommand("debug", [/\S+\@\S+/], async ([to]) => this.dispatchZippedDebugBackup(to)); + monitor.on("backup", this.backup); + monitor.on("debug", async ({ to }) => this.dispatchZippedDebugBackup(to)); + monitor.coreHooks.onCrashDetected(this.dispatchCrashReport); + } + + /** + * The core method invoked when a server worker thread is initialized. + * Installs logic to be executed when the server worker dies. + */ + protected async initializeServerWorker(): Promise<ServerWorker> { + const worker = ServerWorker.Create(launchServer); // server initialization delegated to worker + worker.addExitHandler(this.notifyClient); + return worker; + } + + /** + * Prepares the body of the email with instructions on restoring the transmitted remote database backup locally. + */ + private _remoteDebugInstructions: string | undefined; + private generateDebugInstructions = (zipName: string, target: string): string => { + if (!this._remoteDebugInstructions) { + this._remoteDebugInstructions = readFileSync(resolve(__dirname, "./templates/remote_debug_instructions.txt"), { encoding: "utf8" }); + } + return this._remoteDebugInstructions + .replace(/__zipname__/, zipName) + .replace(/__target__/, target) + .replace(/__signature__/, this.signature); + } + + /** + * Prepares the body of the email with information regarding a crash event. + */ + private _crashInstructions: string | undefined; + private generateCrashInstructions({ name, message, stack }: Error): string { + if (!this._crashInstructions) { + this._crashInstructions = readFileSync(resolve(__dirname, "./templates/crash_instructions.txt"), { encoding: "utf8" }); + } + return this._crashInstructions + .replace(/__name__/, name || "[no error name found]") + .replace(/__message__/, message || "[no error message found]") + .replace(/__stack__/, stack || "[no error stack found]") + .replace(/__signature__/, this.signature); + } + + /** + * This sends a pseudorandomly generated guid to the configuration's recipients, allowing them alone + * to kill the server via the /kill/:key route. + */ + private dispatchSessionPassword = async (sessionKey: string): Promise<void> => { + const { mainLog } = this.sessionMonitor; + const { notificationRecipient } = DashSessionAgent; + mainLog(green("dispatching session key...")); + const error = await Email.dispatch({ + to: notificationRecipient, + subject: "Dash Release Session Admin Authentication Key", + content: [ + `Here's the key for this session (started @ ${new Date().toUTCString()}):`, + sessionKey, + this.signature + ].join("\n\n") + }); + if (error) { + this.sessionMonitor.mainLog(red(`dispatch failure @ ${notificationRecipient} (${yellow(error.message)})`)); + mainLog(red("distribution of session key experienced errors")); + } else { + mainLog(green("successfully distributed session key to recipients")); + } + } + + /** + * This sends an email with the generated crash report. + */ + private dispatchCrashReport: MessageHandler<{ error: Error }> = async ({ error: crashCause }) => { + const { mainLog } = this.sessionMonitor; + const { notificationRecipient } = DashSessionAgent; + const error = await Email.dispatch({ + to: notificationRecipient, + subject: "Dash Web Server Crash", + content: this.generateCrashInstructions(crashCause) + }); + if (error) { + this.sessionMonitor.mainLog(red(`dispatch failure @ ${notificationRecipient} ${yellow(`(${error.message})`)}`)); + mainLog(red("distribution of crash notification experienced errors")); + } else { + mainLog(green("successfully distributed crash notification to recipients")); + } + } + + /** + * Logic for interfacing with Solr. Either starts it, + * stops it, or rebuilds its indicies. + */ + private executeSolrCommand = async (args: string[]): Promise<void> => { + const { exec, mainLog } = this.sessionMonitor; + const action = args[0]; + if (action === "index") { + exec("npx ts-node ./updateSearch.ts", { cwd: pathFromRoot("./src/server") }); + } else { + const command = `${onWindows ? "solr.cmd" : "solr"} ${args[0] === "start" ? "start" : "stop -p 8983"}`; + await exec(command, { cwd: "./solr-8.3.1/bin" }); + try { + await get("http://localhost:8983"); + mainLog(green("successfully connected to 8983 after running solr initialization")); + } catch { + mainLog(red("unable to connect at 8983 after running solr initialization")); + } + } + } + + /** + * Broadcast to all clients that their connection + * is no longer valid, and explain why / what to expect. + */ + private notifyClient: ExitHandler = reason => { + const { _socket } = WebSocket; + if (_socket) { + const message = typeof reason === "boolean" ? (reason ? "exit" : "temporary") : "crash"; + Utils.Emit(_socket, MessageStore.ConnectionTerminated, message); + } + } + + /** + * Performs a backup of the database, saved to the desktop subdirectory. + * This should work as is only on our specific release server. + */ + private backup = async (): Promise<void> => this.sessionMonitor.exec("backup.bat", { cwd: this.releaseDesktop }); + + /** + * Compress either a brand new backup or the most recent backup and send it + * as an attachment to an email, dispatched to the requested recipient. + * @param mode specifies whether or not to make a new backup before exporting + * @param to the recipient of the email + */ + private async dispatchZippedDebugBackup(to: string): Promise<void> { + const { mainLog } = this.sessionMonitor; + try { + // if desired, complete an immediate backup to send + await this.backup(); + mainLog("backup complete"); + + const backupsDirectory = `${this.releaseDesktop}/backups`; + + // sort all backups by their modified time, and choose the most recent one + const target = readdirSync(backupsDirectory).map(filename => ({ + modifiedTime: statSync(`${backupsDirectory}/${filename}`).mtimeMs, + filename + })).sort((a, b) => b.modifiedTime - a.modifiedTime)[0].filename; + mainLog(`targeting ${target}...`); + + // create a zip file and to it, write the contents of the backup directory + const zipName = `${target}.zip`; + const zipPath = `${this.releaseDesktop}/${zipName}`; + const targetPath = `${backupsDirectory}/${target}`; + const output = createWriteStream(zipPath); + const zip = Archiver('zip'); + zip.pipe(output); + zip.directory(`${targetPath}/Dash`, false); + await zip.finalize(); + mainLog(`zip finalized with size ${statSync(zipPath).size} bytes, saved to ${zipPath}`); + + // dispatch the email to the recipient, containing the finalized zip file + const error = await Email.dispatch({ + to, + subject: `Remote debug: compressed backup of ${target}...`, + content: this.generateDebugInstructions(zipName, target), + attachments: [{ filename: zipName, path: zipPath }] + }); + + // since this is intended to be a zero-footprint operation, clean up + // by unlinking both the backup generated earlier in the function and the compressed zip file. + // to generate a persistent backup, just run backup. + unlinkSync(zipPath); + rimraf.sync(targetPath); + + // indicate success or failure + mainLog(`${error === null ? green("successfully dispatched") : red("failed to dispatch")} ${zipName} to ${cyan(to)}`); + error && mainLog(red(error.message)); + } catch (error) { + mainLog(red("unable to dispatch zipped backup...")); + mainLog(red(error.message)); + } + } + +} + +export namespace DashSessionAgent { + + export const notificationRecipient = "brownptcdash@gmail.com"; + +}
\ No newline at end of file diff --git a/src/server/DashSession/templates/crash_instructions.txt b/src/server/DashSession/templates/crash_instructions.txt new file mode 100644 index 000000000..65417919d --- /dev/null +++ b/src/server/DashSession/templates/crash_instructions.txt @@ -0,0 +1,14 @@ +You, as a Dash Administrator, are being notified of a server crash event. Here's what we know: + +name: +__name__ + +message: +__message__ + +stack: +__stack__ + +The server is already restarting itself, but if you're concerned, use the Remote Desktop Connection to monitor progress. + +__signature__
\ No newline at end of file diff --git a/src/server/DashSession/templates/remote_debug_instructions.txt b/src/server/DashSession/templates/remote_debug_instructions.txt new file mode 100644 index 000000000..c279c460a --- /dev/null +++ b/src/server/DashSession/templates/remote_debug_instructions.txt @@ -0,0 +1,16 @@ +Instructions: + +Download this attachment, open your downloads folder and find this file (__zipname__). +Right click on the zip file and select 'Extract to __target__\'. +Open up the command line, and remember that you can get the path to any file or directory by literally dragging it from the file system and dropping it onto the terminal. +Unless it's in your path, you'll want to navigate to the MongoDB bin directory, given for Windows: + +cd '/c/Program Files/MongoDB/Server/[your version, i.e. 4.0, goes here]/bin' + +Then run the following command (if you're in the bin folder, make that ./mongorestore ...): + +mongorestore --gzip [/path/to/directory/you/just/unzipped] --db Dash + +Assuming everything runs well, this will mirror your local database with that of the server. Now, just start the server locally and debug. + +__signature__
\ No newline at end of file diff --git a/src/server/GarbageCollector.ts b/src/server/GarbageCollector.ts index 09b52eadf..5729c3ee5 100644 --- a/src/server/GarbageCollector.ts +++ b/src/server/GarbageCollector.ts @@ -100,7 +100,7 @@ async function GarbageCollect(full: boolean = true) { if (!full) { await Database.Instance.updateMany({ _id: { $nin: notToDelete } }, { $set: { "deleted": true } }); await Database.Instance.updateMany({ _id: { $in: notToDelete } }, { $unset: { "deleted": true } }); - console.log(await Search.Instance.updateDocuments( + console.log(await Search.updateDocuments( notToDelete.map<any>(id => ({ id, deleted: { set: null } })) @@ -122,7 +122,7 @@ async function GarbageCollect(full: boolean = true) { // const result = await Database.Instance.delete({ _id: { $in: toDelete } }, "newDocuments"); console.log(`${deleted} documents deleted`); - await Search.Instance.deleteDocuments(toDelete); + await Search.deleteDocuments(toDelete); console.log("Cleared search documents"); const folder = "./src/server/public/files/"; diff --git a/src/server/IDatabase.ts b/src/server/IDatabase.ts new file mode 100644 index 000000000..6a63df485 --- /dev/null +++ b/src/server/IDatabase.ts @@ -0,0 +1,24 @@ +import * as mongodb from 'mongodb'; +import { Transferable } from './Message'; + +export const DocumentsCollection = 'documents'; +export const NewDocumentsCollection = 'newDocuments'; +export interface IDatabase { + update(id: string, value: any, callback: (err: mongodb.MongoError, res: mongodb.UpdateWriteOpResult) => void, upsert?: boolean, collectionName?: string): Promise<void>; + updateMany(query: any, update: any, collectionName?: string): Promise<mongodb.WriteOpResult>; + + replace(id: string, value: any, callback: (err: mongodb.MongoError, res: mongodb.UpdateWriteOpResult) => void, upsert?: boolean, collectionName?: string): void; + + delete(query: any, collectionName?: string): Promise<mongodb.DeleteWriteOpResultObject>; + delete(id: string, collectionName?: string): Promise<mongodb.DeleteWriteOpResultObject>; + + deleteAll(collectionName?: string, persist?: boolean): Promise<any>; + + insert(value: any, collectionName?: string): Promise<void>; + + getDocument(id: string, fn: (result?: Transferable) => void, collectionName?: string): void; + getDocuments(ids: string[], fn: (result: Transferable[]) => void, collectionName?: string): void; + visit(ids: string[], fn: (result: any) => string[] | Promise<string[]>, collectionName?: string): Promise<void>; + + query(query: { [key: string]: any }, projection?: { [key: string]: 0 | 1 }, collectionName?: string): Promise<mongodb.Cursor>; +} diff --git a/src/server/MemoryDatabase.ts b/src/server/MemoryDatabase.ts new file mode 100644 index 000000000..543f96e7f --- /dev/null +++ b/src/server/MemoryDatabase.ts @@ -0,0 +1,100 @@ +import { IDatabase, DocumentsCollection, NewDocumentsCollection } from './IDatabase'; +import { Transferable } from './Message'; +import * as mongodb from 'mongodb'; + +export class MemoryDatabase implements IDatabase { + + private db: { [collectionName: string]: { [id: string]: any } } = {}; + + private getCollection(collectionName: string) { + const collection = this.db[collectionName]; + if (collection) { + return collection; + } else { + return this.db[collectionName] = {}; + } + } + + public update(id: string, value: any, callback: (err: mongodb.MongoError, res: mongodb.UpdateWriteOpResult) => void, _upsert?: boolean, collectionName = DocumentsCollection): Promise<void> { + const collection = this.getCollection(collectionName); + const set = "$set"; + if (set in value) { + let currentVal = collection[id] ?? (collection[id] = {}); + const val = value[set]; + for (const key in val) { + const keys = key.split("."); + for (let i = 0; i < keys.length - 1; i++) { + const k = keys[i]; + if (typeof currentVal[k] === "object") { + currentVal = currentVal[k]; + } else { + currentVal[k] = {}; + currentVal = currentVal[k]; + } + } + currentVal[keys[keys.length - 1]] = val[key]; + } + } else { + collection[id] = value; + } + callback(null as any, {} as any); + return Promise.resolve(undefined); + } + + public updateMany(query: any, update: any, collectionName = NewDocumentsCollection): Promise<mongodb.WriteOpResult> { + throw new Error("Can't updateMany a MemoryDatabase"); + } + + public replace(id: string, value: any, callback: (err: mongodb.MongoError, res: mongodb.UpdateWriteOpResult) => void, upsert?: boolean, collectionName = DocumentsCollection): void { + this.update(id, value, callback, upsert, collectionName); + } + + public delete(query: any, collectionName?: string): Promise<mongodb.DeleteWriteOpResultObject>; + public delete(id: string, collectionName?: string): Promise<mongodb.DeleteWriteOpResultObject>; + public delete(id: any, collectionName = DocumentsCollection): Promise<mongodb.DeleteWriteOpResultObject> { + const i = id.id ?? id; + delete this.getCollection(collectionName)[i]; + + return Promise.resolve({} as any); + } + + public deleteAll(collectionName = DocumentsCollection, _persist = true): Promise<any> { + delete this.db[collectionName]; + return Promise.resolve(); + } + + public insert(value: any, collectionName = DocumentsCollection): Promise<void> { + const id = value.id; + this.getCollection(collectionName)[id] = value; + return Promise.resolve(); + } + + public getDocument(id: string, fn: (result?: Transferable) => void, collectionName = NewDocumentsCollection): void { + fn(this.getCollection(collectionName)[id]); + } + public getDocuments(ids: string[], fn: (result: Transferable[]) => void, collectionName = DocumentsCollection): void { + fn(ids.map(id => this.getCollection(collectionName)[id])); + } + + public async visit(ids: string[], fn: (result: any) => string[] | Promise<string[]>, collectionName = NewDocumentsCollection): Promise<void> { + const visited = new Set<string>(); + while (ids.length) { + const count = Math.min(ids.length, 1000); + const index = ids.length - count; + const fetchIds = ids.splice(index, count).filter(id => !visited.has(id)); + if (!fetchIds.length) { + continue; + } + const docs = await new Promise<{ [key: string]: any }[]>(res => this.getDocuments(fetchIds, res, collectionName)); + for (const doc of docs) { + const id = doc.id; + visited.add(id); + ids.push(...(await fn(doc))); + } + } + } + + public query(): Promise<mongodb.Cursor> { + throw new Error("Can't query a MemoryDatabase"); + } +} diff --git a/src/server/Message.ts b/src/server/Message.ts index aaee143e8..621abfd1e 100644 --- a/src/server/Message.ts +++ b/src/server/Message.ts @@ -50,6 +50,7 @@ export namespace MessageStore { export const GetFields = new Message<string[]>("Get Fields"); // send string[] of 'id' get Transferable[] back export const GetDocument = new Message<string>("Get Document"); export const DeleteAll = new Message<any>("Delete All"); + export const ConnectionTerminated = new Message<string>("Connection Terminated"); export const GetRefField = new Message<string>("Get Ref Field"); export const GetRefFields = new Message<string[]>("Get Ref Fields"); diff --git a/src/server/ProcessFactory.ts b/src/server/ProcessFactory.ts new file mode 100644 index 000000000..acb8b3a99 --- /dev/null +++ b/src/server/ProcessFactory.ts @@ -0,0 +1,44 @@ +import { existsSync, mkdirSync } from "fs"; +import { pathFromRoot, fileDescriptorFromStream } from './ActionUtilities'; +import rimraf = require("rimraf"); +import { ChildProcess, spawn, StdioOptions } from "child_process"; +import { Stream } from "stream"; + +export namespace ProcessFactory { + + export type Sink = "pipe" | "ipc" | "ignore" | "inherit" | Stream | number | null | undefined; + + export async function createWorker(command: string, args?: readonly string[], stdio?: StdioOptions | "logfile", detached = true): Promise<ChildProcess> { + if (stdio === "logfile") { + const log_fd = await Logger.create(command, args); + stdio = ["ignore", log_fd, log_fd]; + } + const child = spawn(command, args, { detached, stdio }); + child.unref(); + return child; + } + +} + +export namespace Logger { + + const logPath = pathFromRoot("./logs"); + + export async function initialize() { + if (existsSync(logPath)) { + if (!process.env.SPAWNED) { + await new Promise<any>(resolve => rimraf(logPath, resolve)); + } + } + mkdirSync(logPath); + } + + export async function create(command: string, args?: readonly string[]): Promise<number> { + return fileDescriptorFromStream(generate_log_path(command, args)); + } + + function generate_log_path(command: string, args?: readonly string[]) { + return pathFromRoot(`./logs/${command}-${args?.length}-${new Date().toUTCString()}.log`); + } + +}
\ No newline at end of file diff --git a/src/server/RouteManager.ts b/src/server/RouteManager.ts index 41204964e..d072b7709 100644 --- a/src/server/RouteManager.ts +++ b/src/server/RouteManager.ts @@ -1,6 +1,6 @@ import RouteSubscriber from "./RouteSubscriber"; import { DashUserModel } from "./authentication/models/user_model"; -import * as express from 'express'; +import { Request, Response, Express } from 'express'; import { cyan, red, green } from 'colors'; export enum Method { @@ -9,21 +9,22 @@ export enum Method { } export interface CoreArguments { - req: express.Request; - res: express.Response; + req: Request; + res: Response; isRelease: boolean; } -export type OnValidation = (core: CoreArguments & { user: DashUserModel }) => any | Promise<any>; -export type OnUnauthenticated = (core: CoreArguments) => any | Promise<any>; -export type OnError = (core: CoreArguments & { error: any }) => any | Promise<any>; +export type AuthorizedCore = CoreArguments & { user: DashUserModel }; +export type SecureHandler = (core: AuthorizedCore) => any | Promise<any>; +export type PublicHandler = (core: CoreArguments) => any | Promise<any>; +export type ErrorHandler = (core: CoreArguments & { error: any }) => any | Promise<any>; export interface RouteInitializer { method: Method; subscription: string | RouteSubscriber | (string | RouteSubscriber)[]; - onValidation: OnValidation; - onUnauthenticated?: OnUnauthenticated; - onError?: OnError; + secureHandler: SecureHandler; + publicHandler?: PublicHandler; + errorHandler?: ErrorHandler; } const registered = new Map<string, Set<Method>>(); @@ -34,7 +35,7 @@ enum RegistrationError { } export default class RouteManager { - private server: express.Express; + private server: Express; private _isRelease: boolean; private failedRegistrations: { route: string, reason: RegistrationError }[] = []; @@ -42,7 +43,7 @@ export default class RouteManager { return this._isRelease; } - constructor(server: express.Express, isRelease: boolean) { + constructor(server: Express, isRelease: boolean) { this.server = server; this._isRelease = isRelease; } @@ -67,10 +68,9 @@ export default class RouteManager { console.log('please remove all duplicate routes before continuing'); } if (malformedCount) { - console.log(`please ensure all routes adhere to ^\/[A-Za-z]+(\/\:[A-Za-z]+)*$`); + console.log(`please ensure all routes adhere to ^\/$|^\/[A-Za-z]+(\/\:[A-Za-z?]+)*$`); } - console.log(); - process.exit(0); + process.exit(1); } else { console.log(green("all server routes have been successfully registered:")); Array.from(registered.keys()).sort().forEach(route => console.log(cyan(route))); @@ -83,29 +83,34 @@ export default class RouteManager { * @param initializer */ addSupervisedRoute = (initializer: RouteInitializer): void => { - const { method, subscription, onValidation, onUnauthenticated, onError } = initializer; + const { method, subscription, secureHandler, publicHandler, errorHandler } = initializer; + const isRelease = this._isRelease; - const supervised = async (req: express.Request, res: express.Response) => { - const { user, originalUrl: target } = req; + const supervised = async (req: Request, res: Response) => { + let { user } = req; + const { originalUrl: target } = req; + if (process.env.DB === "MEM" && !user) { + user = { id: "guest", email: "", userDocumentId: "guestDocId" }; + } const core = { req, res, isRelease }; const tryExecute = async (toExecute: (args: any) => any | Promise<any>, args: any) => { try { await toExecute(args); } catch (e) { console.log(red(target), user && ("email" in user) ? "<user logged out>" : undefined); - if (onError) { - onError({ ...core, error: e }); + if (errorHandler) { + errorHandler({ ...core, error: e }); } else { _error(res, `The server encountered an internal error when serving ${target}.`, e); } } }; if (user) { - await tryExecute(onValidation, { ...core, user }); + await tryExecute(secureHandler, { ...core, user }); } else { req.session!.target = target; - if (onUnauthenticated) { - await tryExecute(onUnauthenticated, core); + if (publicHandler) { + await tryExecute(publicHandler, core); if (!res.headersSent) { res.redirect("/login"); } @@ -128,7 +133,7 @@ export default class RouteManager { } else { route = subscriber.build; } - if (!/^\/[A-Za-z]+(\/\:[A-Za-z]+)*$/g.test(route)) { + if (!/^\/$|^\/[A-Za-z]+(\/\:[A-Za-z?]+)*$/g.test(route)) { this.failedRegistrations.push({ reason: RegistrationError.Malformed, route @@ -174,22 +179,22 @@ export const STATUS = { PERMISSION_DENIED: 403 }; -export function _error(res: express.Response, message: string, error?: any) { +export function _error(res: Response, message: string, error?: any) { console.error(message); res.statusMessage = message; res.status(STATUS.EXECUTION_ERROR).send(error); } -export function _success(res: express.Response, body: any) { +export function _success(res: Response, body: any) { res.status(STATUS.OK).send(body); } -export function _invalid(res: express.Response, message: string) { +export function _invalid(res: Response, message: string) { res.statusMessage = message; res.status(STATUS.BAD_REQUEST).send(); } -export function _permission_denied(res: express.Response, message?: string) { +export function _permission_denied(res: Response, message?: string) { if (message) { res.statusMessage = message; } diff --git a/src/server/Search.ts b/src/server/Search.ts index 723dc101b..21064e520 100644 --- a/src/server/Search.ts +++ b/src/server/Search.ts @@ -1,14 +1,13 @@ import * as rp from 'request-promise'; -import { Database } from './database'; -import { thisExpression } from 'babel-types'; +import { red } from 'colors'; -export class Search { - public static Instance = new Search(); - private url = 'http://localhost:8983/solr/'; +const pathTo = (relative: string) => `http://localhost:8983/solr/dash/${relative}`; - public async updateDocument(document: any) { +export namespace Search { + + export async function updateDocument(document: any) { try { - const res = await rp.post(this.url + "dash/update", { + const res = await rp.post(pathTo("update"), { headers: { 'content-type': 'application/json' }, body: JSON.stringify([document]) }); @@ -18,9 +17,9 @@ export class Search { } } - public async updateDocuments(documents: any[]) { + export async function updateDocuments(documents: any[]) { try { - const res = await rp.post(this.url + "dash/update", { + const res = await rp.post(pathTo("update"), { headers: { 'content-type': 'application/json' }, body: JSON.stringify(documents) }); @@ -30,9 +29,9 @@ export class Search { } } - public async search(query: any) { + export async function search(query: any) { try { - const searchResults = JSON.parse(await rp.get(this.url + "dash/select", { + const searchResults = JSON.parse(await rp.get(pathTo("select"), { qs: query })); const { docs, numFound } = searchResults.response; @@ -43,9 +42,9 @@ export class Search { } } - public async clear() { + export async function clear() { try { - return await rp.post(this.url + "dash/update", { + await rp.post(pathTo("update"), { body: { delete: { query: "*:*" @@ -53,10 +52,13 @@ export class Search { }, json: true }); - } catch { } + } catch (e) { + console.log(red("Unable to clear search...")); + console.log(red(e.message)); + } } - public deleteDocuments(docs: string[]) { + export async function deleteDocuments(docs: string[]) { const promises: rp.RequestPromise[] = []; const nToDelete = 1000; let index = 0; @@ -64,7 +66,7 @@ export class Search { const count = Math.min(docs.length - index, nToDelete); const deleteIds = docs.slice(index, index + count); index += count; - promises.push(rp.post(this.url + "dash/update", { + promises.push(rp.post(pathTo("update"), { body: { delete: { query: deleteIds.map(id => `id:"${id}"`).join(" ") diff --git a/src/server/Websocket/Websocket.ts b/src/server/Websocket/Websocket.ts index 60c34aa44..6dda6956e 100644 --- a/src/server/Websocket/Websocket.ts +++ b/src/server/Websocket/Websocket.ts @@ -7,26 +7,30 @@ import { Search } from "../Search"; import * as io from 'socket.io'; import YoutubeApi from "../apis/youtube/youtubeApiSample"; import { GoogleCredentialsLoader } from "../credentials/CredentialsLoader"; -import { logPort, addBeforeExitHandler } from "../ActionUtilities"; +import { logPort } from "../ActionUtilities"; import { timeMap } from "../ApiManagers/UserManager"; import { green } from "colors"; export namespace WebSocket { + export let _socket: Socket; const clients: { [key: string]: Client } = {}; export const socketMap = new Map<SocketIO.Socket, string>(); + export let disconnect: Function; - export async function start(serverPort: number, isRelease: boolean) { + export async function start(isRelease: boolean) { await preliminaryFunctions(); - initialize(serverPort, isRelease); + initialize(isRelease); } async function preliminaryFunctions() { } - export function initialize(socketPort: number, isRelease: boolean) { + function initialize(isRelease: boolean) { const endpoint = io(); - endpoint.on("connection", function (socket: Socket) { + endpoint.on("connection", function(socket: Socket) { + _socket = socket; + socket.use((_packet, next) => { const userEmail = socketMap.get(socket); if (userEmail) { @@ -52,8 +56,14 @@ export namespace WebSocket { Utils.AddServerHandler(socket, MessageStore.DeleteFields, ids => DeleteFields(socket, ids)); Utils.AddServerHandlerCallback(socket, MessageStore.GetRefField, GetRefField); Utils.AddServerHandlerCallback(socket, MessageStore.GetRefFields, GetRefFields); + + disconnect = () => { + socket.broadcast.emit("connection_terminated", Date.now()); + socket.disconnect(true); + }; }); - addBeforeExitHandler(async () => { await new Promise<void>(resolve => endpoint.close(resolve)); }); + + const socketPort = isRelease ? Number(process.env.socketPort) : 4321; endpoint.listen(socketPort); logPort("websocket", socketPort); } @@ -73,7 +83,9 @@ export namespace WebSocket { export async function deleteFields() { await Database.Instance.deleteAll(); - await Search.Instance.clear(); + if (process.env.DISABLE_SEARCH !== "true") { + await Search.clear(); + } await Database.Instance.deleteAll('newDocuments'); } @@ -82,7 +94,9 @@ export namespace WebSocket { await Database.Instance.deleteAll('newDocuments'); await Database.Instance.deleteAll('sessions'); await Database.Instance.deleteAll('users'); - await Search.Instance.clear(); + if (process.env.DISABLE_SEARCH !== "true") { + await Search.clear(); + } } function barReceived(socket: SocketIO.Socket, userEmail: string) { @@ -104,7 +118,7 @@ export namespace WebSocket { Database.Instance.update(newValue.id, newValue, () => socket.broadcast.emit(MessageStore.SetField.Message, newValue)); if (newValue.type === Types.Text) { - Search.Instance.updateDocument({ id: newValue.id, data: (newValue as any).data }); + Search.updateDocument({ id: newValue.id, data: (newValue as any).data }); console.log("set field"); console.log("checking in"); } @@ -127,6 +141,7 @@ export namespace WebSocket { "pdf": ["_t", "url"], "audio": ["_t", "url"], "web": ["_t", "url"], + "RichTextField": ["_t", value => value.Text], "date": ["_d", value => new Date(value.date).toISOString()], "proxy": ["_i", "fieldId"], "list": ["_l", list => { @@ -171,7 +186,7 @@ export namespace WebSocket { function UpdateField(socket: Socket, diff: Diff) { Database.Instance.update(diff.id, diff.diff, () => socket.broadcast.emit(MessageStore.UpdateField.Message, diff), false, "newDocuments"); - const docfield = diff.diff.$set; + const docfield = diff.diff.$set || diff.diff.$unset; if (!docfield) { return; } @@ -190,7 +205,7 @@ export namespace WebSocket { } } if (dynfield) { - Search.Instance.updateDocument(update); + Search.updateDocument(update); } } @@ -199,16 +214,14 @@ export namespace WebSocket { socket.broadcast.emit(MessageStore.DeleteField.Message, id); }); - Search.Instance.deleteDocuments([id]); + Search.deleteDocuments([id]); } function DeleteFields(socket: Socket, ids: string[]) { Database.Instance.delete({ _id: { $in: ids } }, "newDocuments").then(() => { socket.broadcast.emit(MessageStore.DeleteFields.Message, ids); }); - - Search.Instance.deleteDocuments(ids); - + Search.deleteDocuments(ids); } function CreateField(newValue: any) { diff --git a/src/server/authentication/config/passport.ts b/src/server/authentication/config/passport.ts index 0ced99b0d..286209b20 100644 --- a/src/server/authentication/config/passport.ts +++ b/src/server/authentication/config/passport.ts @@ -1,8 +1,6 @@ import * as passport from 'passport'; import * as passportLocal from 'passport-local'; -import _ from "lodash"; import { default as User } from '../models/user_model'; -import { Request, Response, NextFunction } from "express"; const LocalStrategy = passportLocal.Strategy; @@ -28,21 +26,4 @@ passport.use(new LocalStrategy({ usernameField: 'email', passReqToCallback: true return done(undefined, user); }); }); -})); - -export let isAuthenticated = (req: Request, res: Response, next: NextFunction) => { - if (req.isAuthenticated()) { - return next(); - } - return res.redirect("/login"); -}; - -export let isAuthorized = (req: Request, res: Response, next: NextFunction) => { - const provider = req.path.split("/").slice(-1)[0]; - - if (_.find(req.user && "tokens" in req.user ? req.user["tokens"] : undefined, { kind: provider })) { - next(); - } else { - res.redirect(`/auth/${provider}`); - } -};
\ No newline at end of file +}));
\ No newline at end of file diff --git a/src/server/authentication/models/current_user_utils.ts b/src/server/authentication/models/current_user_utils.ts index 50bfb4832..36d4cd2f2 100644 --- a/src/server/authentication/models/current_user_utils.ts +++ b/src/server/authentication/models/current_user_utils.ts @@ -9,7 +9,7 @@ import { Doc, DocListCast } from "../../../new_fields/Doc"; import { List } from "../../../new_fields/List"; import { listSpec } from "../../../new_fields/Schema"; import { ScriptField, ComputedField } from "../../../new_fields/ScriptField"; -import { Cast, PromiseValue } from "../../../new_fields/Types"; +import { Cast, PromiseValue, StrCast } from "../../../new_fields/Types"; import { Utils } from "../../../Utils"; import { nullAudio } from "../../../new_fields/URLField"; import { DragManager } from "../../../client/util/DragManager"; @@ -41,12 +41,13 @@ export class CurrentUserUtils { } // setup the "creator" buttons for the sidebar-- eg. the default set of draggable document creation tools - static setupCreatorButtons(doc: Doc) { + static setupCreatorButtons(doc: Doc, buttons?: string[]) { const notes = CurrentUserUtils.setupNoteTypes(doc); doc.noteTypes = Docs.Create.TreeDocument(notes, { title: "Note Types", height: 75 }); doc.activePen = doc; const docProtoData: { title: string, icon: string, drag?: string, ignoreClick?: boolean, click?: string, ischecked?: string, activePen?: Doc, backgroundColor?: string, dragFactory?: Doc }[] = [ { title: "collection", icon: "folder", ignoreClick: true, drag: 'Docs.Create.FreeformDocument([], { nativeWidth: undefined, nativeHeight: undefined, width: 150, height: 100, title: "freeform" })' }, + { title: "preview", icon: "expand", ignoreClick: true, drag: 'Docs.Create.DocumentDocument(ComputedField.MakeFunction("selectedDocs(this,true,[_last_])?.[0]"), { width: 250, height: 250, title: "container" })' }, { title: "todo item", icon: "check", ignoreClick: true, drag: 'getCopy(this.dragFactory, true)', dragFactory: notes[notes.length - 1] }, { title: "web page", icon: "globe-asia", ignoreClick: true, drag: 'Docs.Create.WebDocument("https://en.wikipedia.org/wiki/Hedgehog", { width: 300, height: 300, title: "New Webpage" })' }, { title: "cat image", icon: "cat", ignoreClick: true, drag: 'Docs.Create.ImageDocument("https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg", { width: 200, title: "an image of a cat" })' }, @@ -60,7 +61,7 @@ export class CurrentUserUtils { { title: "use scrubber", icon: "eraser", click: 'activateScrubber(this.activePen.pen = sameDocs(this.activePen.pen, this) ? undefined : this);', ischecked: `sameDocs(this.activePen.pen, this)`, backgroundColor: "green", activePen: doc }, { title: "use drag", icon: "mouse-pointer", click: 'deactivateInk();this.activePen.pen = this;', ischecked: `sameDocs(this.activePen.pen, this)`, backgroundColor: "white", activePen: doc }, ]; - return docProtoData.map(data => Docs.Create.FontIconDocument({ + return docProtoData.filter(d => !buttons || !buttons.includes(d.title)).map(data => Docs.Create.FontIconDocument({ nativeWidth: 100, nativeHeight: 100, width: 100, height: 100, dropAction: data.click ? "copy" : undefined, title: data.title, icon: data.icon, ignoreClick: data.ignoreClick, onDragStart: data.drag ? ScriptField.MakeFunction(data.drag) : undefined, onClick: data.click ? ScriptField.MakeScript(data.click) : undefined, ischecked: data.ischecked ? ComputedField.MakeFunction(data.ischecked) : undefined, activePen: data.activePen, @@ -68,6 +69,27 @@ export class CurrentUserUtils { })); } + static async updateCreatorButtons(doc: Doc) { + const toolsBtn = await Cast(doc.ToolsBtn, Doc); + if (toolsBtn) { + const stackingDoc = await Cast(toolsBtn.sourcePanel, Doc); + if (stackingDoc) { + const stackdocs = await Cast(stackingDoc.data, listSpec(Doc)); + if (stackdocs) { + const dragset = await Cast(stackdocs[0], Doc); + if (dragset) { + const dragdocs = await Cast(dragset.data, listSpec(Doc)); + if (dragdocs) { + const dragDocs = await Promise.all(dragdocs); + const newButtons = this.setupCreatorButtons(doc, dragDocs.map(d => StrCast(d.title))); + newButtons.map(nb => Doc.AddDocToList(dragset, "data", nb)); + } + } + } + } + } + } + // setup the Creator button which will display the creator panel. This panel will include the drag creators and the color picker. when clicked, this panel will be displayed in the target container (ie, sidebarContainer) static setupToolsPanel(sidebarContainer: Doc, doc: Doc) { // setup a masonry view of all he creators @@ -202,6 +224,7 @@ export class CurrentUserUtils { doc.undoBtn && reaction(() => UndoManager.undoStack.slice(), () => Doc.GetProto(doc.undoBtn as Doc).opacity = UndoManager.CanUndo() ? 1 : 0.4, { fireImmediately: true }); doc.redoBtn && reaction(() => UndoManager.redoStack.slice(), () => Doc.GetProto(doc.redoBtn as Doc).opacity = UndoManager.CanRedo() ? 1 : 0.4, { fireImmediately: true }); + this.updateCreatorButtons(doc); return doc; } @@ -299,4 +322,4 @@ export class CurrentUserUtils { }; return recurs([] as Attribute[], schema ? schema.rootAttributeGroup : undefined); } -}
\ No newline at end of file +} diff --git a/src/server/authentication/models/user_model.ts b/src/server/authentication/models/user_model.ts index cc670a03a..78e39dbc1 100644 --- a/src/server/authentication/models/user_model.ts +++ b/src/server/authentication/models/user_model.ts @@ -73,7 +73,11 @@ userSchema.pre("save", function save(next) { }); const comparePassword: comparePasswordFunction = function (this: DashUserModel, candidatePassword, cb) { + // Choose one of the following bodies for authentication logic. + // secure bcrypt.compare(candidatePassword, this.password, cb); + // bypass password + // cb(undefined, true); }; userSchema.methods.comparePassword = comparePassword; diff --git a/src/server/database.ts b/src/server/database.ts index 5bdf1fc45..83ce865c6 100644 --- a/src/server/database.ts +++ b/src/server/database.ts @@ -5,11 +5,13 @@ import { Utils, emptyFunction } from '../Utils'; import { DashUploadUtils } from './DashUploadUtils'; import { Credentials } from 'google-auth-library'; import { GoogleApiServerUtils } from './apis/google/GoogleApiServerUtils'; +import { IDatabase } from './IDatabase'; +import { MemoryDatabase } from './MemoryDatabase'; import * as mongoose from 'mongoose'; -import { addBeforeExitHandler } from './ActionUtilities'; export namespace Database { + export let disconnect: Function; const schema = 'Dash'; const port = 27017; export const url = `mongodb://localhost:${port}/${schema}`; @@ -25,7 +27,7 @@ export namespace Database { export async function tryInitializeConnection() { try { const { connection } = mongoose; - addBeforeExitHandler(async () => { await new Promise<any>(resolve => connection.close(resolve)); }); + disconnect = async () => new Promise<any>(resolve => connection.close(resolve)); if (connection.readyState === ConnectionStates.disconnected) { await new Promise<void>((resolve, reject) => { connection.on('error', reject); @@ -44,7 +46,7 @@ export namespace Database { } } - class Database { + class Database implements IDatabase { public static DocumentsCollection = 'documents'; private MongoClient = mongodb.MongoClient; private currentWrites: { [id: string]: Promise<void> } = {}; @@ -215,7 +217,7 @@ export namespace Database { if (!fetchIds.length) { continue; } - const docs = await new Promise<{ [key: string]: any }[]>(res => Instance.getDocuments(fetchIds, res, "newDocuments")); + const docs = await new Promise<{ [key: string]: any }[]>(res => this.getDocuments(fetchIds, res, collectionName)); for (const doc of docs) { const id = doc.id; visited.add(id); @@ -262,7 +264,16 @@ export namespace Database { } } - export const Instance = new Database(); + function getDatabase() { + switch (process.env.DB) { + case "MEM": + return new MemoryDatabase(); + default: + return new Database(); + } + } + + export const Instance: IDatabase = getDatabase(); export namespace Auxiliary { @@ -331,4 +342,4 @@ export namespace Database { } -}
\ No newline at end of file +} diff --git a/src/server/index.ts b/src/server/index.ts index cef6ff476..313a2f0e2 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -3,14 +3,13 @@ import { GoogleApiServerUtils } from "./apis/google/GoogleApiServerUtils"; import * as mobileDetect from 'mobile-detect'; import * as path from 'path'; import { Database } from './database'; -const serverPort = 4321; import { DashUploadUtils } from './DashUploadUtils'; import RouteSubscriber from './RouteSubscriber'; -import initializeServer from './Initialization'; -import RouteManager, { Method, _success, _permission_denied, _error, _invalid, OnUnauthenticated } from './RouteManager'; +import initializeServer from './server_Initialization'; +import RouteManager, { Method, _success, _permission_denied, _error, _invalid, PublicHandler } from './RouteManager'; import * as qs from 'query-string'; import UtilManager from './ApiManagers/UtilManager'; -import SearchManager from './ApiManagers/SearchManager'; +import { SearchManager } from './ApiManagers/SearchManager'; import UserManager from './ApiManagers/UserManager'; import { WebSocket } from './Websocket/Websocket'; import DownloadManager from './ApiManagers/DownloadManager'; @@ -21,9 +20,14 @@ import UploadManager from "./ApiManagers/UploadManager"; import { log_execution } from "./ActionUtilities"; import GeneralGoogleManager from "./ApiManagers/GeneralGoogleManager"; import GooglePhotosManager from "./ApiManagers/GooglePhotosManager"; -import DiagnosticManager from "./ApiManagers/DiagnosticManager"; +import { Logger } from "./ProcessFactory"; import { yellow } from "colors"; +import { DashSessionAgent } from "./DashSession/DashSessionAgent"; +import SessionManager from "./ApiManagers/SessionManager"; +import { AppliedSessionAgent } from "resilient-server-session"; +export const onWindows = process.platform === "win32"; +export let sessionAgent: AppliedSessionAgent; export const publicDirectory = path.resolve(__dirname, "public"); export const filesDirectory = path.resolve(publicDirectory, "files"); @@ -33,14 +37,17 @@ export const filesDirectory = path.resolve(publicDirectory, "files"); * before clients can access the server should be run or awaited here. */ async function preliminaryFunctions() { + await Logger.initialize(); await GoogleCredentialsLoader.loadCredentials(); GoogleApiServerUtils.processProjectCredentials(); await DashUploadUtils.buildFileDirectories(); - await log_execution({ - startMessage: "attempting to initialize mongodb connection", - endMessage: "connection outcome determined", - action: Database.tryInitializeConnection - }); + if (process.env.DB !== "MEM") { + await log_execution({ + startMessage: "attempting to initialize mongodb connection", + endMessage: "connection outcome determined", + action: Database.tryInitializeConnection + }); + } } /** @@ -54,10 +61,10 @@ async function preliminaryFunctions() { */ function routeSetter({ isRelease, addSupervisedRoute, logRegistrationOutcome }: RouteManager) { const managers = [ + new SessionManager(), new UserManager(), new UploadManager(), new DownloadManager(), - new DiagnosticManager(), new SearchManager(), new PDFManager(), new DeleteManager(), @@ -69,11 +76,6 @@ function routeSetter({ isRelease, addSupervisedRoute, logRegistrationOutcome }: // initialize API Managers console.log(yellow("\nregistering server routes...")); managers.forEach(manager => manager.register(addSupervisedRoute)); - logRegistrationOutcome(); - - // initialize the web socket (bidirectional communication: if a user changes - // a field on one client, that change must be broadcast to all other clients) - WebSocket.initialize(serverPort, isRelease); /** * Accessing root index redirects to home @@ -81,10 +83,16 @@ function routeSetter({ isRelease, addSupervisedRoute, logRegistrationOutcome }: addSupervisedRoute({ method: Method.GET, subscription: "/", - onValidation: ({ res }) => res.redirect("/home") + secureHandler: ({ res }) => res.redirect("/home") }); - const serve: OnUnauthenticated = ({ req, res }) => { + addSupervisedRoute({ + method: Method.GET, + subscription: "/serverHeartbeat", + secureHandler: ({ res }) => res.send(true) + }); + + const serve: PublicHandler = ({ req, res }) => { const detector = new mobileDetect(req.headers['user-agent'] || ""); const filename = detector.mobile() !== null ? 'mobile/image.html' : 'index.html'; res.sendFile(path.join(__dirname, '../../deploy/' + filename)); @@ -93,8 +101,8 @@ function routeSetter({ isRelease, addSupervisedRoute, logRegistrationOutcome }: addSupervisedRoute({ method: Method.GET, subscription: ["/home", new RouteSubscriber("doc").add("docId")], - onValidation: serve, - onUnauthenticated: ({ req, ...remaining }) => { + secureHandler: serve, + publicHandler: ({ req, ...remaining }) => { const { originalUrl: target } = req; const sharing = qs.parse(qs.extract(req.originalUrl), { sort: false }).sharing === "true"; const docAccess = target.startsWith("/doc/"); @@ -103,13 +111,37 @@ function routeSetter({ isRelease, addSupervisedRoute, logRegistrationOutcome }: } } }); + + logRegistrationOutcome(); + + // initialize the web socket (bidirectional communication: if a user changes + // a field on one client, that change must be broadcast to all other clients) + WebSocket.start(isRelease); } -(async function start() { +/** + * This function can be used in two different ways. If not in release mode, + * this is simply the logic that is invoked to start the server. In release mode, + * however, this becomes the logic invoked by a single worker thread spawned by + * the main monitor (master) thread. + */ +export async function launchServer() { await log_execution({ startMessage: "\nstarting execution of preliminary functions", endMessage: "completed preliminary functions\n", action: preliminaryFunctions }); - await initializeServer({ serverPort: 1050, routeSetter }); -})(); + await initializeServer(routeSetter); +} + +/** + * If you're in development mode, you won't need to run a session. + * The session spawns off new server processes each time an error is encountered, and doesn't + * log the output of the server process, so it's not ideal for development. + * So, the 'else' clause is exactly what we've always run when executing npm start. + */ +if (process.env.RELEASE) { + (sessionAgent = new DashSessionAgent()).launch(); +} else { + launchServer(); +} diff --git a/src/server/remapUrl.ts b/src/server/remapUrl.ts index 5218a239a..45d2fdd33 100644 --- a/src/server/remapUrl.ts +++ b/src/server/remapUrl.ts @@ -54,7 +54,7 @@ async function update() { })); console.log("Done"); // await Promise.all(updates.map(update => { - // return limit(() => Search.Instance.updateDocument(update)); + // return limit(() => Search.updateDocument(update)); // })); cursor.close(); } diff --git a/src/server/Initialization.ts b/src/server/server_Initialization.ts index a41e2fea0..9f67c1dda 100644 --- a/src/server/Initialization.ts +++ b/src/server/server_Initialization.ts @@ -19,32 +19,39 @@ import * as fs from 'fs'; import * as request from 'request'; import RouteSubscriber from './RouteSubscriber'; import { publicDirectory } from '.'; -import { logPort, addBeforeExitHandler } from './ActionUtilities'; +import { logPort, } from './ActionUtilities'; import { timeMap } from './ApiManagers/UserManager'; import { blue, yellow } from 'colors'; +import * as cors from "cors"; /* RouteSetter is a wrapper around the server that prevents the server from being exposed. */ export type RouteSetter = (server: RouteManager) => void; -export interface InitializationOptions { - serverPort: number; - routeSetter: RouteSetter; -} +export let disconnect: Function; -export default async function InitializeServer(options: InitializationOptions) { - const { serverPort, routeSetter } = options; +export default async function InitializeServer(routeSetter: RouteSetter) { const app = buildWithMiddleware(express()); - app.use(express.static(publicDirectory)); + app.use(express.static(publicDirectory, { + setHeaders: res => res.setHeader("Access-Control-Allow-Origin", "*") + })); app.use("/images", express.static(publicDirectory)); - - app.use("*", ({ user, originalUrl }, _res, next) => { - if (!originalUrl.includes("Heartbeat")) { - const userEmail = user && ("email" in user) ? user["email"] : undefined; + const corsOptions = { + origin: function (_origin: any, callback: any) { + callback(null, true); + } + }; + app.use(cors(corsOptions)); + app.use("*", ({ user, originalUrl }, res, next) => { + if (user && !originalUrl.includes("Heartbeat")) { + const userEmail = (user as any).email; if (userEmail) { timeMap[userEmail] = Date.now(); } } + if (!user && originalUrl === "/") { + return res.redirect("/login"); + } next(); }); @@ -55,13 +62,16 @@ export default async function InitializeServer(options: InitializationOptions) { registerCorsProxy(app); const isRelease = determineEnvironment(); + routeSetter(new RouteManager(app, isRelease)); + const serverPort = isRelease ? Number(process.env.serverPort) : 1050; const server = app.listen(serverPort, () => { - logPort("server", serverPort); + logPort("server", Number(serverPort)); console.log(); }); - addBeforeExitHandler(async () => { await new Promise<Error>(resolve => server.close(resolve)); }); + disconnect = async () => new Promise<Error>(resolve => server.close(resolve)); + return isRelease; } @@ -76,7 +86,7 @@ function buildWithMiddleware(server: express.Express) { resave: true, cookie: { maxAge: week }, saveUninitialized: true, - store: new MongoStore({ url: Database.url }) + store: process.env.DB === "MEM" ? new session.MemoryStore() : new MongoStore({ url: Database.url }) }), flash(), expressFlash(), @@ -142,4 +152,4 @@ function registerCorsProxy(server: express.Express) { }); }).pipe(res); }); -}
\ No newline at end of file +} diff --git a/src/server/updateSearch.ts b/src/server/updateSearch.ts new file mode 100644 index 000000000..83094d36a --- /dev/null +++ b/src/server/updateSearch.ts @@ -0,0 +1,121 @@ +import { Database } from "./database"; +import { Search } from "./Search"; +import { log_execution } from "./ActionUtilities"; +import { cyan, green, yellow, red } from "colors"; + +const suffixMap: { [type: string]: (string | [string, string | ((json: any) => any)]) } = { + "number": "_n", + "string": "_t", + "boolean": "_b", + "image": ["_t", "url"], + "video": ["_t", "url"], + "pdf": ["_t", "url"], + "audio": ["_t", "url"], + "web": ["_t", "url"], + "date": ["_d", value => new Date(value.date).toISOString()], + "proxy": ["_i", "fieldId"], + "list": ["_l", list => { + const results = []; + for (const value of list.fields) { + const term = ToSearchTerm(value); + if (term) { + results.push(term.value); + } + } + return results.length ? results : null; + }] +}; + +function ToSearchTerm(val: any): { suffix: string, value: any } | undefined { + if (val === null || val === undefined) { + return; + } + const type = val.__type || typeof val; + let suffix = suffixMap[type]; + if (!suffix) { + return; + } + + if (Array.isArray(suffix)) { + const accessor = suffix[1]; + if (typeof accessor === "function") { + val = accessor(val); + } else { + val = val[accessor]; + } + suffix = suffix[0]; + } + + return { suffix, value: val }; +} + +async function update() { + console.log(green("Beginning update...")); + await log_execution<void>({ + startMessage: "Clearing existing Solr information...", + endMessage: "Solr information successfully cleared", + action: Search.clear, + color: cyan + }); + const cursor = await log_execution({ + startMessage: "Connecting to and querying for all documents from database...", + endMessage: ({ result, error }) => { + const success = error === null && result !== undefined; + if (!success) { + console.log(red("Unable to connect to the database.")); + process.exit(0); + } + return "Connection successful and query complete"; + }, + action: () => Database.Instance.query({}), + color: yellow + }); + const updates: any[] = []; + let numDocs = 0; + function updateDoc(doc: any) { + numDocs++; + if ((numDocs % 50) === 0) { + console.log(`Batch of 50 complete, total of ${numDocs}`); + } + if (doc.__type !== "Doc") { + return; + } + const fields = doc.fields; + if (!fields) { + return; + } + const update: any = { id: doc._id }; + let dynfield = false; + for (const key in fields) { + const value = fields[key]; + const term = ToSearchTerm(value); + if (term !== undefined) { + const { suffix, value } = term; + update[key + suffix] = value; + dynfield = true; + } + } + if (dynfield) { + updates.push(update); + } + } + await cursor?.forEach(updateDoc); + const result = await log_execution({ + startMessage: `Dispatching updates for ${updates.length} documents`, + endMessage: "Dispatched updates complete", + action: () => Search.updateDocuments(updates), + color: cyan + }); + try { + const { status } = JSON.parse(result).responseHeader; + console.log(status ? red(`Failed with status code (${status})`) : green("Success!")); + } catch { + console.log(red("Error:")); + console.log(result); + console.log("\n"); + } + await cursor?.close(); + process.exit(0); +} + +update();
\ No newline at end of file diff --git a/src/typings/index.d.ts b/src/typings/index.d.ts index 9e8ace962..281bb3217 100644 --- a/src/typings/index.d.ts +++ b/src/typings/index.d.ts @@ -2,6 +2,7 @@ declare module 'googlephotos'; declare module 'react-image-lightbox-with-rotate'; +declare module 'cors'; declare module '@react-pdf/renderer' { import * as React from 'react'; |