diff options
Diffstat (limited to 'src/client/util')
-rw-r--r-- | src/client/util/DocumentManager.ts | 128 | ||||
-rw-r--r-- | src/client/util/DragManager.ts | 344 | ||||
-rw-r--r-- | src/client/util/History.ts | 122 | ||||
-rw-r--r-- | src/client/util/ProsemirrorKeymap.ts | 100 | ||||
-rw-r--r-- | src/client/util/RichTextRules.ts | 43 | ||||
-rw-r--r-- | src/client/util/RichTextSchema.tsx | 589 | ||||
-rw-r--r-- | src/client/util/Scripting.ts | 141 | ||||
-rw-r--r-- | src/client/util/ScrollBox.tsx | 4 | ||||
-rw-r--r-- | src/client/util/SearchUtil.ts | 25 | ||||
-rw-r--r-- | src/client/util/SelectionManager.ts | 63 | ||||
-rw-r--r-- | src/client/util/SerializationHelper.ts | 130 | ||||
-rw-r--r-- | src/client/util/TooltipLinkingMenu.tsx | 86 | ||||
-rw-r--r-- | src/client/util/TooltipTextMenu.scss | 258 | ||||
-rw-r--r-- | src/client/util/TooltipTextMenu.tsx | 575 | ||||
-rw-r--r-- | src/client/util/Transform.ts | 56 | ||||
-rw-r--r-- | src/client/util/TypedEvent.ts | 4 | ||||
-rw-r--r-- | src/client/util/UndoManager.ts | 105 | ||||
-rw-r--r-- | src/client/util/jsx-decl.d.ts | 1 | ||||
-rw-r--r-- | src/client/util/type_decls.d | 12 |
19 files changed, 2228 insertions, 558 deletions
diff --git a/src/client/util/DocumentManager.ts b/src/client/util/DocumentManager.ts index 5b99b4ef8..d06af6dd5 100644 --- a/src/client/util/DocumentManager.ts +++ b/src/client/util/DocumentManager.ts @@ -1,8 +1,14 @@ -import React = require('react') -import { observer } from 'mobx-react'; -import { observable, action } from 'mobx'; -import { Document } from "../../fields/Document" +import { computed, observable } from 'mobx'; import { DocumentView } from '../views/nodes/DocumentView'; +import { Doc, DocListCast, Opt } from '../../new_fields/Doc'; +import { FieldValue, Cast, NumCast, BoolCast } from '../../new_fields/Types'; +import { listSpec } from '../../new_fields/Schema'; +import { undoBatch } from './UndoManager'; +import { CollectionDockingView } from '../views/collections/CollectionDockingView'; +import { Id } from '../../new_fields/RefField'; +import { CollectionView } from '../views/collections/CollectionView'; +import { CollectionPDFView } from '../views/collections/CollectionPDFView'; +import { CollectionVideoView } from '../views/collections/CollectionVideoView'; export class DocumentManager { @@ -24,28 +30,114 @@ export class DocumentManager { // this.DocumentViews = new Array<DocumentView>(); } - public getDocumentView(toFind: Document): DocumentView | null { + public getDocumentViewById(id: string, preferredCollection?: CollectionView | CollectionPDFView | CollectionVideoView): DocumentView | null { - let toReturn: DocumentView | null; - toReturn = null; + let toReturn: DocumentView | null = null; + let passes = preferredCollection ? [preferredCollection, undefined] : [undefined]; + + for (let i = 0; i < passes.length; i++) { + DocumentManager.Instance.DocumentViews.map(view => { + if (view.props.Document[Id] === id && (!passes[i] || view.props.ContainingCollectionView === preferredCollection)) { + toReturn = view; + return; + } + }); + if (!toReturn) { + DocumentManager.Instance.DocumentViews.map(view => { + let doc = view.props.Document.proto; + if (doc && doc[Id] === id && (!passes[i] || view.props.ContainingCollectionView === preferredCollection)) { + toReturn = view; + } + }); + } + } + + return toReturn; + } + + public getDocumentView(toFind: Doc, preferredCollection?: CollectionView | CollectionPDFView | CollectionVideoView): DocumentView | null { + return this.getDocumentViewById(toFind[Id], preferredCollection); + } + + public getDocumentViews(toFind: Doc): DocumentView[] { + + let toReturn: DocumentView[] = []; //gets document view that is in a freeform canvas collection DocumentManager.Instance.DocumentViews.map(view => { let doc = view.props.Document; // if (view.props.ContainingCollectionView instanceof CollectionFreeFormView) { - // if (Object.is(doc, toFind)) { - // toReturn = view; - // return; - // } - // } - - if (Object.is(doc, toFind)) { - toReturn = view; - return; + + if (doc === toFind) { + toReturn.push(view); + } else { + let docSrc = FieldValue(doc.proto); + if (docSrc && Object.is(docSrc, toFind)) { + toReturn.push(view); + } } + }); - }) + return toReturn; + } - return (toReturn); + @computed + public get LinkedDocumentViews() { + return DocumentManager.Instance.DocumentViews.filter(dv => dv.isSelected() || BoolCast(dv.props.Document.libraryBrush, false)).reduce((pairs, dv) => { + let linksList = DocListCast(dv.props.Document.linkedToDocs); + if (linksList && linksList.length) { + pairs.push(...linksList.reduce((pairs, link) => { + if (link) { + let linkToDoc = FieldValue(Cast(link.linkedTo, Doc)); + if (linkToDoc) { + DocumentManager.Instance.getDocumentViews(linkToDoc).map(docView1 => + pairs.push({ a: dv, b: docView1, l: link })); + } + } + return pairs; + }, [] as { a: DocumentView, b: DocumentView, l: Doc }[])); + } + linksList = DocListCast(dv.props.Document.linkedFromDocs); + if (linksList && linksList.length) { + pairs.push(...linksList.reduce((pairs, link) => { + if (link) { + let linkFromDoc = FieldValue(Cast(link.linkedFrom, Doc)); + if (linkFromDoc) { + DocumentManager.Instance.getDocumentViews(linkFromDoc).map(docView1 => + pairs.push({ a: dv, b: docView1, l: link })); + } + } + return pairs; + }, pairs)); + } + return pairs; + }, [] as { a: DocumentView, b: DocumentView, l: Doc }[]); + } + + @undoBatch + public jumpToDocument = async (docDelegate: Doc, makeCopy: boolean = true): Promise<void> => { + let doc = docDelegate.proto ? docDelegate.proto : docDelegate; + const page = NumCast(doc.page, undefined); + const contextDoc = await Cast(doc.annotationOn, Doc); + if (contextDoc) { + const curPage = NumCast(contextDoc.curPage, page); + if (page !== curPage) contextDoc.curPage = page; + } + let docView = DocumentManager.Instance.getDocumentView(doc); + if (docView) { + docView.props.focus(docView.props.Document); + } else { + if (!contextDoc) { + CollectionDockingView.Instance.AddRightSplit(docDelegate ? (makeCopy ? Doc.MakeCopy(docDelegate) : docDelegate) : Doc.MakeDelegate(doc)); + } else { + let contextView = DocumentManager.Instance.getDocumentView(contextDoc); + if (contextView) { + contextDoc.panTransformType = "Ease"; + contextView.props.focus(contextDoc); + } else { + CollectionDockingView.Instance.AddRightSplit(contextDoc); + } + } + } } }
\ No newline at end of file diff --git a/src/client/util/DragManager.ts b/src/client/util/DragManager.ts index 4a61220a5..7f75a95f0 100644 --- a/src/client/util/DragManager.ts +++ b/src/client/util/DragManager.ts @@ -1,40 +1,70 @@ -import { DocumentDecorations } from "../views/DocumentDecorations"; +import { action, runInAction } from "mobx"; +import { Doc, DocListCastAsync } from "../../new_fields/Doc"; +import { Cast } from "../../new_fields/Types"; +import { emptyFunction } from "../../Utils"; import { CollectionDockingView } from "../views/collections/CollectionDockingView"; -import { Document } from "../../fields/Document" -import { action } from "mobx"; -import { DocumentView } from "../views/nodes/DocumentView"; -import { ImageField } from "../../fields/ImageField"; -import { KeyStore } from "../../fields/KeyStore"; - -export function setupDrag(_reference: React.RefObject<HTMLDivElement>, docFunc: () => Document) { - let onRowMove = action((e: PointerEvent): void => { +import * as globalCssVariables from "../views/globalCssVariables.scss"; + +export type dropActionType = "alias" | "copy" | undefined; +export function SetupDrag(_reference: React.RefObject<HTMLElement>, docFunc: () => Doc | Promise<Doc>, moveFunc?: DragManager.MoveFunction, dropAction?: dropActionType) { + let onRowMove = async (e: PointerEvent) => { e.stopPropagation(); e.preventDefault(); document.removeEventListener("pointermove", onRowMove); document.removeEventListener('pointerup', onRowUp); - DragManager.StartDrag(_reference.current!, { document: docFunc() }); - }); - let onRowUp = action((e: PointerEvent): void => { + var dragData = new DragManager.DocumentDragData([await docFunc()]); + dragData.dropAction = dropAction; + dragData.moveDocument = moveFunc; + DragManager.StartDocumentDrag([_reference.current!], dragData, e.x, e.y); + }; + let onRowUp = (): void => { document.removeEventListener("pointermove", onRowMove); document.removeEventListener('pointerup', onRowUp); - }); - let onItemDown = (e: React.PointerEvent) => { + }; + let onItemDown = async (e: React.PointerEvent) => { // if (this.props.isSelected() || this.props.isTopMost) { - if (e.button == 0) { + if (e.button === 0) { e.stopPropagation(); if (e.shiftKey) { - CollectionDockingView.Instance.StartOtherDrag(docFunc(), e); + CollectionDockingView.Instance.StartOtherDrag([await docFunc()], e); } else { document.addEventListener("pointermove", onRowMove); - document.addEventListener('pointerup', onRowUp); + document.addEventListener("pointerup", onRowUp); } } //} - } + }; return onItemDown; } +export async function DragLinksAsDocuments(dragEle: HTMLElement, x: number, y: number, sourceDoc: Doc) { + let srcTarg = sourceDoc.proto; + let draggedDocs: Doc[] = []; + let draggedFromDocs: Doc[] = []; + if (srcTarg) { + let linkToDocs = await DocListCastAsync(srcTarg.linkedToDocs); + let linkFromDocs = await DocListCastAsync(srcTarg.linkedFromDocs); + if (linkToDocs) draggedDocs = linkToDocs.map(linkDoc => Cast(linkDoc.linkedTo, Doc) as Doc); + if (linkFromDocs) draggedFromDocs = linkFromDocs.map(linkDoc => Cast(linkDoc.linkedFrom, Doc) as Doc); + } + draggedDocs.push(...draggedFromDocs); + if (draggedDocs.length) { + let moddrag: Doc[] = []; + for (const draggedDoc of draggedDocs) { + let doc = await Cast(draggedDoc.annotationOn, Doc); + if (doc) moddrag.push(doc); + } + let dragData = new DragManager.DocumentDragData(moddrag.length ? moddrag : draggedDocs); + DragManager.StartDocumentDrag([dragEle], dragData, x, y, { + handlers: { + dragComplete: action(emptyFunction), + }, + hideSource: false + }); + } +} + export namespace DragManager { export function Root() { const root = document.getElementById("root"); @@ -47,7 +77,9 @@ export namespace DragManager { let dragDiv: HTMLDivElement; export enum DragButtons { - Left = 1, Right = 2, Both = Left | Right + Left = 1, + Right = 2, + Both = Left | Right } interface DragOptions { @@ -60,8 +92,7 @@ export namespace DragManager { (): void; } - export class DragCompleteEvent { - } + export class DragCompleteEvent { } export interface DragHandlers { dragComplete: (e: DragCompleteEvent) => void; @@ -70,21 +101,29 @@ export namespace DragManager { export interface DropOptions { handlers: DropHandlers; } - export class DropEvent { - constructor(readonly x: number, readonly y: number, readonly data: { [id: string]: any }) { } + constructor( + readonly x: number, + readonly y: number, + readonly data: { [id: string]: any }, + readonly mods: string + ) { } } export interface DropHandlers { drop: (e: Event, de: DropEvent) => void; } - - export function MakeDropTarget(element: HTMLElement, options: DropOptions): DragDropDisposer { + 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"); + throw new Error( + "Element is already droppable, can't make it droppable again" + ); } - element.dataset["canDrop"] = "true"; + element.dataset.canDrop = "true"; const handler = (e: Event) => { const ce = e as CustomEvent<DropEvent>; options.handlers.drop(e, ce.detail); @@ -92,54 +131,120 @@ export namespace DragManager { element.addEventListener("dashOnDrop", handler); return () => { element.removeEventListener("dashOnDrop", handler); - delete element.dataset["canDrop"] + delete element.dataset.canDrop; }; } - export function StartDrag(ele: HTMLElement, dragData: { [id: string]: any }, options?: DragOptions) { + export type MoveFunction = (document: Doc, targetCollection: Doc, addDocument: (document: Doc) => boolean) => boolean; + export class DocumentDragData { + constructor(dragDoc: Doc[]) { + this.draggedDocuments = dragDoc; + this.droppedDocuments = dragDoc; + this.xOffset = 0; + this.yOffset = 0; + } + draggedDocuments: Doc[]; + droppedDocuments: Doc[]; + xOffset: number; + yOffset: number; + dropAction: dropActionType; + userDropAction: dropActionType; + moveDocument?: MoveFunction; + [id: string]: any; + } + + export let StartDragFunctions: (() => void)[] = []; + + export function StartDocumentDrag(eles: HTMLElement[], dragData: DocumentDragData, downX: number, downY: number, options?: DragOptions) { + runInAction(() => StartDragFunctions.map(func => func())); + StartDrag(eles, dragData, downX, downY, options, + (dropData: { [id: string]: any }) => + (dropData.droppedDocuments = dragData.userDropAction === "alias" || (!dragData.userDropAction && dragData.dropAction === "alias") ? + dragData.draggedDocuments.map(d => Doc.MakeAlias(d)) : + dragData.userDropAction === "copy" || (!dragData.userDropAction && dragData.dropAction === "copy") ? + dragData.draggedDocuments.map(d => Doc.MakeCopy(d, true)) : + dragData.draggedDocuments)); + } + + export class LinkDragData { + constructor(linkSourceDoc: Doc, blacklist: Doc[] = []) { + this.linkSourceDocument = linkSourceDoc; + this.blacklist = blacklist; + } + droppedDocuments: Doc[] = []; + linkSourceDocument: Doc; + blacklist: Doc[]; + dontClearTextBox?: boolean; + [id: string]: any; + } + + export function StartLinkDrag(ele: HTMLElement, dragData: LinkDragData, 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) { if (!dragDiv) { dragDiv = document.createElement("div"); - dragDiv.className = "dragManager-dragDiv" + dragDiv.className = "dragManager-dragDiv"; + dragDiv.style.pointerEvents = "none"; DragManager.Root().appendChild(dragDiv); } - const w = ele.offsetWidth, h = ele.offsetHeight; - const rect = ele.getBoundingClientRect(); - const scaleX = rect.width / w, scaleY = rect.height / h; - let x = rect.left, y = rect.top; - // const offsetX = e.x - rect.left, offsetY = e.y - rect.top; - - let dragElement = ele.cloneNode(true) as HTMLElement; - dragElement.style.opacity = "0.7"; - dragElement.style.position = "absolute"; - dragElement.style.bottom = ""; - dragElement.style.left = ""; - dragElement.style.transformOrigin = "0 0"; - dragElement.style.zIndex = "1000"; - dragElement.style.transform = `translate(${x}px, ${y}px) scale(${scaleX}, ${scaleY})`; - dragElement.style.width = `${rect.width / scaleX}px`; - dragElement.style.height = `${rect.height / scaleY}px`; - - // bcz: PDFs don't show up if you clone them because they contain a canvas. - // however, PDF's have a thumbnail field that contains an image of their canvas. - // So we replace the pdf's canvas with the image thumbnail - const docView: DocumentView = dragData["documentView"]; - const doc: Document = docView ? docView.props.Document : dragData["document"]; - - if (doc) { - var pdfBox = dragElement.getElementsByClassName("pdfBox-cont")[0] as HTMLElement; - let thumbnail = doc.GetT(KeyStore.Thumbnail, ImageField); - if (pdfBox && pdfBox.childElementCount && thumbnail) { - let img = new Image(); - img!.src = thumbnail.toString(); - img!.style.position = "absolute"; - img!.style.width = `${rect.width / scaleX}px`; - img!.style.height = `${rect.height / scaleY}px`; - pdfBox.replaceChild(img!, pdfBox.children[0]) - } - } + let scaleXs: number[] = []; + let scaleYs: number[] = []; + let xs: number[] = []; + let ys: number[] = []; + + const docs: Doc[] = + dragData instanceof DocumentDragData ? dragData.draggedDocuments : []; + let dragElements = eles.map(ele => { + const w = ele.offsetWidth, + h = ele.offsetHeight; + const rect = ele.getBoundingClientRect(); + const scaleX = rect.width / w, + scaleY = rect.height / h; + let x = rect.left, + y = rect.top; + xs.push(x); + ys.push(y); + scaleXs.push(scaleX); + scaleYs.push(scaleY); + let dragElement = ele.cloneNode(true) as HTMLElement; + dragElement.style.opacity = "0.7"; + dragElement.style.position = "absolute"; + dragElement.style.margin = "0"; + dragElement.style.top = "0"; + dragElement.style.bottom = ""; + dragElement.style.left = "0"; + dragElement.style.color = "black"; + dragElement.style.transformOrigin = "0 0"; + dragElement.style.zIndex = globalCssVariables.contextMenuZindex;// "1000"; + dragElement.style.transform = `translate(${x}px, ${y}px) scale(${scaleX}, ${scaleY})`; + dragElement.style.width = `${rect.width / scaleX}px`; + dragElement.style.height = `${rect.height / scaleY}px`; + + // bcz: if PDFs are rendered with svg's, then this code isn't needed + // bcz: PDFs don't show up if you clone them when rendered using a canvas. + // however, PDF's have a thumbnail field that contains an image of their canvas. + // So we replace the pdf's canvas with the image thumbnail + // if (docs.length) { + // var pdfBox = dragElement.getElementsByClassName("pdfBox-cont")[0] as HTMLElement; + // let thumbnail = docs[0].GetT(KeyStore.Thumbnail, ImageField); + // if (pdfBox && pdfBox.childElementCount && thumbnail) { + // let img = new Image(); + // img.src = thumbnail.toString(); + // img.style.position = "absolute"; + // img.style.width = `${rect.width / scaleX}px`; + // img.style.height = `${rect.height / scaleY}px`; + // pdfBox.replaceChild(img, pdfBox.children[0]) + // } + // } - dragDiv.appendChild(dragElement); + dragDiv.appendChild(dragElement); + return dragElement; + }); let hideSource = false; if (options) { @@ -149,59 +254,92 @@ export namespace DragManager { hideSource = options.hideSource(); } } - const wasHidden = ele.hidden; - if (hideSource) { - ele.hidden = true; - } + eles.map(ele => (ele.hidden = hideSource)); + + let lastX = downX; + let lastY = downY; const moveHandler = (e: PointerEvent) => { e.stopPropagation(); e.preventDefault(); - x += e.movementX; - y += e.movementY; + if (dragData instanceof DocumentDragData) { + dragData.userDropAction = e.ctrlKey || e.altKey ? "alias" : undefined; + } if (e.shiftKey) { - abortDrag(); - CollectionDockingView.Instance.StartOtherDrag(doc, { pageX: e.pageX, pageY: e.pageY, preventDefault: () => { }, button: 0 }); + AbortDrag(); + CollectionDockingView.Instance.StartOtherDrag(docs, { + pageX: e.pageX, + pageY: e.pageY, + preventDefault: emptyFunction, + button: 0 + }); } - dragElement.style.transform = `translate(${x}px, ${y}px) scale(${scaleX}, ${scaleY})`; + //TODO: Why can't we use e.movementX and e.movementY? + let moveX = e.pageX - lastX; + let moveY = e.pageY - lastY; + lastX = e.pageX; + lastY = e.pageY; + dragElements.map((dragElement, i) => (dragElement.style.transform = + `translate(${(xs[i] += moveX)}px, ${(ys[i] += moveY)}px) + scale(${scaleXs[i]}, ${scaleYs[i]})`) + ); }; - const abortDrag = () => { + let hideDragElements = () => { + dragElements.map(dragElement => dragElement.parentNode === dragDiv && dragDiv.removeChild(dragElement)); + eles.map(ele => (ele.hidden = false)); + }; + let endDrag = () => { document.removeEventListener("pointermove", moveHandler, true); document.removeEventListener("pointerup", upHandler); - dragDiv.removeChild(dragElement); - if (hideSource && !wasHidden) { - ele.hidden = false; + if (options) { + options.handlers.dragComplete({}); } - } + }; + + AbortDrag = () => { + hideDragElements(); + endDrag(); + }; const upHandler = (e: PointerEvent) => { - abortDrag(); - FinishDrag(ele, e, dragData, options); + hideDragElements(); + dispatchDrag(eles, e, dragData, options, finishDrag); + endDrag(); }; document.addEventListener("pointermove", moveHandler, true); document.addEventListener("pointerup", upHandler); } - function FinishDrag(dragEle: HTMLElement, e: PointerEvent, dragData: { [index: string]: any }, options?: DragOptions) { - let parent = dragEle.parentElement; - if (parent) - parent.removeChild(dragEle); + function dispatchDrag(dragEles: HTMLElement[], e: PointerEvent, dragData: { [index: string]: any }, options?: DragOptions, finishDrag?: (dragData: { [index: string]: any }) => void) { + let removed = dragEles.map(dragEle => { + // let parent = dragEle.parentElement; + // if (parent) parent.removeChild(dragEle); + let ret = [dragEle, dragEle.style.width, dragEle.style.height]; + dragEle.style.width = "0"; + dragEle.style.height = "0"; + return ret; + }); const target = document.elementFromPoint(e.x, e.y); - if (parent) - parent.appendChild(dragEle); - if (!target) { - return; - } - target.dispatchEvent(new CustomEvent<DropEvent>("dashOnDrop", { - bubbles: true, - detail: { - x: e.x, - y: e.y, - data: dragData - } - })); - if (options) { - options.handlers.dragComplete({}); + removed.map(r => { + let 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); + }); + if (target) { + if (finishDrag) finishDrag(dragData); + + target.dispatchEvent( + new CustomEvent<DropEvent>("dashOnDrop", { + bubbles: true, + detail: { + x: e.x, + y: e.y, + data: dragData, + mods: e.altKey ? "AltKey" : "" + } + }) + ); } - DocumentDecorations.Instance.Hidden = false; } -}
\ No newline at end of file +} diff --git a/src/client/util/History.ts b/src/client/util/History.ts new file mode 100644 index 000000000..92d2b2b44 --- /dev/null +++ b/src/client/util/History.ts @@ -0,0 +1,122 @@ +import { Doc, Opt, Field } from "../../new_fields/Doc"; +import { DocServer } from "../DocServer"; +import { Main } from "../views/Main"; +import { RouteStore } from "../../server/RouteStore"; + +export namespace HistoryUtil { + export interface DocInitializerList { + [key: string]: string | number; + } + + export interface DocUrl { + type: "doc"; + docId: string; + initializers: { + [docId: string]: DocInitializerList; + }; + } + + export type ParsedUrl = DocUrl; + + // const handlers: ((state: ParsedUrl | null) => void)[] = []; + function onHistory(e: PopStateEvent) { + if (window.location.pathname !== RouteStore.home) { + const url = e.state as ParsedUrl || parseUrl(window.location.pathname); + if (url) { + switch (url.type) { + case "doc": + onDocUrl(url); + break; + } + } + } + // for (const handler of handlers) { + // handler(e.state); + // } + } + + export function pushState(state: ParsedUrl) { + history.pushState(state, "", createUrl(state)); + } + + export function replaceState(state: ParsedUrl) { + history.replaceState(state, "", createUrl(state)); + } + + function copyState(state: ParsedUrl): ParsedUrl { + return JSON.parse(JSON.stringify(state)); + } + + export function getState(): ParsedUrl { + return copyState(history.state); + } + + // export function addHandler(handler: (state: ParsedUrl | null) => void) { + // handlers.push(handler); + // } + + // export function removeHandler(handler: (state: ParsedUrl | null) => void) { + // const index = handlers.indexOf(handler); + // if (index !== -1) { + // handlers.splice(index, 1); + // } + // } + + export function parseUrl(pathname: string): ParsedUrl | undefined { + let pathnameSplit = pathname.split("/"); + if (pathnameSplit.length !== 2) { + return undefined; + } + const type = pathnameSplit[0]; + const data = pathnameSplit[1]; + + if (type === "doc") { + const s = data.split("?"); + if (s.length < 1 || s.length > 2) { + return undefined; + } + const docId = s[0]; + const initializers = s.length === 2 ? JSON.parse(decodeURIComponent(s[1])) : {}; + return { + type: "doc", + docId, + initializers + }; + } + + return undefined; + } + + export function createUrl(params: ParsedUrl): string { + let baseUrl = DocServer.prepend(`/${params.type}`); + switch (params.type) { + case "doc": + const initializers = encodeURIComponent(JSON.stringify(params.initializers)); + const id = params.docId; + let url = baseUrl + `/${id}`; + if (Object.keys(params.initializers).length) { + url += `?${initializers}`; + } + return url; + } + return ""; + } + + export async function initDoc(id: string, initializer: DocInitializerList) { + const doc = await DocServer.GetRefField(id); + if (!(doc instanceof Doc)) { + return; + } + Doc.assign(doc, initializer); + } + + async function onDocUrl(url: DocUrl) { + const field = await DocServer.GetRefField(url.docId); + await Promise.all(Object.keys(url.initializers).map(id => initDoc(id, url.initializers[id]))); + if (field instanceof Doc) { + Main.Instance.openWorkspace(field, true); + } + } + + window.onpopstate = onHistory; +} diff --git a/src/client/util/ProsemirrorKeymap.ts b/src/client/util/ProsemirrorKeymap.ts new file mode 100644 index 000000000..00d086b97 --- /dev/null +++ b/src/client/util/ProsemirrorKeymap.ts @@ -0,0 +1,100 @@ +import { Schema } from "prosemirror-model"; +import { + wrapIn, setBlockType, chainCommands, toggleMark, exitCode, + joinUp, joinDown, lift, selectParentNode +} from "prosemirror-commands"; +import { wrapInList, splitListItem, liftListItem, sinkListItem } from "prosemirror-schema-list"; +import { undo, redo } from "prosemirror-history"; +import { undoInputRule } from "prosemirror-inputrules"; +import { Transaction, EditorState } from "prosemirror-state"; + +const mac = typeof navigator !== "undefined" ? /Mac/.test(navigator.platform) : false; + +export type KeyMap = { [key: string]: any }; + +export default function buildKeymap<S extends Schema<any>>(schema: S, mapKeys?: KeyMap): KeyMap { + let keys: { [key: string]: any } = {}, type; + + function bind(key: string, cmd: any) { + if (mapKeys) { + let mapped = mapKeys[key]; + if (mapped === false) return; + if (mapped) key = mapped; + } + keys[key] = cmd; + } + + bind("Mod-z", undo); + bind("Shift-Mod-z", redo); + bind("Backspace", undoInputRule); + + if (!mac) { + bind("Mod-y", redo); + } + + bind("Alt-ArrowUp", joinUp); + bind("Alt-ArrowDown", joinDown); + bind("Mod-BracketLeft", lift); + bind("Escape", selectParentNode); + + if (type = schema.marks.strong) { + bind("Mod-b", toggleMark(type)); + bind("Mod-B", toggleMark(type)); + } + if (type = schema.marks.em) { + bind("Mod-i", toggleMark(type)); + bind("Mod-I", toggleMark(type)); + } + if (type = schema.marks.code) { + bind("Mod-`", toggleMark(type)); + } + + if (type = schema.nodes.bullet_list) { + bind("Ctrl-b", wrapInList(type)); + } + if (type = schema.nodes.ordered_list) { + bind("Ctrl-n", wrapInList(type)); + } + if (type = schema.nodes.blockquote) { + bind("Ctrl->", wrapIn(type)); + } + if (type = schema.nodes.hard_break) { + let br = type, cmd = chainCommands(exitCode, (state, dispatch) => { + if (dispatch) { + dispatch(state.tr.replaceSelectionWith(br.create()).scrollIntoView()); + return true; + } + return false; + }); + bind("Mod-Enter", cmd); + bind("Shift-Enter", cmd); + if (mac) { + bind("Ctrl-Enter", cmd); + } + } + if (type = schema.nodes.list_item) { + bind("Enter", splitListItem(type)); + bind("Shift-Tab", liftListItem(type)); + bind("Tab", sinkListItem(type)); + } + if (type = schema.nodes.paragraph) { + bind("Shift-Ctrl-0", setBlockType(type)); + } + if (type = schema.nodes.code_block) { + bind("Shift-Ctrl-\\", setBlockType(type)); + } + if (type = schema.nodes.heading) { + for (let i = 1; i <= 6; i++) { + bind("Shift-Ctrl-" + i, setBlockType(type, { level: i })); + } + } + if (type = schema.nodes.horizontal_rule) { + let hr = type; + bind("Mod-_", (state: EditorState<S>, dispatch: (tx: Transaction<S>) => void) => { + dispatch(state.tr.replaceSelectionWith(hr.create()).scrollIntoView()); + return true; + }); + } + + return keys; +}
\ No newline at end of file diff --git a/src/client/util/RichTextRules.ts b/src/client/util/RichTextRules.ts new file mode 100644 index 000000000..3b8396510 --- /dev/null +++ b/src/client/util/RichTextRules.ts @@ -0,0 +1,43 @@ +import { + inputRules, + wrappingInputRule, + textblockTypeInputRule, + smartQuotes, + emDash, + ellipsis +} from "prosemirror-inputrules"; +import { Schema, NodeSpec, MarkSpec, DOMOutputSpecArray, NodeType } from "prosemirror-model"; + +import { schema } from "./RichTextSchema"; + +export const inpRules = { + rules: [ + ...smartQuotes, + ellipsis, + emDash, + + // > blockquote + wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote), + + // 1. ordered list + wrappingInputRule( + /^(\d+)\.\s$/, + schema.nodes.ordered_list, + match => ({ order: +match[1] }), + (match, node) => node.childCount + node.attrs.order === +match[1] + ), + + // * bullet list + wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list), + + // ``` code block + textblockTypeInputRule(/^```$/, schema.nodes.code_block), + + // # heading + textblockTypeInputRule( + new RegExp("^(#{1,6})\\s$"), + schema.nodes.heading, + match => ({ level: match[1].length }) + ) + ] +}; diff --git a/src/client/util/RichTextSchema.tsx b/src/client/util/RichTextSchema.tsx index abf448c9f..3e3e98206 100644 --- a/src/client/util/RichTextSchema.tsx +++ b/src/client/util/RichTextSchema.tsx @@ -1,129 +1,147 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Schema, NodeSpec, MarkSpec, DOMOutputSpecArray } from "prosemirror-model" -import { joinUp, lift, setBlockType, toggleMark, wrapIn } from 'prosemirror-commands' -import { redo, undo } from 'prosemirror-history' -import { orderedList, bulletList, listItem } from 'prosemirror-schema-list' +import { Schema, NodeSpec, MarkSpec, DOMOutputSpecArray, NodeType } from "prosemirror-model"; +import { joinUp, lift, setBlockType, toggleMark, wrapIn } from 'prosemirror-commands'; +import { redo, undo } from 'prosemirror-history'; +import { orderedList, bulletList, listItem, } from 'prosemirror-schema-list'; +import { EditorState, Transaction, NodeSelection, } from "prosemirror-state"; +import { EditorView, } from "prosemirror-view"; const pDOM: DOMOutputSpecArray = ["p", 0], blockquoteDOM: DOMOutputSpecArray = ["blockquote", 0], hrDOM: DOMOutputSpecArray = ["hr"], - preDOM: DOMOutputSpecArray = ["pre", ["code", 0]], brDOM: DOMOutputSpecArray = ["br"], ulDOM: DOMOutputSpecArray = ["ul", 0] + preDOM: DOMOutputSpecArray = ["pre", ["code", 0]], brDOM: DOMOutputSpecArray = ["br"], ulDOM: DOMOutputSpecArray = ["ul", 0]; // :: Object // [Specs](#model.NodeSpec) for the nodes defined in this schema. export const nodes: { [index: string]: NodeSpec } = { - // :: NodeSpec The top level document node. - doc: { - content: "block+" - }, - - // :: NodeSpec A plain paragraph textblock. Represented in the DOM - // as a `<p>` element. - paragraph: { - content: "inline*", - group: "block", - parseDOM: [{ tag: "p" }], - toDOM() { return pDOM } - }, - - // :: NodeSpec A blockquote (`<blockquote>`) wrapping one or more blocks. - blockquote: { - content: "block+", - group: "block", - defining: true, - parseDOM: [{ tag: "blockquote" }], - toDOM() { return blockquoteDOM } - }, - - // :: NodeSpec A horizontal rule (`<hr>`). - horizontal_rule: { - group: "block", - parseDOM: [{ tag: "hr" }], - toDOM() { return hrDOM } - }, - - // :: NodeSpec A heading textblock, with a `level` attribute that - // should hold the number 1 to 6. Parsed and serialized as `<h1>` to - // `<h6>` elements. - heading: { - attrs: { level: { default: 1 } }, - content: "inline*", - group: "block", - defining: true, - parseDOM: [{ tag: "h1", attrs: { level: 1 } }, - { tag: "h2", attrs: { level: 2 } }, - { tag: "h3", attrs: { level: 3 } }, - { tag: "h4", attrs: { level: 4 } }, - { tag: "h5", attrs: { level: 5 } }, - { tag: "h6", attrs: { level: 6 } }], - toDOM(node: any) { return ["h" + node.attrs.level, 0] } - }, - - // :: NodeSpec A code listing. Disallows marks or non-text inline - // nodes by default. Represented as a `<pre>` element with a - // `<code>` element inside of it. - code_block: { - content: "text*", - marks: "", - group: "block", - code: true, - defining: true, - parseDOM: [{ tag: "pre", preserveWhitespace: "full" }], - toDOM() { return preDOM } - }, - - // :: NodeSpec The text node. - text: { - group: "inline" - }, - - // :: NodeSpec An inline image (`<img>`) node. Supports `src`, - // `alt`, and `href` attributes. The latter two default to the empty - // string. - image: { - inline: true, - attrs: { - src: {}, - alt: { default: null }, - title: { default: null } - }, - group: "inline", - draggable: true, - parseDOM: [{ - tag: "img[src]", getAttrs(dom: any) { - return { - src: dom.getAttribute("src"), - title: dom.getAttribute("title"), - alt: dom.getAttribute("alt") + // :: NodeSpec The top level document node. + doc: { + content: "block+" + }, + + // :: NodeSpec A plain paragraph textblock. Represented in the DOM + // as a `<p>` element. + paragraph: { + content: "inline*", + group: "block", + parseDOM: [{ tag: "p" }], + toDOM() { return pDOM; } + }, + + // :: NodeSpec A blockquote (`<blockquote>`) wrapping one or more blocks. + blockquote: { + content: "block+", + group: "block", + defining: true, + parseDOM: [{ tag: "blockquote" }], + toDOM() { return blockquoteDOM; } + }, + + // :: NodeSpec A horizontal rule (`<hr>`). + horizontal_rule: { + group: "block", + parseDOM: [{ tag: "hr" }], + toDOM() { return hrDOM; } + }, + + // :: NodeSpec A heading textblock, with a `level` attribute that + // should hold the number 1 to 6. Parsed and serialized as `<h1>` to + // `<h6>` elements. + heading: { + attrs: { level: { default: 1 } }, + content: "inline*", + group: "block", + defining: true, + parseDOM: [{ tag: "h1", attrs: { level: 1 } }, + { tag: "h2", attrs: { level: 2 } }, + { tag: "h3", attrs: { level: 3 } }, + { tag: "h4", attrs: { level: 4 } }, + { tag: "h5", attrs: { level: 5 } }, + { tag: "h6", attrs: { level: 6 } }], + toDOM(node: any) { return ["h" + node.attrs.level, 0]; } + }, + + // :: NodeSpec A code listing. Disallows marks or non-text inline + // nodes by default. Represented as a `<pre>` element with a + // `<code>` element inside of it. + code_block: { + content: "text*", + marks: "", + group: "block", + code: true, + defining: true, + parseDOM: [{ tag: "pre", preserveWhitespace: "full" }], + toDOM() { return preDOM; } + }, + + // :: NodeSpec The text node. + text: { + group: "inline" + }, + + // :: NodeSpec An inline image (`<img>`) node. Supports `src`, + // `alt`, and `href` attributes. The latter two default to the empty + // string. + image: { + inline: true, + attrs: { + src: {}, + width: { default: "100px" }, + alt: { default: null }, + title: { default: null } + }, + 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, something weird happens: dragging the image will not move it but clone it. Why? + toDOM(node) { + const attrs = { style: `width: ${node.attrs.width}` }; + return ["img", { ...node.attrs, ...attrs }]; } - } - }], - toDOM(node: any) { return ["img", node.attrs] } - }, - - // :: NodeSpec A hard line break, represented in the DOM as `<br>`. - hard_break: { - inline: true, - group: "inline", - selectable: false, - parseDOM: [{ tag: "br" }], - toDOM() { return brDOM } - }, - - ordered_list: { - ...orderedList, - content: 'list_item+', - group: 'block' - }, - bullet_list: { - content: 'list_item+', - group: 'block', - parseDOM: [{ tag: "ul" }, { style: "list-style-type=disc;" }], - toDOM() { return ulDOM } - }, - list_item: { - ...listItem, - content: 'paragraph block*' - } -} + }, + + // :: NodeSpec A hard line break, represented in the DOM as `<br>`. + hard_break: { + inline: true, + group: "inline", + selectable: false, + parseDOM: [{ tag: "br" }], + toDOM() { return brDOM; } + }, + + ordered_list: { + ...orderedList, + content: 'list_item+', + group: 'block' + }, + //this doesn't currently work for some reason + bullet_list: { + ...bulletList, + content: 'list_item+', + group: 'block', + // parseDOM: [{ tag: "ul" }, { style: 'list-style-type=disc' }], + // toDOM() { return ulDOM } + }, + //bullet_list: { + // content: 'list_item+', + // group: 'block', + //active: blockActive(schema.nodes.bullet_list), + //enable: wrapInList(schema.nodes.bullet_list), + //run: wrapInList(schema.nodes.bullet_list), + //select: state => true, + // }, + list_item: { + ...listItem, + content: 'paragraph block*' + } +}; const emDOM: DOMOutputSpecArray = ["em", 0]; const strongDOM: DOMOutputSpecArray = ["strong", 0]; @@ -132,86 +150,263 @@ const underlineDOM: DOMOutputSpecArray = ["underline", 0]; // :: Object [Specs](#model.MarkSpec) for the marks in the schema. export const marks: { [index: string]: MarkSpec } = { - // :: MarkSpec A link. Has `href` and `title` attributes. `title` - // defaults to the empty string. Rendered and parsed as an `<a>` - // element. - link: { - attrs: { - href: {}, - title: { default: null } - }, - inclusive: false, - parseDOM: [{ - tag: "a[href]", getAttrs(dom: any) { - return { href: dom.getAttribute("href"), title: dom.getAttribute("title") } - } - }], - toDOM(node: any) { return ["a", node.attrs, 0] } - }, - - // :: MarkSpec An emphasis mark. Rendered as an `<em>` element. - // Has parse rules that also match `<i>` and `font-style: italic`. - em: { - parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style=italic" }], - toDOM() { return emDOM } - }, - - // :: MarkSpec A strong mark. Rendered as `<strong>`, parse rules - // also match `<b>` and `font-weight: bold`. - strong: { - parseDOM: [{ tag: "strong" }, - { tag: "b" }, - { style: "font-weight" }], - toDOM() { return strongDOM } - }, - - underline: { - parseDOM: [ - { tag: 'u' }, - { style: 'text-decoration=underline' } - ], - toDOM: () => ['span', { - style: 'text-decoration:underline' - }] - }, - - strikethrough: { - parseDOM: [ - { tag: 'strike' }, - { style: 'text-decoration=line-through' }, - { style: 'text-decoration-line=line-through' } - ], - toDOM: () => ['span', { - style: 'text-decoration-line:line-through' - }] - }, - - subscript: { - excludes: 'superscript', - parseDOM: [ - { tag: 'sub' }, - { style: 'vertical-align=sub' } - ], - toDOM: () => ['sub'] - }, - - superscript: { - excludes: 'subscript', - parseDOM: [ - { tag: 'sup' }, - { style: 'vertical-align=super' } - ], - toDOM: () => ['sup'] - }, - - - // :: MarkSpec Code font mark. Represented as a `<code>` element. - code: { - parseDOM: [{ tag: "code" }], - toDOM() { return codeDOM } - } + // :: MarkSpec A link. Has `href` and `title` attributes. `title` + // defaults to the empty string. Rendered and parsed as an `<a>` + // element. + link: { + attrs: { + href: {}, + title: { default: null } + }, + inclusive: false, + parseDOM: [{ + tag: "a[href]", getAttrs(dom: any) { + return { href: dom.getAttribute("href"), title: dom.getAttribute("title") }; + } + }], + toDOM(node: any) { return ["a", node.attrs, 0]; } + }, + + // :: MarkSpec An emphasis mark. Rendered as an `<em>` element. + // Has parse rules that also match `<i>` and `font-style: italic`. + em: { + parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style=italic" }], + toDOM() { return emDOM; } + }, + + // :: MarkSpec A strong mark. Rendered as `<strong>`, parse rules + // also match `<b>` and `font-weight: bold`. + strong: { + parseDOM: [{ tag: "strong" }, + { tag: "b" }, + { style: "font-weight" }], + toDOM() { return strongDOM; } + }, + + underline: { + parseDOM: [ + { tag: 'u' }, + { style: 'text-decoration=underline' } + ], + toDOM: () => ['span', { + style: 'text-decoration:underline' + }] + }, + + strikethrough: { + parseDOM: [ + { tag: 'strike' }, + { style: 'text-decoration=line-through' }, + { style: 'text-decoration-line=line-through' } + ], + toDOM: () => ['span', { + style: 'text-decoration-line:line-through' + }] + }, + + subscript: { + excludes: 'superscript', + parseDOM: [ + { tag: 'sub' }, + { style: 'vertical-align=sub' } + ], + toDOM: () => ['sub'] + }, + + superscript: { + excludes: 'subscript', + parseDOM: [ + { tag: 'sup' }, + { style: 'vertical-align=super' } + ], + toDOM: () => ['sup'] + }, + + + // :: MarkSpec Code font mark. Represented as a `<code>` element. + code: { + parseDOM: [{ tag: "code" }], + toDOM() { return codeDOM; } + }, + + + /* FONTS */ + timesNewRoman: { + parseDOM: [{ style: 'font-family: "Times New Roman", Times, serif;' }], + toDOM: () => ['span', { + style: 'font-family: "Times New Roman", Times, serif;' + }] + }, + + arial: { + parseDOM: [{ style: 'font-family: Arial, Helvetica, sans-serif;' }], + toDOM: () => ['span', { + style: 'font-family: Arial, Helvetica, sans-serif;' + }] + }, + + georgia: { + parseDOM: [{ style: 'font-family: Georgia, serif;' }], + toDOM: () => ['span', { + style: 'font-family: Georgia, serif;' + }] + }, + + comicSans: { + parseDOM: [{ style: 'font-family: "Comic Sans MS", cursive, sans-serif;' }], + toDOM: () => ['span', { + style: 'font-family: "Comic Sans MS", cursive, sans-serif;' + }] + }, + + tahoma: { + parseDOM: [{ style: 'font-family: Tahoma, Geneva, sans-serif;' }], + toDOM: () => ['span', { + style: 'font-family: Tahoma, Geneva, sans-serif;' + }] + }, + + impact: { + parseDOM: [{ style: 'font-family: Impact, Charcoal, sans-serif;' }], + toDOM: () => ['span', { + style: 'font-family: Impact, Charcoal, sans-serif;' + }] + }, + + crimson: { + parseDOM: [{ style: 'font-family: "Crimson Text", sans-serif;' }], + toDOM: () => ['span', { + style: 'font-family: "Crimson Text", sans-serif;' + }] + }, + + /** FONT SIZES */ + + p10: { + parseDOM: [{ style: 'font-size: 10px;' }], + toDOM: () => ['span', { + style: 'font-size: 10px;' + }] + }, + + p12: { + parseDOM: [{ style: 'font-size: 12px;' }], + toDOM: () => ['span', { + style: 'font-size: 12px;' + }] + }, + + p14: { + parseDOM: [{ style: 'font-size: 14px;' }], + toDOM: () => ['span', { + style: 'font-size: 14px;' + }] + }, + + p16: { + parseDOM: [{ style: 'font-size: 16px;' }], + toDOM: () => ['span', { + style: 'font-size: 16px;' + }] + }, + + p24: { + parseDOM: [{ style: 'font-size: 24px;' }], + toDOM: () => ['span', { + style: 'font-size: 24px;' + }] + }, + + p32: { + parseDOM: [{ style: 'font-size: 32px;' }], + toDOM: () => ['span', { + style: 'font-size: 32px;' + }] + }, + + p48: { + parseDOM: [{ style: 'font-size: 48px;' }], + toDOM: () => ['span', { + style: 'font-size: 48px;' + }] + }, + + p72: { + parseDOM: [{ style: 'font-size: 72px;' }], + toDOM: () => ['span', { + style: 'font-size: 72px;' + }] + }, +}; +function getFontSize(element: any) { + return parseFloat((getComputedStyle(element) as any).fontSize); } +export class ImageResizeView { + _handle: HTMLElement; + _img: HTMLElement; + _outer: HTMLElement; + constructor(node: any, view: any, getPos: any) { + this._handle = document.createElement("span"); + this._img = document.createElement("img"); + this._outer = document.createElement("span"); + this._outer.style.position = "relative"; + this._outer.style.width = node.attrs.width; + this._outer.style.display = "inline-block"; + this._outer.style.overflow = "hidden"; + + this._img.setAttribute("src", node.attrs.src); + this._img.style.width = "100%"; + this._handle.style.position = "absolute"; + this._handle.style.width = "20px"; + this._handle.style.height = "20px"; + this._handle.style.backgroundColor = "blue"; + this._handle.style.borderRadius = "15px"; + this._handle.style.display = "none"; + this._handle.style.bottom = "-10px"; + this._handle.style.right = "-10px"; + let self = this; + this._handle.onpointerdown = function (e: any) { + e.preventDefault(); + e.stopPropagation(); + const startX = e.pageX; + const startWidth = parseFloat(node.attrs.width); + const onpointermove = (e: any) => { + const currentX = e.pageX; + const diffInPx = currentX - startX; + self._outer.style.width = `${startWidth + diffInPx}`; + }; + + const onpointerup = () => { + document.removeEventListener("pointermove", onpointermove); + document.removeEventListener("pointerup", onpointerup); + view.dispatch( + view.state.tr.setNodeMarkup(getPos(), null, + { src: node.attrs.src, width: self._outer.style.width }) + .setSelection(view.state.selection)); + }; + + document.addEventListener("pointermove", onpointermove); + document.addEventListener("pointerup", onpointerup); + }; + + this._outer.appendChild(this._handle); + this._outer.appendChild(this._img); + (this as any).dom = this._outer; + } + + selectNode() { + this._img.classList.add("ProseMirror-selectednode"); + + this._handle.style.display = ""; + } + + deselectNode() { + this._img.classList.remove("ProseMirror-selectednode"); + + this._handle.style.display = "none"; + } +} // :: Schema // This schema rougly corresponds to the document schema used by // [CommonMark](http://commonmark.org/), minus the list elements, @@ -220,4 +415,4 @@ export const marks: { [index: string]: MarkSpec } = { // // To reuse elements from this schema, extend or read from its // `spec.nodes` and `spec.marks` [properties](#model.Schema.spec). -export const schema = new Schema({ nodes, marks })
\ No newline at end of file +export const schema = new Schema({ nodes, marks });
\ No newline at end of file diff --git a/src/client/util/Scripting.ts b/src/client/util/Scripting.ts index 46bd1a206..e45f61c11 100644 --- a/src/client/util/Scripting.ts +++ b/src/client/util/Scripting.ts @@ -1,56 +1,76 @@ // import * as ts from "typescript" let ts = (window as any).ts; -import { Opt, Field } from "../../fields/Field"; -import { Document } from "../../fields/Document"; -import { NumberField } from "../../fields/NumberField"; -import { ImageField } from "../../fields/ImageField"; -import { TextField } from "../../fields/TextField"; -import { RichTextField } from "../../fields/RichTextField"; -import { KeyStore } from "../../fields/KeyStore"; -import { ListField } from "../../fields/ListField"; // // @ts-ignore // import * as typescriptlib from '!!raw-loader!../../../node_modules/typescript/lib/lib.d.ts' // // @ts-ignore // import * as typescriptes5 from '!!raw-loader!../../../node_modules/typescript/lib/lib.es5.d.ts' // @ts-ignore -import * as typescriptlib from '!!raw-loader!./type_decls.d' +import * as typescriptlib from '!!raw-loader!./type_decls.d'; +import { Docs } from "../documents/Documents"; +import { Doc, Field } from '../../new_fields/Doc'; +import { ImageField, PdfField, VideoField, AudioField } from '../../new_fields/URLField'; +import { List } from '../../new_fields/List'; +import { RichTextField } from '../../new_fields/RichTextField'; +export interface ScriptSucccess { + success: true; + result: any; +} + +export interface ScriptError { + success: false; + error: any; +} -export interface ExecutableScript { - (): any; +export type ScriptResult = ScriptSucccess | ScriptError; + +export interface CompiledScript { + readonly compiled: true; + readonly originalScript: string; + readonly options: Readonly<ScriptOptions>; + run(args?: { [name: string]: any }): ScriptResult; +} - compiled: boolean; +export interface CompileError { + compiled: false; + errors: any[]; } -function Compile(script: string | undefined, diagnostics: Opt<any[]>, scope: { [name: string]: any }): ExecutableScript { - const compiled = !(diagnostics && diagnostics.some(diag => diag.category == ts.DiagnosticCategory.Error)); +export type CompileResult = CompiledScript | CompileError; - let func: () => Opt<Field>; - if (compiled && script) { - let fieldTypes = [Document, NumberField, TextField, ImageField, RichTextField, ListField]; - let paramNames = ["KeyStore", ...fieldTypes.map(fn => fn.name)]; - let params: any[] = [KeyStore, ...fieldTypes] - for (let prop in scope) { - if (prop === "this") { +function Run(script: string | undefined, customParams: string[], diagnostics: any[], originalScript: string, options: ScriptOptions): CompileResult { + const errors = diagnostics.some(diag => diag.category === ts.DiagnosticCategory.Error); + if (errors || !script) { + return { compiled: false, errors: diagnostics }; + } + + let fieldTypes = [Doc, ImageField, PdfField, VideoField, AudioField, List, RichTextField]; + let paramNames = ["Docs", ...fieldTypes.map(fn => fn.name)]; + let params: any[] = [Docs, ...fieldTypes]; + let compiledFunction = new Function(...paramNames, `return ${script}`); + let { capturedVariables = {} } = options; + let run = (args: { [name: string]: any } = {}): ScriptResult => { + let argsArray: any[] = []; + for (let name of customParams) { + if (name === "this") { continue; } - paramNames.push(prop); - params.push(scope[prop]); + if (name in args) { + argsArray.push(args[name]); + } else { + argsArray.push(capturedVariables[name]); + } } - let thisParam = scope["this"]; - let compiledFunction = new Function(...paramNames, script); - func = function (): Opt<Field> { - return compiledFunction.apply(thisParam, params) - }; - } else { - func = () => undefined; - } - - return Object.assign(func, - { - compiled - }); + let thisParam = args.this || capturedVariables.this; + try { + const result = compiledFunction.apply(thisParam, params).apply(thisParam, argsArray); + return { success: true, result }; + } catch (error) { + return { success: false, error }; + } + }; + return { compiled: true, run, originalScript, options }; } interface File { @@ -72,14 +92,14 @@ class ScriptingCompilerHost { } // getDefaultLibFileName(options: ts.CompilerOptions): string { getDefaultLibFileName(options: any): string { - return 'node_modules/typescript/lib/lib.d.ts' // No idea what this means... + return 'node_modules/typescript/lib/lib.d.ts'; // No idea what this means... } writeFile(fileName: string, content: string) { const file = this.files.find(file => file.fileName === fileName); if (file) { file.content = content; } else { - this.files.push({ fileName, content }) + this.files.push({ fileName, content }); } } getCurrentDirectory(): string { @@ -106,27 +126,44 @@ class ScriptingCompilerHost { } } -export function CompileScript(script: string, scope?: { [name: string]: any }, addReturn: boolean = false): ExecutableScript { +export interface ScriptOptions { + requiredType?: string; + addReturn?: boolean; + params?: { [name: string]: string }; + capturedVariables?: { [name: string]: Field }; +} + +export function CompileScript(script: string, options: ScriptOptions = {}): CompileResult { + const { requiredType = "", addReturn = false, params = {}, capturedVariables = {} } = options; let host = new ScriptingCompilerHost; - let funcScript = `(function() { + let paramNames: string[] = []; + if ("this" in params || "this" in capturedVariables) { + paramNames.push("this"); + } + for (const key in params) { + if (key === "this") continue; + paramNames.push(key); + } + let paramList = paramNames.map(key => { + const val = params[key]; + return `${key}: ${val}`; + }); + for (const key in capturedVariables) { + if (key === "this") continue; + paramNames.push(key); + paramList.push(`${key}: ${capturedVariables[key].constructor.name}`); + } + let paramString = paramList.join(", "); + let funcScript = `(function(${paramString})${requiredType ? `: ${requiredType}` : ''} { ${addReturn ? `return ${script};` : script} - })()` + })`; host.writeFile("file.ts", funcScript); host.writeFile('node_modules/typescript/lib/lib.d.ts', typescriptlib); let program = ts.createProgram(["file.ts"], {}, host); let testResult = program.emit(); - let outputText = "return " + host.readFile("file.js"); + let outputText = host.readFile("file.js"); let diagnostics = ts.getPreEmitDiagnostics(program).concat(testResult.diagnostics); - return Compile(outputText, diagnostics, scope || {}); -} - -export function ToField(data: any): Opt<Field> { - if (typeof data == "string") { - return new TextField(data); - } else if (typeof data == "number") { - return new NumberField(data); - } - return undefined; + return Run(outputText, paramNames, diagnostics, script, options); }
\ No newline at end of file diff --git a/src/client/util/ScrollBox.tsx b/src/client/util/ScrollBox.tsx index b6b088170..a209874a3 100644 --- a/src/client/util/ScrollBox.tsx +++ b/src/client/util/ScrollBox.tsx @@ -1,4 +1,4 @@ -import React = require("react") +import React = require("react"); export class ScrollBox extends React.Component { onWheel = (e: React.WheelEvent) => { @@ -16,6 +16,6 @@ export class ScrollBox extends React.Component { }} onWheel={this.onWheel}> {this.props.children} </div> - ) + ); } }
\ No newline at end of file diff --git a/src/client/util/SearchUtil.ts b/src/client/util/SearchUtil.ts new file mode 100644 index 000000000..4ccff0d1b --- /dev/null +++ b/src/client/util/SearchUtil.ts @@ -0,0 +1,25 @@ +import * as rp from 'request-promise'; +import { DocServer } from '../DocServer'; +import { Doc } from '../../new_fields/Doc'; +import { Id } from '../../new_fields/RefField'; + +export namespace SearchUtil { + export function Search(query: string, returnDocs: true): Promise<Doc[]>; + export function Search(query: string, returnDocs: false): Promise<string[]>; + export async function Search(query: string, returnDocs: boolean) { + const ids = JSON.parse(await rp.get(DocServer.prepend("/search"), { + qs: { query } + })); + if (!returnDocs) { + return ids; + } + const docMap = await DocServer.GetRefFields(ids); + return ids.map((id: string) => docMap[id]).filter((doc: any) => doc instanceof Doc); + } + + export async function GetAliasesOfDocument(doc: Doc): Promise<Doc[]> { + const proto = await Doc.GetT(doc, "proto", Doc, true); + const protoId = (proto || doc)[Id]; + return Search(`{!join from=id to=proto_i}id:${protoId}`, true); + } +}
\ No newline at end of file diff --git a/src/client/util/SelectionManager.ts b/src/client/util/SelectionManager.ts index 1a711ae64..8c92c2023 100644 --- a/src/client/util/SelectionManager.ts +++ b/src/client/util/SelectionManager.ts @@ -1,5 +1,8 @@ import { observable, action } from "mobx"; +import { Doc } from "../../new_fields/Doc"; import { DocumentView } from "../views/nodes/DocumentView"; +import { FormattedTextBox } from "../views/nodes/FormattedTextBox"; +import { NumCast } from "../../new_fields/Types"; export namespace SelectionManager { class Manager { @@ -10,30 +13,76 @@ export namespace SelectionManager { SelectDoc(doc: DocumentView, ctrlPressed: boolean): void { // if doc is not in SelectedDocuments, add it if (!ctrlPressed) { - manager.SelectedDocuments = []; + this.DeselectAll(); } if (manager.SelectedDocuments.indexOf(doc) === -1) { - manager.SelectedDocuments.push(doc) + manager.SelectedDocuments.push(doc); + doc.props.whenActiveChanged(true); } } + + @action + DeselectAll(): void { + manager.SelectedDocuments.map(dv => dv.props.whenActiveChanged(false)); + manager.SelectedDocuments = []; + FormattedTextBox.InputBoxOverlay = undefined; + } + @action + ReselectAll() { + let sdocs = manager.SelectedDocuments.map(d => d); + manager.SelectedDocuments = []; + return sdocs; + } + @action + ReselectAll2(sdocs: DocumentView[]) { + sdocs.map(s => SelectionManager.SelectDoc(s, true)); + } } - const manager = new Manager; + const manager = new Manager(); export function SelectDoc(doc: DocumentView, ctrlPressed: boolean): void { - manager.SelectDoc(doc, ctrlPressed) + manager.SelectDoc(doc, ctrlPressed); } export function IsSelected(doc: DocumentView): boolean { return manager.SelectedDocuments.indexOf(doc) !== -1; } - export function DeselectAll(): void { - manager.SelectedDocuments = [] + export function DeselectAll(except?: Doc): void { + let found: DocumentView | undefined = undefined; + if (except) { + for (const view of manager.SelectedDocuments) { + if (view.props.Document === except) found = view; + } + } + + manager.DeselectAll(); + if (found) manager.SelectDoc(found, false); } + export function ReselectAll() { + let sdocs = manager.ReselectAll(); + setTimeout(() => manager.ReselectAll2(sdocs), 0); + } export function SelectedDocuments(): Array<DocumentView> { return manager.SelectedDocuments; } -}
\ No newline at end of file + export function ViewsSortedVertically(): DocumentView[] { + let sorted = SelectionManager.SelectedDocuments().slice().sort((doc1, doc2) => { + if (NumCast(doc1.props.Document.x) > NumCast(doc2.props.Document.x)) return 1; + if (NumCast(doc1.props.Document.x) < NumCast(doc2.props.Document.x)) return -1; + return 0; + }); + return sorted; + } + export function ViewsSortedHorizontally(): DocumentView[] { + let sorted = SelectionManager.SelectedDocuments().slice().sort((doc1, doc2) => { + if (NumCast(doc1.props.Document.y) > NumCast(doc2.props.Document.y)) return 1; + if (NumCast(doc1.props.Document.y) < NumCast(doc2.props.Document.y)) return -1; + return 0; + }); + return sorted; + } +} diff --git a/src/client/util/SerializationHelper.ts b/src/client/util/SerializationHelper.ts new file mode 100644 index 000000000..7ded85e43 --- /dev/null +++ b/src/client/util/SerializationHelper.ts @@ -0,0 +1,130 @@ +import { PropSchema, serialize, deserialize, custom, setDefaultModelSchema, getDefaultModelSchema, primitive, SKIP } from "serializr"; +import { Field } from "../../new_fields/Doc"; + +export namespace SerializationHelper { + let serializing: number = 0; + export function IsSerializing() { + return serializing > 0; + } + + export function Serialize(obj: Field): any { + if (obj === undefined || obj === null) { + return undefined; + } + + if (typeof obj !== 'object') { + return obj; + } + + serializing += 1; + if (!(obj.constructor.name in reverseMap)) { + throw Error(`type '${obj.constructor.name}' not registered. Make sure you register it using a @Deserializable decorator`); + } + + const json = serialize(obj); + json.__type = reverseMap[obj.constructor.name]; + serializing -= 1; + return json; + } + + export function Deserialize(obj: any): any { + if (obj === undefined || obj === null) { + return undefined; + } + + if (typeof obj !== 'object') { + return obj; + } + + serializing += 1; + if (!obj.__type) { + throw Error("No property 'type' found in JSON."); + } + + if (!(obj.__type in serializationTypes)) { + throw Error(`type '${obj.__type}' not registered. Make sure you register it using a @Deserializable decorator`); + } + + const value = deserialize(serializationTypes[obj.__type], obj); + serializing -= 1; + return value; + } +} + +let serializationTypes: { [name: string]: any } = {}; +let reverseMap: { [ctor: string]: string } = {}; + +export interface DeserializableOpts { + (constructor: { new(...args: any[]): any }): void; + withFields(fields: string[]): Function; +} + +export function Deserializable(name: string): DeserializableOpts; +export function Deserializable(constructor: { new(...args: any[]): any }): void; +export function Deserializable(constructor: { new(...args: any[]): any } | string): DeserializableOpts | void { + function addToMap(name: string, ctor: { new(...args: any[]): any }) { + const schema = getDefaultModelSchema(ctor) as any; + if (schema.targetClass !== ctor) { + const newSchema = { ...schema, factory: () => new ctor() }; + setDefaultModelSchema(ctor, newSchema); + } + if (!(name in serializationTypes)) { + serializationTypes[name] = ctor; + reverseMap[ctor.name] = name; + } else { + throw new Error(`Name ${name} has already been registered as deserializable`); + } + } + if (typeof constructor === "string") { + return Object.assign((ctor: { new(...args: any[]): any }) => { + addToMap(constructor, ctor); + }, { withFields: Deserializable.withFields }); + } + addToMap(constructor.name, constructor); +} + +export namespace Deserializable { + export function withFields(fields: string[]) { + return function (constructor: { new(...fields: any[]): any }) { + Deserializable(constructor); + let schema = getDefaultModelSchema(constructor); + if (schema) { + schema.factory = context => { + const args = fields.map(key => context.json[key]); + return new constructor(...args); + }; + // TODO A modified version of this would let us not reassign fields that we're passing into the constructor later on in deserializing + // fields.forEach(field => { + // if (field in schema.props) { + // let propSchema = schema.props[field]; + // if (propSchema === false) { + // return; + // } else if (propSchema === true) { + // propSchema = primitive(); + // } + // schema.props[field] = custom(propSchema.serializer, + // () => { + // return SKIP; + // }); + // } + // }); + } else { + schema = { + props: {}, + factory: context => { + const args = fields.map(key => context.json[key]); + return new constructor(...args); + } + }; + setDefaultModelSchema(constructor, schema); + } + }; + } +} + +export function autoObject(): PropSchema { + return custom( + (s) => SerializationHelper.Serialize(s), + (s) => SerializationHelper.Deserialize(s) + ); +}
\ No newline at end of file diff --git a/src/client/util/TooltipLinkingMenu.tsx b/src/client/util/TooltipLinkingMenu.tsx new file mode 100644 index 000000000..55e0eb909 --- /dev/null +++ b/src/client/util/TooltipLinkingMenu.tsx @@ -0,0 +1,86 @@ +import { action, IReactionDisposer, reaction } from "mobx"; +import { Dropdown, DropdownSubmenu, MenuItem, MenuItemSpec, renderGrouped, icons, } from "prosemirror-menu"; //no import css +import { baseKeymap, lift } from "prosemirror-commands"; +import { history, redo, undo } from "prosemirror-history"; +import { keymap } from "prosemirror-keymap"; +import { EditorState, Transaction, NodeSelection, TextSelection } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import { schema } from "./RichTextSchema"; +import { Schema, NodeType, MarkType } from "prosemirror-model"; +import React = require("react"); +import "./TooltipTextMenu.scss"; +const { toggleMark, setBlockType, wrapIn } = require("prosemirror-commands"); +import { library } from '@fortawesome/fontawesome-svg-core'; +import { wrapInList, bulletList, liftListItem, listItem } from 'prosemirror-schema-list'; +import { + faListUl, +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { FieldViewProps } from "../views/nodes/FieldView"; +import { throwStatement } from "babel-types"; + +const SVG = "http://www.w3.org/2000/svg"; + +//appears above a selection of text in a RichTextBox to give user options such as Bold, Italics, etc. +export class TooltipLinkingMenu { + + private tooltip: HTMLElement; + private view: EditorView; + private editorProps: FieldViewProps; + + constructor(view: EditorView, editorProps: FieldViewProps) { + this.view = view; + this.editorProps = editorProps; + this.tooltip = document.createElement("div"); + this.tooltip.className = "tooltipMenu linking"; + + //add the div which is the tooltip + view.dom.parentNode!.parentNode!.appendChild(this.tooltip); + + let target = "https://www.google.com"; + + let link = document.createElement("a"); + link.href = target; + link.textContent = target; + link.target = "_blank"; + link.style.color = "white"; + this.tooltip.appendChild(link); + + this.update(view, undefined); + } + + //updates the tooltip menu when the selection changes + update(view: EditorView, lastState: EditorState | undefined) { + let state = view.state; + // Don't do anything if the document/selection didn't change + if (lastState && lastState.doc.eq(state.doc) && + lastState.selection.eq(state.selection)) return; + + // Hide the tooltip if the selection is empty + if (state.selection.empty) { + this.tooltip.style.display = "none"; + return; + } + + console.log("STORED:"); + console.log(state.doc.content.firstChild!.content); + + // Otherwise, reposition it and update its content + this.tooltip.style.display = ""; + let { from, to } = state.selection; + let start = view.coordsAtPos(from), end = view.coordsAtPos(to); + // The box in which the tooltip is positioned, to use as base + let box = this.tooltip.offsetParent!.getBoundingClientRect(); + // Find a center-ish x position from the selection endpoints (when + // crossing lines, end may be more to the left) + let left = Math.max((start.left + end.left) / 2, start.left + 3); + this.tooltip.style.left = (left - box.left) * this.editorProps.ScreenToLocalTransform().Scale + "px"; + let width = Math.abs(start.left - end.left) / 2 * this.editorProps.ScreenToLocalTransform().Scale; + let mid = Math.min(start.left, end.left) + width; + + this.tooltip.style.width = "auto"; + this.tooltip.style.bottom = (box.bottom - start.top) * this.editorProps.ScreenToLocalTransform().Scale + "px"; + } + + destroy() { this.tooltip.remove(); } +} diff --git a/src/client/util/TooltipTextMenu.scss b/src/client/util/TooltipTextMenu.scss index fa43f5326..437da0d63 100644 --- a/src/client/util/TooltipTextMenu.scss +++ b/src/client/util/TooltipTextMenu.scss @@ -1,14 +1,260 @@ +@import "../views/globalCssVariables"; + +.ProseMirror-textblock-dropdown { + min-width: 3em; + } + + .ProseMirror-menu { + margin: 0 -4px; + line-height: 1; + } + + .ProseMirror-tooltip .ProseMirror-menu { + width: -webkit-fit-content; + width: fit-content; + white-space: pre; + } + + .ProseMirror-menuitem { + margin-right: 3px; + display: inline-block; + } + + .ProseMirror-menuseparator { + // border-right: 1px solid #ddd; + margin-right: 3px; + } + + .ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu { + font-size: 90%; + white-space: nowrap; + } + + .ProseMirror-menu-dropdown { + vertical-align: 1px; + cursor: pointer; + position: relative; + padding-right: 15px; + margin: 3px; + background: #333333; + border-radius: 3px; + text-align: center; + } + + .ProseMirror-menu-dropdown-wrap { + padding: 1px 0 1px 4px; + display: inline-block; + position: relative; + } + + .ProseMirror-menu-dropdown:after { + content: ""; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid currentColor; + opacity: .6; + position: absolute; + right: 4px; + top: calc(50% - 2px); + } + + .ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu { + position: absolute; + background: $dark-color; + color:white; + border: 1px solid rgb(223, 223, 223); + padding: 2px; + } + + .ProseMirror-menu-dropdown-menu { + z-index: 15; + min-width: 6em; + } + + .linking { + text-align: center; + } + + .ProseMirror-menu-dropdown-item { + cursor: pointer; + padding: 2px 8px 2px 4px; + width: auto; + } + + .ProseMirror-menu-dropdown-item:hover { + background: #2e2b2b; + } + + .ProseMirror-menu-submenu-wrap { + position: relative; + margin-right: -4px; + } + + .ProseMirror-menu-submenu-label:after { + content: ""; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 4px solid currentColor; + opacity: .6; + position: absolute; + right: 4px; + top: calc(50% - 4px); + } + + .ProseMirror-menu-submenu { + display: none; + min-width: 4em; + left: 100%; + top: -3px; + } + + .ProseMirror-menu-active { + background: #eee; + border-radius: 4px; + } + + .ProseMirror-menu-active { + background: #eee; + border-radius: 4px; + } + + .ProseMirror-menu-disabled { + opacity: .3; + } + + .ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu { + display: block; + } + + .ProseMirror-menubar { + border-top-left-radius: inherit; + border-top-right-radius: inherit; + position: relative; + min-height: 1em; + color: white; + padding: 1px 6px; + top: 0; left: 0; right: 0; + border-bottom: 1px solid silver; + background:$dark-color; + z-index: 10; + -moz-box-sizing: border-box; + box-sizing: border-box; + overflow: visible; + } + + .ProseMirror-icon { + display: inline-block; + line-height: .8; + vertical-align: -2px; /* Compensate for padding */ + padding: 2px 8px; + cursor: pointer; + } + + .ProseMirror-menu-disabled.ProseMirror-icon { + cursor: default; + } + + .ProseMirror-icon svg { + fill: currentColor; + height: 1em; + } + + .ProseMirror-icon span { + vertical-align: text-top; + } + + .ProseMirror ul, .ProseMirror ol { + padding-left: 30px; + } + + .ProseMirror blockquote { + padding-left: 1em; + border-left: 3px solid #eee; + margin-left: 0; margin-right: 0; + } + + .ProseMirror-example-setup-style img { + cursor: default; + } + + .ProseMirror-prompt { + background: white; + padding: 5px 10px 5px 15px; + border: 1px solid silver; + position: fixed; + border-radius: 3px; + z-index: 11; + box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2); + } + + .ProseMirror-prompt h5 { + margin: 0; + font-weight: normal; + font-size: 100%; + color: #444; + } + + .ProseMirror-prompt input[type="text"], + .ProseMirror-prompt textarea { + background: #eee; + border: none; + outline: none; + } + + .ProseMirror-prompt input[type="text"] { + padding: 0 4px; + } + + .ProseMirror-prompt-close { + position: absolute; + left: 2px; top: 1px; + color: #666; + border: none; background: transparent; padding: 0; + } + + .ProseMirror-prompt-close:after { + content: "✕"; + font-size: 12px; + } + + .ProseMirror-invalid { + background: #ffc; + border: 1px solid #cc7; + border-radius: 4px; + padding: 5px 10px; + position: absolute; + min-width: 10em; + } + + .ProseMirror-prompt-buttons { + margin-top: 5px; + display: none; + } .tooltipMenu { position: absolute; - z-index: 20; - background: rgb(19, 18, 18); + z-index: 200; + background: $dark-color; border: 1px solid silver; border-radius: 4px; padding: 2px 10px; margin-bottom: 7px; -webkit-transform: translateX(-50%); transform: translateX(-50%); + pointer-events: all; + .ProseMirror-example-setup-style hr { + padding: 2px 10px; + border: none; + margin: 1em 0; + } + + .ProseMirror-example-setup-style hr:after { + content: ""; + display: block; + height: 1px; + background-color: silver; + line-height: 2px; + } } .tooltipMenu:before { @@ -31,7 +277,7 @@ bottom: -4.5px; border: 5px solid transparent; border-bottom-width: 0; - border-top-color: black; + border-top-color: $dark-color; } .menuicon { @@ -51,4 +297,8 @@ .underline {text-decoration: underline} .superscript {vertical-align:super} .subscript { vertical-align:sub } - .strikethrough {text-decoration-line:line-through}
\ No newline at end of file + .strikethrough {text-decoration-line:line-through} + .font-size-indicator { + font-size: 12px; + padding-right: 0px; + } diff --git a/src/client/util/TooltipTextMenu.tsx b/src/client/util/TooltipTextMenu.tsx index 3b87fe9de..4d40d09b2 100644 --- a/src/client/util/TooltipTextMenu.tsx +++ b/src/client/util/TooltipTextMenu.tsx @@ -1,125 +1,478 @@ import { action, IReactionDisposer, reaction } from "mobx"; -import { baseKeymap } from "prosemirror-commands"; +import { Dropdown, DropdownSubmenu, MenuItem, MenuItemSpec, renderGrouped, icons, } from "prosemirror-menu"; //no import css +import { baseKeymap, lift } from "prosemirror-commands"; import { history, redo, undo } from "prosemirror-history"; import { keymap } from "prosemirror-keymap"; -const { exampleSetup } = require("prosemirror-example-setup") -import { EditorState, Transaction, } from "prosemirror-state"; +import { EditorState, Transaction, NodeSelection, TextSelection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { schema } from "./RichTextSchema"; -import React = require("react") +import { Schema, NodeType, MarkType, Mark } from "prosemirror-model"; +import React = require("react"); import "./TooltipTextMenu.scss"; const { toggleMark, setBlockType, wrapIn } = require("prosemirror-commands"); -import { library } from '@fortawesome/fontawesome-svg-core' -import { wrapInList, bulletList } from 'prosemirror-schema-list' +import { library } from '@fortawesome/fontawesome-svg-core'; +import { wrapInList, bulletList, liftListItem, listItem, } from 'prosemirror-schema-list'; +import { liftTarget, RemoveMarkStep, AddMarkStep } from 'prosemirror-transform'; import { - faListUl, + faListUl, } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { FieldViewProps } from "../views/nodes/FieldView"; +import { throwStatement } from "babel-types"; +import { View } from "@react-pdf/renderer"; +import { DragManager } from "./DragManager"; +import { Doc, Opt, Field } from "../../new_fields/Doc"; +import { Id } from "../../new_fields/RefField"; +import { Utils } from "../northstar/utils/Utils"; +import { DocServer } from "../DocServer"; +import { CollectionFreeFormDocumentView } from "../views/nodes/CollectionFreeFormDocumentView"; +import { CollectionDockingView } from "../views/collections/CollectionDockingView"; +import { DocumentManager } from "./DocumentManager"; +const SVG = "http://www.w3.org/2000/svg"; - +//appears above a selection of text in a RichTextBox to give user options such as Bold, Italics, etc. export class TooltipTextMenu { - private tooltip: HTMLElement; - - constructor(view: EditorView) { - this.tooltip = document.createElement("div"); - this.tooltip.className = "tooltipMenu"; - - //add the div which is the tooltip - view.dom.parentNode!.appendChild(this.tooltip); - - //add additional icons - library.add(faListUl); - - //add the buttons to the tooltip - let items = [ - { command: toggleMark(schema.marks.strong), dom: this.icon("B", "strong") }, - { command: toggleMark(schema.marks.em), dom: this.icon("i", "em") }, - { command: toggleMark(schema.marks.underline), dom: this.icon("U", "underline") }, - { command: toggleMark(schema.marks.strikethrough), dom: this.icon("S", "strikethrough") }, - { command: toggleMark(schema.marks.superscript), dom: this.icon("s", "superscript") }, - { command: toggleMark(schema.marks.subscript), dom: this.icon("s", "subscript") }, - { command: wrapInList(schema.nodes.bullet_list), dom: this.icon(":", "bullets") } - ] - items.forEach(({ dom }) => this.tooltip.appendChild(dom)); - - //pointer down handler to activate button effects - this.tooltip.addEventListener("pointerdown", e => { - e.preventDefault(); - view.focus(); - items.forEach(({ command, dom }) => { - if (dom.contains(e.srcElement)) { - command(view.state, view.dispatch, view) + public tooltip: HTMLElement; + private num_icons = 0; + private view: EditorView; + private fontStyles: MarkType[]; + private fontSizes: MarkType[]; + private listTypes: NodeType[]; + private editorProps: FieldViewProps; + private state: EditorState; + private fontSizeToNum: Map<MarkType, number>; + private fontStylesToName: Map<MarkType, string>; + private listTypeToIcon: Map<NodeType, string>; + private fontSizeIndicator: HTMLSpanElement = document.createElement("span"); + private linkEditor?: HTMLDivElement; + private linkText?: HTMLDivElement; + private linkDrag?: HTMLImageElement; + //dropdown doms + private fontSizeDom?: Node; + private fontStyleDom?: Node; + private listTypeBtnDom?: Node; + + constructor(view: EditorView, editorProps: FieldViewProps) { + this.view = view; + this.state = view.state; + this.editorProps = editorProps; + this.tooltip = document.createElement("div"); + this.tooltip.className = "tooltipMenu"; + + //add the div which is the tooltip + view.dom.parentNode!.parentNode!.appendChild(this.tooltip); + + //add additional icons + library.add(faListUl); + //add the buttons to the tooltip + let items = [ + { command: toggleMark(schema.marks.strong), dom: this.icon("B", "strong") }, + { command: toggleMark(schema.marks.em), dom: this.icon("i", "em") }, + { command: toggleMark(schema.marks.underline), dom: this.icon("U", "underline") }, + { command: toggleMark(schema.marks.strikethrough), dom: this.icon("S", "strikethrough") }, + { command: toggleMark(schema.marks.superscript), dom: this.icon("s", "superscript") }, + { command: toggleMark(schema.marks.subscript), dom: this.icon("s", "subscript") }, + // { command: wrapInList(schema.nodes.bullet_list), dom: this.icon(":", "bullets") }, + // { command: wrapInList(schema.nodes.ordered_list), dom: this.icon("1)", "bullets") }, + // { command: lift, dom: this.icon("<", "lift") }, + ]; + //add menu items + items.forEach(({ dom, command }) => { + this.tooltip.appendChild(dom); + + //pointer down handler to activate button effects + dom.addEventListener("pointerdown", e => { + e.preventDefault(); + view.focus(); + command(view.state, view.dispatch, view); + }); + + }); + + //list of font styles + this.fontStylesToName = new Map(); + this.fontStylesToName.set(schema.marks.timesNewRoman, "Times New Roman"); + this.fontStylesToName.set(schema.marks.arial, "Arial"); + this.fontStylesToName.set(schema.marks.georgia, "Georgia"); + this.fontStylesToName.set(schema.marks.comicSans, "Comic Sans MS"); + this.fontStylesToName.set(schema.marks.tahoma, "Tahoma"); + this.fontStylesToName.set(schema.marks.impact, "Impact"); + this.fontStylesToName.set(schema.marks.crimson, "Crimson Text"); + this.fontStyles = Array.from(this.fontStylesToName.keys()); + + //font sizes + this.fontSizeToNum = new Map(); + this.fontSizeToNum.set(schema.marks.p10, 10); + this.fontSizeToNum.set(schema.marks.p12, 12); + this.fontSizeToNum.set(schema.marks.p14, 14); + this.fontSizeToNum.set(schema.marks.p16, 16); + this.fontSizeToNum.set(schema.marks.p24, 24); + this.fontSizeToNum.set(schema.marks.p32, 32); + this.fontSizeToNum.set(schema.marks.p48, 48); + this.fontSizeToNum.set(schema.marks.p72, 72); + this.fontSizes = Array.from(this.fontSizeToNum.keys()); + + //list types + this.listTypeToIcon = new Map(); + this.listTypeToIcon.set(schema.nodes.bullet_list, ":"); + this.listTypeToIcon.set(schema.nodes.ordered_list, "1)"); + this.listTypes = Array.from(this.listTypeToIcon.keys()); + + this.update(view, undefined); + } + + //label of dropdown will change to given label + updateFontSizeDropdown(label: string) { + //filtering function - might be unecessary + let cut = (arr: MenuItem[]) => arr.filter(x => x); + + //font SIZES + let fontSizeBtns: MenuItem[] = []; + this.fontSizeToNum.forEach((number, mark) => { + fontSizeBtns.push(this.dropdownMarkBtn(String(number), "width: 50px;", mark, this.view, this.changeToMarkInGroup, this.fontSizes)); + }); + + if (this.fontSizeDom) { this.tooltip.removeChild(this.fontSizeDom); } + this.fontSizeDom = (new Dropdown(cut(fontSizeBtns), { + label: label, + css: "color:white; min-width: 60px; padding-left: 5px; margin-right: 0;" + }) as MenuItem).render(this.view).dom; + this.tooltip.appendChild(this.fontSizeDom); + } + + //label of dropdown will change to given label + updateFontStyleDropdown(label: string) { + //filtering function - might be unecessary + let cut = (arr: MenuItem[]) => arr.filter(x => x); + + //font STYLES + let fontBtns: MenuItem[] = []; + this.fontStylesToName.forEach((name, mark) => { + fontBtns.push(this.dropdownMarkBtn(name, "font-family: " + name + ", sans-serif; width: 125px;", mark, this.view, this.changeToMarkInGroup, this.fontStyles)); + }); + + if (this.fontStyleDom) { this.tooltip.removeChild(this.fontStyleDom); } + this.fontStyleDom = (new Dropdown(cut(fontBtns), { + label: label, + css: "color:white; width: 125px; margin-left: -3px; padding-left: 2px;" + }) as MenuItem).render(this.view).dom; + + this.tooltip.appendChild(this.fontStyleDom); + } + + updateLinkMenu() { + if (!this.linkEditor || !this.linkText) { + this.linkEditor = document.createElement("div"); + this.linkEditor.style.color = "white"; + this.linkText = document.createElement("div"); + this.linkText.style.cssFloat = "left"; + this.linkText.style.marginRight = "5px"; + this.linkText.style.marginLeft = "5px"; + 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(); }; + let linkBtn = document.createElement("div"); + linkBtn.textContent = ">>"; + linkBtn.style.width = "20px"; + linkBtn.style.height = "20px"; + linkBtn.style.color = "white"; + linkBtn.style.cssFloat = "left"; + linkBtn.onpointerdown = (e: PointerEvent) => { + let node = this.view.state.selection.$from.nodeAfter; + let link = node && node.marks.find(m => m.type.name === "link"); + if (link) { + let href: string = link.attrs.href; + if (href.indexOf(DocServer.prepend("/doc/")) === 0) { + let docid = href.replace(DocServer.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); + } + else CollectionDockingView.Instance.AddRightSplit(f); + } + })); + } + 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 = "20px"; + this.linkDrag.style.height = "20px"; + this.linkDrag.style.color = "white"; + this.linkDrag.style.background = "black"; + this.linkDrag.style.cssFloat = "left"; + this.linkDrag.onpointerdown = (e: PointerEvent) => { + let dragData = new DragManager.LinkDragData(this.editorProps.Document); + dragData.dontClearTextBox = true; + DragManager.StartLinkDrag(this.linkDrag!, dragData, e.clientX, e.clientY, + { + handlers: { + dragComplete: action(() => { + let m = dragData.droppedDocuments; + this.makeLink(DocServer.prepend("/doc/" + m[0][Id])); + }), + }, + hideSource: false + }); + }; + this.linkEditor.appendChild(this.linkDrag); + this.linkEditor.appendChild(this.linkText); + this.linkEditor.appendChild(linkBtn); + this.tooltip.appendChild(this.linkEditor); + } + + let node = this.view.state.selection.$from.nodeAfter; + let 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(); + } + }; + this.tooltip.appendChild(this.linkEditor); + } + + makeLink = (target: string) => { + let node = this.view.state.selection.$from.nodeAfter; + let link = this.view.state.schema.mark(this.view.state.schema.marks.link, { href: target }); + 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"); + } + + //will display a remove-list-type button if selection is in list, otherwise will show list type dropdown + updateListItemDropdown(label: string, listTypeBtn: Node) { + //remove old btn + if (listTypeBtn) { this.tooltip.removeChild(listTypeBtn); } + + //Make a dropdown of all list types + let toAdd: MenuItem[] = []; + this.listTypeToIcon.forEach((icon, type) => { + toAdd.push(this.dropdownNodeBtn(icon, "width: 40px;", type, this.view, this.listTypes, this.changeToNodeType)); + }); + //option to remove the list formatting + toAdd.push(this.dropdownNodeBtn("X", "width: 40px;", undefined, this.view, this.listTypes, this.changeToNodeType)); + + listTypeBtn = (new Dropdown(toAdd, { + label: label, + css: "color:white; width: 40px;" + }) as MenuItem).render(this.view).dom; + + //add this new button and return it + this.tooltip.appendChild(listTypeBtn); + return listTypeBtn; + } + + //for a specific grouping of marks (passed in), remove all and apply the passed-in one to the selected text + changeToMarkInGroup(markType: MarkType, view: EditorView, fontMarks: MarkType[]) { + let { empty, $cursor, ranges } = view.state.selection as TextSelection; + let state = view.state; + let 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, tr = state.tr; + for (let i = 0; !has && i < ranges.length; i++) { + let { $from, $to } = ranges[i]; + has = state.doc.rangeHasMark($from.pos, $to.pos, type); + } + for (let i of ranges) { + let { $from, $to } = i; + if (has) { + toggleMark(type)(view.state, view.dispatch, view); + } + } + } + } + }); //actually apply font + return toggleMark(markType)(view.state, view.dispatch, view); + } + + //remove all node typeand apply the passed-in one to the selected text + changeToNodeType(nodeType: NodeType | undefined, view: EditorView, allNodes: NodeType[]) { + //remove old + liftListItem(schema.nodes.list_item)(view.state, view.dispatch); + if (nodeType) { //add new + wrapInList(nodeType)(view.state, view.dispatch); } - }) - }) - - this.update(view, undefined); - } - - // Helper function to create menu icons - icon(text: string, name: string) { - let span = document.createElement("span"); - span.className = "menuicon " + name; - span.title = name; - span.textContent = text; - return span; - } - - blockActive(view: EditorView) { - const { $from, to } = view.state.selection - - return to <= $from.end() && $from.parent.hasMarkup(schema.nodes.bulletList); - } - - //this doesn't currently work but hopefully will soon - unorderedListIcon(): HTMLSpanElement { - let span = document.createElement("span"); - let icon = document.createElement("FontAwesomeIcon"); - icon.className = "menuicon fa fa-smile-o"; - span.appendChild(icon); - return span; - } - - // 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") - } - } - - //updates the tooltip menu when the selection changes - update(view: EditorView, lastState: EditorState | undefined) { - let state = view.state - // Don't do anything if the document/selection didn't change - if (lastState && lastState.doc.eq(state.doc) && - lastState.selection.eq(state.selection)) return - - // Hide the tooltip if the selection is empty - if (state.selection.empty) { - this.tooltip.style.display = "none" - return - } - - // Otherwise, reposition it and update its content - this.tooltip.style.display = "" - let { from, to } = state.selection - // These are in screen coordinates - //check this - tranform - let start = view.coordsAtPos(from), end = view.coordsAtPos(to) - // The box in which the tooltip is positioned, to use as base - let box = this.tooltip.offsetParent!.getBoundingClientRect() - // Find a center-ish x position from the selection endpoints (when - // crossing lines, end may be more to the left) - let left = Math.max((start.left + end.left) / 2, start.left + 3) - this.tooltip.style.left = (left - box.left) + "px" - let width = Math.abs(start.left - end.left) / 2; - let mid = Math.min(start.left, end.left) + width; - //THIS WIDTH IS 15 * NUMBER OF ICONS + 15 - this.tooltip.style.width = 120 + "px"; - this.tooltip.style.bottom = (box.bottom - start.top) + "px"; - } - - destroy() { this.tooltip.remove() } -}
\ No newline at end of file + } + + //makes a button for the drop down FOR MARKS + //css is the style you want applied to the button + dropdownMarkBtn(label: string, css: string, markType: MarkType, view: EditorView, changeToMarkInGroup: (markType: MarkType<any>, view: EditorView, groupMarks: MarkType[]) => any, groupMarks: MarkType[]) { + return new MenuItem({ + title: "", + label: label, + execEvent: "", + class: "menuicon", + css: css, + enable(state) { return true; }, + run() { + changeToMarkInGroup(markType, view, groupMarks); + } + }); + } + + //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: "menuicon", + css: css, + enable(state) { return true; }, + run() { + changeToNodeInGroup(nodeType, view, groupNodes); + } + }); + } + + // Helper function to create menu icons + icon(text: string, name: string) { + let span = document.createElement("span"); + span.className = name + " menuicon"; + span.title = name; + span.textContent = text; + span.style.color = "white"; + return span; + } + + //method for checking whether node can be inserted + canInsert(state: EditorState, nodeType: NodeType<Schema<string, string>>) { + let $from = state.selection.$from; + for (let d = $from.depth; d >= 0; d--) { + let 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) { + let attrs = {}; + + if (state.selection instanceof NodeSelection) { + const sel: NodeSelection = state.selection; + let $from = sel.$from; + let to = sel.to; + let 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") + }; + } + + //updates the tooltip menu when the selection changes + update(view: EditorView, lastState: EditorState | undefined) { + let state = view.state; + // Don't do anything if the document/selection didn't change + if (lastState && lastState.doc.eq(state.doc) && + lastState.selection.eq(state.selection)) return; + + // Hide the tooltip if the selection is empty + if (state.selection.empty) { + this.tooltip.style.display = "none"; + return; + } + + // Otherwise, reposition it and update its content + this.tooltip.style.display = ""; + let { from, to } = state.selection; + let start = view.coordsAtPos(from), end = view.coordsAtPos(to); + // The box in which the tooltip is positioned, to use as base + let box = this.tooltip.offsetParent!.getBoundingClientRect(); + // Find a center-ish x position from the selection endpoints (when + // crossing lines, end may be more to the left) + let left = Math.max((start.left + end.left) / 2, start.left + 3); + this.tooltip.style.left = (left - box.left) * this.editorProps.ScreenToLocalTransform().Scale + "px"; + let width = Math.abs(start.left - end.left) / 2 * this.editorProps.ScreenToLocalTransform().Scale; + let mid = Math.min(start.left, end.left) + width; + + this.tooltip.style.width = 225 + "px"; + this.tooltip.style.bottom = (box.bottom - start.top) * this.editorProps.ScreenToLocalTransform().Scale + "px"; + + //UPDATE LIST ITEM DROPDOWN + this.listTypeBtnDom = this.updateListItemDropdown(":", this.listTypeBtnDom!); + + //UPDATE FONT STYLE DROPDOWN + let activeStyles = this.activeMarksOnSelection(this.fontStyles); + if (activeStyles.length === 1) { + // if we want to update something somewhere with active font name + let fontName = this.fontStylesToName.get(activeStyles[0]); + if (fontName) { this.updateFontStyleDropdown(fontName); } + } else if (activeStyles.length === 0) { + //crimson on default + this.updateFontStyleDropdown("Crimson Text"); + } else { + this.updateFontStyleDropdown("Various"); + } + + //UPDATE FONT SIZE DROPDOWN + let activeSizes = this.activeMarksOnSelection(this.fontSizes); + if (activeSizes.length === 1) { //if there's only one active font size + let size = this.fontSizeToNum.get(activeSizes[0]); + if (size) { this.updateFontSizeDropdown(String(size) + " pt"); } + } else if (activeSizes.length === 0) { + //should be 14 on default + this.updateFontSizeDropdown("14 pt"); + } else { //multiple font sizes selected + this.updateFontSizeDropdown("Various"); + } + + this.updateLinkMenu(); + } + + //finds all active marks on selection in given group + activeMarksOnSelection(markGroup: MarkType[]) { + //current selection + let { empty, $cursor, ranges } = this.view.state.selection as TextSelection; + let state = this.view.state; + let dispatch = this.view.dispatch; + + let activeMarks = markGroup.filter(mark => { + if (dispatch) { + let has = false, tr = state.tr; + for (let i = 0; !has && i < ranges.length; i++) { + let { $from, $to } = ranges[i]; + return state.doc.rangeHasMark($from.pos, $to.pos, mark); + } + } + return false; + }); + return activeMarks; + } + + destroy() { this.tooltip.remove(); } +} diff --git a/src/client/util/Transform.ts b/src/client/util/Transform.ts index 3e1039166..e9170ec36 100644 --- a/src/client/util/Transform.ts +++ b/src/client/util/Transform.ts @@ -3,7 +3,7 @@ export class Transform { private _translateY: number = 0; private _scale: number = 1; - static get Identity(): Transform { + static Identity(): Transform { return new Transform(0, 0, 1); } @@ -17,78 +17,64 @@ export class Transform { this._scale = scale; } - translate = (x: number, y: number): Transform => { + translate = (x: number, y: number): this => { this._translateX += x; this._translateY += y; return this; } - scale = (scale: number): Transform => { + scale = (scale: number): this => { this._scale *= scale; this._translateX *= scale; this._translateY *= scale; return this; } - scaleAbout = (scale: number, x: number, y: number): Transform => { + scaleAbout = (scale: number, x: number, y: number): this => { this._translateX += x * this._scale - x * this._scale * scale; this._translateY += y * this._scale - y * this._scale * scale; this._scale *= scale; return this; } - transform = (transform: Transform): Transform => { + transform = (transform: Transform): this => { this._translateX = transform._translateX + transform._scale * this._translateX; this._translateY = transform._translateY + transform._scale * this._translateY; this._scale *= transform._scale; return this; } - preTranslate = (x: number, y: number): Transform => { + preTranslate = (x: number, y: number): this => { this._translateX += this._scale * x; this._translateY += this._scale * y; return this; } - preScale = (scale: number): Transform => { + preScale = (scale: number): this => { this._scale *= scale; return this; } - preTransform = (transform: Transform): Transform => { + preTransform = (transform: Transform): this => { this._translateX += transform._translateX * this._scale; this._translateY += transform._translateY * this._scale; this._scale *= transform._scale; return this; } - translated = (x: number, y: number): Transform => { - return this.copy().translate(x, y); - } + translated = (x: number, y: number): Transform => this.copy().translate(x, y); - preTranslated = (x: number, y: number): Transform => { - return this.copy().preTranslate(x, y); - } + preTranslated = (x: number, y: number): Transform => this.copy().preTranslate(x, y); - scaled = (scale: number): Transform => { - return this.copy().scale(scale); - } + scaled = (scale: number): Transform => this.copy().scale(scale); - scaledAbout = (scale: number, x: number, y: number): Transform => { - return this.copy().scaleAbout(scale, x, y); - } + scaledAbout = (scale: number, x: number, y: number): Transform => this.copy().scaleAbout(scale, x, y); - preScaled = (scale: number): Transform => { - return this.copy().preScale(scale); - } + preScaled = (scale: number): Transform => this.copy().preScale(scale); - transformed = (transform: Transform): Transform => { - return this.copy().transform(transform); - } + transformed = (transform: Transform): Transform => this.copy().transform(transform); - preTransformed = (transform: Transform): Transform => { - return this.copy().preTransform(transform); - } + preTransformed = (transform: Transform): Transform => this.copy().preTransform(transform); transformPoint = (x: number, y: number): [number, number] => { x *= this._scale; @@ -98,9 +84,7 @@ export class Transform { return [x, y]; } - transformDirection = (x: number, y: number): [number, number] => { - return [x * this._scale, y * this._scale]; - } + transformDirection = (x: number, y: number): [number, number] => [x * this._scale, y * this._scale]; transformBounds(x: number, y: number, width: number, height: number): { x: number, y: number, width: number, height: number } { [x, y] = this.transformPoint(x, y); @@ -108,12 +92,8 @@ export class Transform { return { x, y, width, height }; } - inverse = () => { - return new Transform(-this._translateX / this._scale, -this._translateY / this._scale, 1 / this._scale) - } + inverse = () => new Transform(-this._translateX / this._scale, -this._translateY / this._scale, 1 / this._scale); - copy = () => { - return new Transform(this._translateX, this._translateY, this._scale); - } + copy = () => new Transform(this._translateX, this._translateY, this._scale); }
\ No newline at end of file diff --git a/src/client/util/TypedEvent.ts b/src/client/util/TypedEvent.ts index 0714a7f5c..532ba78eb 100644 --- a/src/client/util/TypedEvent.ts +++ b/src/client/util/TypedEvent.ts @@ -36,7 +36,5 @@ export class TypedEvent<T> { this.listenersOncer = []; } - pipe = (te: TypedEvent<T>): Disposable => { - return this.on((e) => te.emit(e)); - } + pipe = (te: TypedEvent<T>): Disposable => this.on((e) => te.emit(e)); }
\ No newline at end of file diff --git a/src/client/util/UndoManager.ts b/src/client/util/UndoManager.ts index 46ad558f3..c0ed015bd 100644 --- a/src/client/util/UndoManager.ts +++ b/src/client/util/UndoManager.ts @@ -1,4 +1,14 @@ -import { observable, action } from "mobx"; +import { observable, action, runInAction } from "mobx"; +import 'source-map-support/register'; +import { Without } from "../../Utils"; + +function getBatchName(target: any, key: string | symbol): string { + let keyName = key.toString(); + if (target && target.constructor && target.constructor.name) { + return `${target.constructor.name}.${keyName}`; + } + return keyName; +} function propertyDecorator(target: any, key: string | symbol) { Object.defineProperty(target, key, { @@ -13,18 +23,31 @@ function propertyDecorator(target: any, key: string | symbol) { writable: true, configurable: true, value: function (...args: any[]) { + let batch = UndoManager.StartBatch(getBatchName(target, key)); try { - UndoManager.StartBatch(); return value.apply(this, args); } finally { - UndoManager.EndBatch(); + batch.end(); } } - }) + }); } - }) + }); } -export function undoBatch(target: any, key: string | symbol, descriptor?: TypedPropertyDescriptor<any>): any { + +export function undoBatch(target: any, key: string | symbol, descriptor?: TypedPropertyDescriptor<any>): any; +export function undoBatch(fn: (...args: any[]) => any): (...args: any[]) => any; +export function undoBatch(target: any, key?: string | symbol, descriptor?: TypedPropertyDescriptor<any>): any { + if (!key) { + return function () { + let batch = UndoManager.StartBatch(""); + try { + return target.apply(undefined, arguments); + } finally { + batch.end(); + } + }; + } if (!descriptor) { propertyDecorator(target, key); return; @@ -32,13 +55,13 @@ export function undoBatch(target: any, key: string | symbol, descriptor?: TypedP const oldFunction = descriptor.value; descriptor.value = function (...args: any[]) { + let batch = UndoManager.StartBatch(getBatchName(target, key)); try { - UndoManager.StartBatch() - return oldFunction.apply(this, args) + return oldFunction.apply(this, args); } finally { - UndoManager.EndBatch() + batch.end(); } - } + }; return descriptor; } @@ -70,26 +93,64 @@ export namespace UndoManager { return redoStack.length > 0; } - export function StartBatch(): void { + export function PrintBatches(): void { + GetOpenBatches().forEach(batch => console.log(batch.batchName)); + } + + let openBatches: Batch[] = []; + export function GetOpenBatches(): Without<Batch, 'end'>[] { + return openBatches; + } + export function TraceOpenBatches() { + console.log(`Open batches:\n\t${openBatches.map(batch => batch.batchName).join("\n\t")}\n`); + } + export class Batch { + private disposed: boolean = false; + + constructor(readonly batchName: string) { + openBatches.push(this); + } + + private dispose = (cancel: boolean) => { + if (this.disposed) { + throw new Error("Cannot dispose an already disposed batch"); + } + this.disposed = true; + openBatches.splice(openBatches.indexOf(this)); + EndBatch(cancel); + } + + end = () => { this.dispose(false); }; + cancel = () => { this.dispose(true); }; + } + + export function StartBatch(batchName: string): Batch { batchCounter++; if (batchCounter > 0) { currentBatch = []; } + return new Batch(batchName); } - export const EndBatch = action(() => { + const EndBatch = action((cancel: boolean = false) => { batchCounter--; if (batchCounter === 0 && currentBatch && currentBatch.length) { - undoStack.push(currentBatch); + if (!cancel) { + undoStack.push(currentBatch); + } redoStack.length = 0; currentBatch = undefined; } - }) + }); - export function RunInBatch(fn: () => void) { - StartBatch(); - fn(); - EndBatch(); + //TODO Make this return the return value + export function RunInBatch<T>(fn: () => T, batchName: string) { + let batch = StartBatch(batchName); + try { + return runInAction(fn); + } finally { + batch.end(); + } } export const Undo = action(() => { @@ -109,7 +170,7 @@ export namespace UndoManager { undoing = false; redoStack.push(commands); - }) + }); export const Redo = action(() => { if (redoStack.length === 0) { @@ -122,12 +183,12 @@ export namespace UndoManager { } undoing = true; - for (let i = 0; i < commands.length; i++) { - commands[i].redo(); + for (const command of commands) { + command.redo(); } undoing = false; undoStack.push(commands); - }) + }); }
\ No newline at end of file diff --git a/src/client/util/jsx-decl.d.ts b/src/client/util/jsx-decl.d.ts new file mode 100644 index 000000000..532f06178 --- /dev/null +++ b/src/client/util/jsx-decl.d.ts @@ -0,0 +1 @@ +declare module 'react-jsx-parser'; diff --git a/src/client/util/type_decls.d b/src/client/util/type_decls.d index 679f73f42..47c3481b2 100644 --- a/src/client/util/type_decls.d +++ b/src/client/util/type_decls.d @@ -174,13 +174,14 @@ declare class ListField<T> extends BasicField<T[]>{ Copy(): Field; } declare class Key extends Field { + constructor(name:string); Name: string; TrySetValue(value: any): boolean; GetValue(): any; Copy(): Field; ToScriptString(): string; } -declare type FIELD_WAITING = "<Waiting>"; +declare type FIELD_WAITING = null; declare type Opt<T> = T | undefined; declare type FieldValue<T> = Opt<T> | FIELD_WAITING; // @ts-ignore @@ -213,3 +214,12 @@ declare class Document extends Field { GetAllPrototypes(): Document[]; MakeDelegate(): Document; } + +declare const KeyStore: { + [name: string]: Key; +} + +// @ts-ignore +declare const console: any; + +declare const Documents: any; |