From 62a34043949f051533ca549be48b774696331d43 Mon Sep 17 00:00:00 2001 From: andrewdkim Date: Mon, 5 Aug 2019 17:37:43 -0400 Subject: richtext + buttons --- src/client/views/MainView.tsx | 1 + 1 file changed, 1 insertion(+) (limited to 'src/client/views/MainView.tsx') diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index f5a6715e5..669b8f018 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -400,6 +400,7 @@ export class MainView extends React.Component { ; @@ -523,12 +525,8 @@ export class MainView extends React.Component { /* @TODO this should really be moved into a moveable toolbar component, but for now let's put it here to meet the deadline */ @computed get miscButtons() { - let logoutRef = React.createRef(); - return [ this.isSearchVisible ?
: null, -
-
]; } @@ -568,7 +566,7 @@ export class MainView extends React.Component { let next = () => PresBox.CurrentPresentation.next(); let back = () => PresBox.CurrentPresentation.back(); let startOrResetPres = () => PresBox.CurrentPresentation.startOrResetPres(); - let closePresMode = action(() => { PresBox.CurrentPresentation.presMode = false; this.addDocTabFunc(PresBox.CurrentPresentation.props.Document); }); + let closePresMode = action(() => { PresBox.CurrentPresentation.presMode = false; this.addDocTabFunc(PresBox.CurrentPresentation.props.Document, undefined, "onRight"); }); return !PresBox.CurrentPresentation || !PresBox.CurrentPresentation.presMode ? (null) : ; } diff --git a/src/client/views/TemplateMenu.tsx b/src/client/views/TemplateMenu.tsx index e4ef8313d..9e5e62e03 100644 --- a/src/client/views/TemplateMenu.tsx +++ b/src/client/views/TemplateMenu.tsx @@ -117,13 +117,13 @@ export class TemplateMenu extends React.Component { @action toggleChrome = (): void => { this.props.docs.map(dv => { - let layout = dv.Document.layout instanceof Doc ? dv.Document.layout as Doc : dv.Document; + let layout = dv.Document.layout instanceof Doc ? dv.Document.layout : dv.Document; layout.chromeStatus = (layout.chromeStatus !== "disabled" ? "disabled" : "enabled"); }); } render() { - let layout = this.props.docs[0].Document.layout instanceof Doc ? this.props.docs[0].Document.layout as Doc : this.props.docs[0].Document; + let layout = this.props.docs[0].Document.layout instanceof Doc ? this.props.docs[0].Document.layout : this.props.docs[0].Document; let templateMenu: Array = []; this.props.templates.forEach((checked, template) => templateMenu.push()); diff --git a/src/client/views/animationtimeline/Keyframe.tsx b/src/client/views/animationtimeline/Keyframe.tsx index 7197f4b49..66ad6a76d 100644 --- a/src/client/views/animationtimeline/Keyframe.tsx +++ b/src/client/views/animationtimeline/Keyframe.tsx @@ -8,7 +8,6 @@ import { Doc, DocListCast, DocListCastAsync } from "../../../new_fields/Doc"; import { Cast, NumCast } from "../../../new_fields/Types"; import { List } from "../../../new_fields/List"; import { createSchema, defaultSpec, makeInterface, listSpec } from "../../../new_fields/Schema"; -import { FlyoutProps } from "./Timeline"; import { Transform } from "../../util/Transform"; import { InkField, StrokeData } from "../../../new_fields/InkField"; import { TimelineMenu } from "./TimelineMenu"; @@ -138,48 +137,13 @@ export class Keyframe extends React.Component { @observable private _mouseToggled = false; @observable private _doubleClickEnabled = false; - @computed - private get regiondata() { - let index = this.regions.indexOf(this.props.RegionData); - return RegionData(this.regions[index] as Doc); - } - - @computed - private get regions() { - return Cast(this.props.node.regions, listSpec(Doc)) as List; - } - - @computed - private get firstKeyframe() { - let first: (Doc | undefined) = undefined; - DocListCast(this.regiondata.keyframes!).forEach(kf => { - if (kf.type !== KeyframeFunc.KeyframeType.fade) { - if (!first || first && NumCast(kf.time) < NumCast(first.time)) { - first = kf; - } - } - }); - return first; - } - - @computed - private get lastKeyframe() { - let last: (Doc | undefined) = undefined; - DocListCast(this.regiondata.keyframes!).forEach(kf => { - if (kf.type !== KeyframeFunc.KeyframeType.fade) { - if (!last || last && NumCast(kf.time) > NumCast(last.time)) { - last = kf; - } - } - }); - return last; - } - - @computed - private get keyframes(){ - return DocListCast(this.regiondata.keyframes); - } - + @computed private get regiondata() { return RegionData(this.regions[this.regions.indexOf(this.props.RegionData)] as Doc);} + @computed private get regions() { return Cast(this.props.node.regions, listSpec(Doc)) as List;} + @computed private get keyframes(){ return DocListCast(this.regiondata.keyframes); } + @computed private get pixelPosition(){ return KeyframeFunc.convertPixelTime(this.regiondata.position, "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement);} + @computed private get pixelDuration(){ return KeyframeFunc.convertPixelTime(this.regiondata.duration, "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement); } + @computed private get pixelFadeIn() { return KeyframeFunc.convertPixelTime(this.regiondata.fadeIn, "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement); } + @computed private get pixelFadeOut(){ return KeyframeFunc.convertPixelTime(this.regiondata.fadeOut, "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement); } @computed private get inks() { if (this.props.collection.data_ext) { @@ -191,38 +155,18 @@ export class Keyframe extends React.Component { } } - @computed - private get pixelPosition(){ - return KeyframeFunc.convertPixelTime(this.regiondata.position, "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement); - } - - @computed - private get pixelDuration(){ - return KeyframeFunc.convertPixelTime(this.regiondata.duration, "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement); - } - - @computed - private get pixelFadeIn() { - return KeyframeFunc.convertPixelTime(this.regiondata.fadeIn, "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement); - } - - @computed - private get pixelFadeOut(){ - return KeyframeFunc.convertPixelTime(this.regiondata.fadeOut, "mili", "pixel", this.props.tickSpacing, this.props.tickIncrement); - } - - async componentWillMount() { - if (!this.regiondata.keyframes) { - this.regiondata.keyframes = new List(); - } - let fadeIn = await this.makeKeyData(this.regiondata.position + this.regiondata.fadeIn, KeyframeFunc.KeyframeType.fade)!; - let fadeOut = await this.makeKeyData(this.regiondata.position + this.regiondata.duration - this.regiondata.fadeOut, KeyframeFunc.KeyframeType.fade)!; - let start = await this.makeKeyData(this.regiondata.position, KeyframeFunc.KeyframeType.fade)!; - let finish = await this.makeKeyData(this.regiondata.position + this.regiondata.duration, KeyframeFunc.KeyframeType.fade)!; - (fadeIn.key! as Doc).opacity = 1; - (fadeOut.key! as Doc).opacity = 1; - (start.key! as Doc).opacity = 0.1; - (finish.key! as Doc).opacity = 0.1; + componentWillMount() { + runInAction(async () => { + if (!this.regiondata.keyframes) this.regiondata.keyframes = new List(); + let fadeIn = await this.makeKeyData(this.regiondata.position + this.regiondata.fadeIn, KeyframeFunc.KeyframeType.fade)!; + let fadeOut = await this.makeKeyData(this.regiondata.position + this.regiondata.duration - this.regiondata.fadeOut, KeyframeFunc.KeyframeType.fade)!; + let start = await this.makeKeyData(this.regiondata.position, KeyframeFunc.KeyframeType.fade)!; + let finish = await this.makeKeyData(this.regiondata.position + this.regiondata.duration, KeyframeFunc.KeyframeType.fade)!; + (fadeIn.key! as Doc).opacity = 1; + (fadeOut.key! as Doc).opacity = 1; + (start.key! as Doc).opacity = 0.1; + (finish.key! as Doc).opacity = 0.1; + }); } @action @@ -336,7 +280,7 @@ export class Keyframe extends React.Component { let bar = this._bar.current!; let offset = KeyframeFunc.convertPixelTime(Math.round((e.clientX - bar.getBoundingClientRect().left) * this.props.transform.Scale), "mili", "time", this.props.tickSpacing, this.props.tickIncrement); let leftRegion = KeyframeFunc.findAdjacentRegion(KeyframeFunc.Direction.left, this.regiondata, this.regions); - let firstkf: (Doc | undefined) = this.firstKeyframe; + let firstkf: (Doc | undefined) = this.keyframes[0]; if (firstkf && this.regiondata.position + this.regiondata.fadeIn + offset >= NumCast(firstkf!.time)) { let dif = NumCast(firstkf!.time) - (this.pixelPosition + this.pixelFadeIn); this.regiondata.position = NumCast(firstkf!.time) - this.regiondata.fadeIn; @@ -364,8 +308,8 @@ export class Keyframe extends React.Component { let bar = this._bar.current!; let offset = KeyframeFunc.convertPixelTime(Math.round((e.clientX - bar.getBoundingClientRect().right) * this.props.transform.Scale), "mili", "time", this.props.tickSpacing, this.props.tickIncrement); let rightRegion = KeyframeFunc.findAdjacentRegion(KeyframeFunc.Direction.right, this.regiondata, this.regions); - if (this.lastKeyframe! && this.regiondata.position + this.regiondata.duration - this.regiondata.fadeOut + offset <= NumCast((this.lastKeyframe! as Doc).time)) { - let dif = this.regiondata.position + this.regiondata.duration - this.regiondata.fadeOut - NumCast((this.lastKeyframe! as Doc).time); + if (this.regiondata.position + this.regiondata.duration - this.regiondata.fadeOut + offset <= NumCast((this.keyframes[this.keyframes.length - 1]).time)) { + let dif = this.regiondata.position + this.regiondata.duration - this.regiondata.fadeOut - NumCast((this.keyframes[this.keyframes.length - 1]).time); this.regiondata.duration -= dif; } else if (this.regiondata.duration + offset < this.regiondata.fadeIn + this.regiondata.fadeOut) { // nokeyframes, just fades this.regiondata.duration = this.regiondata.fadeIn + this.regiondata.fadeOut; @@ -532,6 +476,7 @@ export class Keyframe extends React.Component { e.stopPropagation(); let div = ref.current!; div.style.opacity = "1"; + Doc.BrushDoc(this.props.node); } onContainerOut = (e: React.PointerEvent, ref: React.RefObject) => { @@ -539,6 +484,7 @@ export class Keyframe extends React.Component { e.stopPropagation(); let div = ref.current!; div.style.opacity = "0"; + Doc.UnBrushDoc(this.props.node); } @@ -623,7 +569,6 @@ export class Keyframe extends React.Component { } } render() { - console.log("RERENDERING"); return (
; -} - - @observer export class Timeline extends React.Component { @@ -35,26 +24,23 @@ export class Timeline extends React.Component { private readonly MAX_CONTAINER_HEIGHT: number = 800; private readonly DEFAULT_TICK_INCREMENT: number = 1000; - @observable private _isMinimized = false; - @observable private _tickSpacing = this.DEFAULT_TICK_SPACING; - @observable private _tickIncrement = this.DEFAULT_TICK_INCREMENT; - @observable private _scrubberbox = React.createRef(); - @observable private _scrubber = React.createRef(); @observable private _trackbox = React.createRef(); @observable private _titleContainer = React.createRef(); @observable private _timelineContainer = React.createRef(); - @observable private _timelineWrapper = React.createRef(); @observable private _infoContainer = React.createRef(); + @observable private _roundToggleRef = React.createRef(); + @observable private _roundToggleContainerRef = React.createRef(); @observable private _currentBarX: number = 0; @observable private _windSpeed: number = 1; @observable private _isPlaying: boolean = false; //scrubber playing - @observable private _isFrozen: boolean = true; //timeline freeze @observable private _totalLength: number = 0; @observable private _visibleLength: number = 0; @observable private _visibleStart: number = 0; - @observable private _containerHeight: number = this.DEFAULT_CONTAINER_HEIGHT; + @observable private _containerHeight: number = this.DEFAULT_CONTAINER_HEIGHT; + @observable private _tickSpacing = this.DEFAULT_TICK_SPACING; + @observable private _tickIncrement = this.DEFAULT_TICK_INCREMENT; @observable private _time = 100000; //DEFAULT @observable private _ticks: number[] = []; @observable private _playButton = faPlayCircle; @@ -273,39 +259,7 @@ export class Timeline extends React.Component { } } - @action - onTimelineDown = (e: React.PointerEvent) => { - e.preventDefault(); - if (e.nativeEvent.which === 1 && !this._isFrozen) { - document.addEventListener("pointermove", this.onTimelineMove); - document.addEventListener("pointerup", () => { document.removeEventListener("pointermove", this.onTimelineMove); }); - } - } - @action - onTimelineMove = (e: PointerEvent) => { - e.preventDefault(); - e.stopPropagation(); - let timelineContainer = this._timelineWrapper.current!; - let left = parseFloat(timelineContainer.style.left!); - let top = parseFloat(timelineContainer.style.top!); - timelineContainer.style.left = `${left + e.movementX}px`; - timelineContainer.style.top = `${top + e.movementY}px`; - } - - @action - minimize = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - let timelineContainer = this._timelineContainer.current!; - if (this._isMinimized) { - this._isMinimized = false; - timelineContainer.style.visibility = "visible"; - } else { - this._isMinimized = true; - timelineContainer.style.visibility = "hidden"; - } - } @action toReadTime = (time: number): string => { @@ -321,21 +275,6 @@ export class Timeline extends React.Component { timelineContextMenu = (e:MouseEvent): void => { let subitems: ContextMenuProps[] = []; - let timelineContainer = this._timelineWrapper.current!; - subitems.push({ - description: "Pin to Top", event: action(() => { - if (!this._isFrozen) { - timelineContainer.style.left = "0px"; - timelineContainer.style.top = "0px"; - timelineContainer.style.transition = "none"; - } - }), icon: faArrowUp - }); - subitems.push({ - description: this._isFrozen ? "Unfreeze Timeline" : "Freeze Timeline", event: action(() => { - this._isFrozen = !this._isFrozen; - }), icon: "thumbtack" - }); subitems.push({ description: this._timelineVisible ? "Hide Timeline" : "Show Timeline", event: action(() => { this._timelineVisible = !this._timelineVisible; @@ -358,7 +297,8 @@ export class Timeline extends React.Component { let currPixel = KeyframeFunc.convertPixelTime(prevTime, "mili", "pixel", this._tickSpacing, this._tickIncrement); let currCurrent = KeyframeFunc.convertPixelTime(prevCurrent, "mili", "pixel", this._tickSpacing, this._tickIncrement); this._infoContainer.current!.scrollLeft = currPixel - offset; - this._visibleStart = currPixel - offset; + this._visibleStart = currPixel - offset > 0 ? currPixel - offset : 0; + this._visibleStart += this._visibleLength + this._visibleStart > this._totalLength ? this._totalLength - (this._visibleStart + this._visibleLength) :0; this.changeCurrentBarX(currCurrent); } @@ -393,44 +333,70 @@ export class Timeline extends React.Component { private timelineToolBox = (scale:number) => { let size = 50 * scale; //50 is default - return ( + return ( +
-
-
-
- +
+
+
+ +
+
+
); } + + @action + private toggleChecked = (e:React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + let roundToggle = this._roundToggleRef.current!; + let roundToggleContainer = this._roundToggleContainerRef.current!; + if (BoolCast(this.props.Document.isAnimating)){ + roundToggle.style.transform = "translate(0px, 0px)"; + roundToggle.style.animationName = "turnoff"; + roundToggleContainer.style.animationName = "turnoff"; + + this.props.Document.isAnimating = false; + } else { + roundToggle.style.transform = "translate(45px, 0px)"; + roundToggle.style.animationName = "turnon"; + roundToggleContainer.style.animationName = "turnon"; + this.props.Document.isAnimating = true; + } + } render() { return ( -
-
- -
- {this.timelineToolBox(0.5)} -
-
- {this._ticks.map(element => { - if(element % this._tickIncrement === 0) return

{this.toReadTime(element)}

; - })} +
+
+
+
+
+
+ {this._ticks.map(element => { + if(element % this._tickIncrement === 0) return

{this.toReadTime(element)}

; + })} +
+
+
+
+
+ {DocListCast(this.children).map(doc => )} +
-
-
+
+ {DocListCast(this.children).map(doc =>
{Doc.BrushDoc(doc);}} onPointerOut={() => {Doc.UnBrushDoc(doc);}}>

{doc.title}

)}
-
- {DocListCast(this.children).map(doc => )} +
+
-
- {DocListCast(this.children).map(doc =>

{doc.title}

)} -
-
- -
+ { this.timelineToolBox(1) }
- {BoolCast(this.props.Document.isAnimating) ?
: this.timelineToolBox(1) } + +
); } diff --git a/src/client/views/animationtimeline/TimelineMenu.tsx b/src/client/views/animationtimeline/TimelineMenu.tsx index f3b985297..59c25596e 100644 --- a/src/client/views/animationtimeline/TimelineMenu.tsx +++ b/src/client/views/animationtimeline/TimelineMenu.tsx @@ -4,6 +4,7 @@ import {observer} from "mobx-react"; import "./TimelineMenu.scss"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faChartLine, faRoad, faClipboard, faPen, faTrash, faTable } from "@fortawesome/free-solid-svg-icons"; +import { Utils } from "../../../Utils"; @observer @@ -40,7 +41,7 @@ export class TimelineMenu extends React.Component { if (type === "input"){ let inputRef = React.createRef(); let text = ""; - this._currentMenu.push(
{ + this._currentMenu.push(
{ e.stopPropagation(); text = e.target.value; }} onKeyDown={(e) => { @@ -52,23 +53,23 @@ export class TimelineMenu extends React.Component { }}/>
); } else if (type === "button") { let buttonRef = React.createRef(); - this._currentMenu.push(

{ + this._currentMenu.push(

{ e.preventDefault(); e.stopPropagation(); event(e); this.closeMenu(); }}>{title}

); - } + } } @action addMenu = (title:string) => { - this._currentMenu.unshift(

{title}

); + this._currentMenu.unshift(

{title}

); } render() { return ( -
+
{this._currentMenu}
); diff --git a/src/client/views/animationtimeline/Track.tsx b/src/client/views/animationtimeline/Track.tsx index c68d9bb3a..3ec410216 100644 --- a/src/client/views/animationtimeline/Track.tsx +++ b/src/client/views/animationtimeline/Track.tsx @@ -32,18 +32,24 @@ export class Track extends React.Component { @observable private _onKeyframe: (Doc | undefined) = undefined; @observable private _onRegionData: (Doc | undefined) = undefined; @observable private _storedState: (Doc | undefined) = undefined; - - @computed - private get regions() { - return Cast(this.props.node.regions, listSpec(Doc)) as List; - } + @observable private filterList = [ + "regions", + "cursors", + "hidden", + "nativeHeight", + "nativeWidth", + "schemaColumns", + "baseLayout", + "backgroundLayout", + "layout", + ]; + + @computed private get regions() { return Cast(this.props.node.regions, listSpec(Doc)) as List;} componentWillMount() { - if (!this.props.node.regions) { - this.props.node.regions = new List(); - } - - + runInAction(() => { + if (!this.props.node.regions) this.props.node.regions = new List(); + }); } componentDidMount() { @@ -54,11 +60,11 @@ export class Track extends React.Component { this.props.node.hidden = false; this.props.node.opacity = 1; }); - } componentWillUnmount() { runInAction(() => { + //disposing reactions if (this._currentBarXReaction) this._currentBarXReaction(); if (this._timelineVisibleReaction) this._timelineVisibleReaction(); }); @@ -166,17 +172,7 @@ export class Track extends React.Component { }); } - private filterList = [ - "regions", - "cursors", - "hidden", - "nativeHeight", - "nativeWidth", - "schemaColumns", - "baseLayout", - "backgroundLayout", - "layout", - ]; + @action private filterKeys = (keys: string[]): string[] => { @@ -294,7 +290,7 @@ export class Track extends React.Component { return (
-
+
{Doc.BrushDoc(this.props.node);}}onPointerOut={() => {Doc.UnBrushDoc(this.props.node);}}> {DocListCast(this.regions).map((region) => { return ; })} diff --git a/src/client/views/collections/CollectionBaseView.tsx b/src/client/views/collections/CollectionBaseView.tsx index 56d12bd84..0168c466f 100644 --- a/src/client/views/collections/CollectionBaseView.tsx +++ b/src/client/views/collections/CollectionBaseView.tsx @@ -20,7 +20,8 @@ export enum CollectionViewType { Docking, Tree, Stacking, - Masonry + Masonry, + Pivot, } export namespace CollectionViewType { @@ -32,7 +33,8 @@ export namespace CollectionViewType { ["docking", CollectionViewType.Docking], ["tree", CollectionViewType.Tree], ["stacking", CollectionViewType.Stacking], - ["masonry", CollectionViewType.Masonry] + ["masonry", CollectionViewType.Masonry], + ["pivot", CollectionViewType.Pivot] ]); export const valueOf = (value: string) => { diff --git a/src/client/views/collections/CollectionDockingView.scss b/src/client/views/collections/CollectionDockingView.scss index 0e7e0afa7..6f5abd05b 100644 --- a/src/client/views/collections/CollectionDockingView.scss +++ b/src/client/views/collections/CollectionDockingView.scss @@ -1,8 +1,5 @@ @import "../../views/globalCssVariables.scss"; -.collectiondockingview-content { - height: 100%; -} .lm_active .messageCounter{ color:white; background: #999999; @@ -21,7 +18,7 @@ .collectiondockingview-container { width: 100%; - height: 100%; + height:100%; border-style: solid; border-width: $COLLECTION_BORDER_WIDTH; position: absolute; diff --git a/src/client/views/collections/CollectionDockingView.tsx b/src/client/views/collections/CollectionDockingView.tsx index 8fcba99e3..b047e77a8 100644 --- a/src/client/views/collections/CollectionDockingView.tsx +++ b/src/client/views/collections/CollectionDockingView.tsx @@ -9,7 +9,7 @@ import * as ReactDOM from 'react-dom'; import Measure from "react-measure"; import * as GoldenLayout from "../../../client/goldenLayout"; import { DateField } from '../../../new_fields/DateField'; -import { Doc, DocListCast, Field, Opt } from "../../../new_fields/Doc"; +import { Doc, DocListCast, Field, Opt, WidthSym, HeightSym } from "../../../new_fields/Doc"; import { Id } from '../../../new_fields/FieldSymbols'; import { List } from '../../../new_fields/List'; import { FieldId } from "../../../new_fields/RefField"; @@ -30,7 +30,9 @@ import "./CollectionDockingView.scss"; import { SubCollectionViewProps } from "./CollectionSubView"; import React = require("react"); import { ButtonSelector } from './ParentDocumentSelector'; +import { DocumentType } from '../../documents/DocumentTypes'; library.add(faFile); +const _global = (window /* browser */ || global /* node */) as any; @observer export class CollectionDockingView extends React.Component { @@ -533,12 +535,11 @@ interface DockedFrameProps { } @observer export class DockedFrameRenderer extends React.Component { - _mainCont: HTMLDivElement | undefined = undefined; + _mainCont: HTMLDivElement | null = null; @observable private _panelWidth = 0; @observable private _panelHeight = 0; @observable private _document: Opt; @observable private _dataDoc: Opt; - @observable private _isActive: boolean = false; get _stack(): any { @@ -576,6 +577,13 @@ export class DockedFrameRenderer extends React.Component { } componentDidMount() { + let observer = new _global.ResizeObserver(action((entries: any) => { + for (let entry of entries) { + this._panelWidth = entry.contentRect.width; + this._panelHeight = entry.contentRect.height; + } + })); + observer.observe(this.props.glContainer._element[0]); this.props.glContainer.layoutManager.on("activeContentItemChanged", this.onActiveContentItemChanged); this.props.glContainer.on("tab", this.onActiveContentItemChanged); this.onActiveContentItemChanged(); @@ -594,13 +602,21 @@ export class DockedFrameRenderer extends React.Component { } } - panelWidth = () => this._document!.ignoreAspect ? this._panelWidth : Math.min(this._panelWidth, Math.max(NumCast(this._document!.width), this.nativeWidth())); - panelHeight = () => this._document!.ignoreAspect ? this._panelHeight : Math.min(this._panelHeight, Math.max(NumCast(this._document!.height), NumCast(this._document!.nativeHeight, this._panelHeight))); + panelWidth = () => this._document!.ignoreAspect || this._document!.fitWidth ? this._panelWidth : Math.min(this._panelWidth, Math.max(NumCast(this._document!.width), this.nativeWidth())); + panelHeight = () => this._document!.ignoreAspect || this._document!.fitWidth ? this._panelHeight : Math.min(this._panelHeight, Math.max(NumCast(this._document!.height), this.nativeHeight())); - nativeWidth = () => !this._document!.ignoreAspect ? NumCast(this._document!.nativeWidth) || this._panelWidth : 0; - nativeHeight = () => !this._document!.ignoreAspect ? NumCast(this._document!.nativeHeight) || this._panelHeight : 0; + nativeWidth = () => !this._document!.ignoreAspect && !this._document!.fitWidth ? NumCast(this._document!.nativeWidth) || this._panelWidth : 0; + nativeHeight = () => !this._document!.ignoreAspect && !this._document!.fitWidth ? NumCast(this._document!.nativeHeight) || this._panelHeight : 0; contentScaling = () => { + if (this._document!.type === DocumentType.PDF) { + if ((this._document && this._document.fitWidth) || + this._panelHeight / NumCast(this._document!.nativeHeight) > this._panelWidth / NumCast(this._document!.nativeWidth)) { + return this._panelWidth / NumCast(this._document!.nativeWidth); + } else { + return this._panelHeight / NumCast(this._document!.nativeHeight); + } + } const nativeH = this.nativeHeight(); const nativeW = this.nativeWidth(); if (!nativeW || !nativeH) return 1; @@ -619,6 +635,7 @@ export class DockedFrameRenderer extends React.Component { get previewPanelCenteringOffset() { return this.nativeWidth() && !BoolCast(this._document!.ignoreAspect) ? (this._panelWidth - this.nativeWidth() / this.ScreenToLocalTransform().Scale) / 2 : 0; } addDocTab = (doc: Doc, dataDoc: Opt, location: string) => { + SelectionManager.DeselectAll(); if (doc.dockingConfig) { MainView.Instance.openWorkspace(doc); return true; @@ -630,13 +647,10 @@ export class DockedFrameRenderer extends React.Component { return CollectionDockingView.Instance.AddTab(this._stack, doc, dataDoc); } } - @computed get docView() { - if (!this._document) { - return (null); - } - let resolvedDataDoc = this._document.layout instanceof Doc ? this._document : this._dataDoc; - return { getScale={returnOne} />; } - @computed get content() { - return ( -
{ - this._mainCont = ref; - if (ref) { - this._panelWidth = Number(getComputedStyle(ref).width!.replace("px", "")); - this._panelHeight = Number(getComputedStyle(ref).height!.replace("px", "")); - } - })} - style={{ transform: `translate(${this.previewPanelCenteringOffset}px, 0px)` }}> - {this.docView} -
); - } - render() { - if (!this._isActive || !this._document) return null; - let theContent = this.content; - return !this._document ? (null) : - { this._panelWidth = r.offset.width; this._panelHeight = r.offset.height; })}> - {({ measureRef }) =>
- {theContent} -
} -
; + return (!this._isActive || !this._document) ? (null) : + (
this._mainCont = ref} + style={{ + transform: `translate(${this.previewPanelCenteringOffset}px, 0px)`, + height: this._document && this._document.fitWidth ? undefined : "100%" + }}> + {this.docView(this._document)} +
); } } \ No newline at end of file diff --git a/src/client/views/collections/CollectionPDFView.scss b/src/client/views/collections/CollectionPDFView.scss index 50201bae8..62ec8a5be 100644 --- a/src/client/views/collections/CollectionPDFView.scss +++ b/src/client/views/collections/CollectionPDFView.scss @@ -1,26 +1,4 @@ -.collectionPdfView-buttonTray { - top: 15px; - left: 20px; - position: relative; - transform-origin: left top; - position: absolute; -} -.collectionPdfView-thumb { - width: 25px; - height: 25px; - transform-origin: left top; - position: absolute; - background: darkgray; -} - -.collectionPdfView-slider { - width: 25px; - height: 25px; - transform-origin: left top; - position: absolute; - background: lightgray; -} .collectionPdfView-cont { width: 100%; @@ -29,28 +7,5 @@ top: 0; left: 0; z-index: -1; + overflow: hidden !important; } - -.collectionPdfView-cont-dragging { - span { - user-select: none; - } -} - -.collectionPdfView-backward { - color: white; - font-size: 24px; - top: 0px; - left: 0px; - position: absolute; - background-color: rgba(50, 50, 50, 0.2); -} - -.collectionPdfView-forward { - color: white; - font-size: 24px; - top: 0px; - left: 45px; - position: absolute; - background-color: rgba(50, 50, 50, 0.2); -} \ No newline at end of file diff --git a/src/client/views/collections/CollectionPDFView.tsx b/src/client/views/collections/CollectionPDFView.tsx index 8eda4d9ee..cc8142ec0 100644 --- a/src/client/views/collections/CollectionPDFView.tsx +++ b/src/client/views/collections/CollectionPDFView.tsx @@ -1,10 +1,9 @@ -import { computed } from "mobx"; +import { trace } from "mobx"; import { observer } from "mobx-react"; import { Id } from "../../../new_fields/FieldSymbols"; import { emptyFunction } from "../../../Utils"; import { ContextMenu } from "../ContextMenu"; import { FieldView, FieldViewProps } from "../nodes/FieldView"; -import { PDFBox } from "../nodes/PDFBox"; import { CollectionBaseView, CollectionRenderProps, CollectionViewType } from "./CollectionBaseView"; import { CollectionFreeFormView } from "./collectionFreeForm/CollectionFreeFormView"; import "./CollectionPDFView.scss"; @@ -17,35 +16,18 @@ export class CollectionPDFView extends React.Component { return FieldView.LayoutString(CollectionPDFView, fieldKey, fieldExt); } - private _pdfBox?: PDFBox; - private _buttonTray: React.RefObject = React.createRef(); - - @computed - get uIButtons() { - return ( -
- - -
- ); - } - onContextMenu = (e: React.MouseEvent): void => { if (!e.isPropagationStopped() && this.props.Document[Id] !== "mainDoc") { // need to test this because GoldenLayout causes a parallel hierarchy in the React DOM for its children and the main document view7 ContextMenu.Instance.addItem({ description: "PDFOptions", event: emptyFunction, icon: "file-pdf" }); } } - setPdfBox = (pdfBox: PDFBox) => { this._pdfBox = pdfBox; }; - subView = (_type: CollectionViewType, renderProps: CollectionRenderProps) => { - return (<> - - {renderProps.active() ? this.uIButtons : (null)} - ); + return (); } render() { + trace(); return ( {this.subView} diff --git a/src/client/views/collections/CollectionSchemaCells.tsx b/src/client/views/collections/CollectionSchemaCells.tsx index 4dac27e60..179e44266 100644 --- a/src/client/views/collections/CollectionSchemaCells.tsx +++ b/src/client/views/collections/CollectionSchemaCells.tsx @@ -34,7 +34,7 @@ export interface CellProps { row: number; col: number; rowProps: CellInfo; - CollectionView: CollectionView | CollectionPDFView | CollectionVideoView; + CollectionView: Opt; ContainingCollection: Opt; Document: Doc; fieldKey: string; @@ -151,7 +151,7 @@ export class CollectionSchemaCell extends React.Component { fieldExt: "", ruleProvider: undefined, ContainingCollectionView: this.props.CollectionView, - ContainingCollectionDoc: this.props.CollectionView.props.Document, + ContainingCollectionDoc: this.props.CollectionView && this.props.CollectionView.props.Document, isSelected: returnFalse, select: emptyFunction, renderDepth: this.props.renderDepth + 1, @@ -301,7 +301,7 @@ export class CollectionSchemaCheckboxCell extends CollectionSchemaCell { render() { let reference = React.createRef(); let onItemDown = (e: React.PointerEvent) => { - (!this.props.CollectionView.props.isSelected() ? undefined : + (!this.props.CollectionView || !this.props.CollectionView.props.isSelected() ? undefined : SetupDrag(reference, () => this._document, this.props.moveDocument, this.props.Document.schemaDoc ? "copy" : undefined)(e)); }; return ( diff --git a/src/client/views/collections/CollectionSchemaView.tsx b/src/client/views/collections/CollectionSchemaView.tsx index 7bd2a1971..8d931f812 100644 --- a/src/client/views/collections/CollectionSchemaView.tsx +++ b/src/client/views/collections/CollectionSchemaView.tsx @@ -246,7 +246,7 @@ export interface SchemaTableProps { PanelHeight: () => number; PanelWidth: () => number; childDocs?: Doc[]; - CollectionView: CollectionView | CollectionPDFView | CollectionVideoView; + CollectionView: Opt; ContainingCollectionView: Opt; ContainingCollectionDoc: Opt; fieldKey: string; @@ -804,7 +804,7 @@ export class SchemaTable extends React.Component { csv.substring(0, csv.length - 1); let dbName = StrCast(this.props.Document.title); let res = await Gateway.Instance.PostSchema(csv, dbName); - if (self.props.CollectionView.props.addDocument) { + if (self.props.CollectionView && self.props.CollectionView.props.addDocument) { let schemaDoc = await Docs.Create.DBDocument("https://www.cs.brown.edu/" + dbName, { title: dbName }, { dbDoc: self.props.Document }); if (schemaDoc) { //self.props.CollectionView.props.addDocument(schemaDoc, false); diff --git a/src/client/views/collections/CollectionStackingView.tsx b/src/client/views/collections/CollectionStackingView.tsx index ccf131797..45de0fefa 100644 --- a/src/client/views/collections/CollectionStackingView.tsx +++ b/src/client/views/collections/CollectionStackingView.tsx @@ -42,7 +42,7 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { @computed get gridGap() { return NumCast(this.props.Document.gridGap, 10); } @computed get isStackingView() { return BoolCast(this.props.Document.singleColumn, true); } @computed get numGroupColumns() { return this.isStackingView ? Math.max(1, this.Sections.size + (this.showAddAGroup ? 1 : 0)) : 1; } - @computed get showAddAGroup() { return (this.sectionFilter && (this.props.CollectionView.props.Document.chromeStatus !== 'view-mode' && this.props.CollectionView.props.Document.chromeStatus !== 'disabled')); } + @computed get showAddAGroup() { return (this.sectionFilter && this.props.ContainingCollectionDoc && (this.props.ContainingCollectionDoc.chromeStatus !== 'view-mode' && this.props.ContainingCollectionDoc.chromeStatus !== 'disabled')); } @computed get columnWidth() { return Math.min(this.props.PanelWidth() / (this.props as any).ContentScaling() - 2 * this.xMargin, this.isStackingView ? Number.MAX_VALUE : NumCast(this.props.Document.columnWidth, 250)); @@ -160,13 +160,13 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { if (!d) return 0; let nw = NumCast(d.nativeWidth); let nh = NumCast(d.nativeHeight); - if (!d.ignoreAspect && nw && nh) { + if (!d.ignoreAspect && !d.fitWidth && nw && nh) { let aspect = nw && nh ? nh / nw : 1; let wid = this.columnWidth / (this.isStackingView ? this.numGroupColumns : 1); if (!(d.nativeWidth && !d.ignoreAspect && this.props.Document.fillColumn)) wid = Math.min(d[WidthSym](), wid); return wid * aspect; } - return d[HeightSym](); + return d.fitWidth ? Math.min(this.props.PanelHeight() - 2 * this.yMargin, d[HeightSym]()) : d[HeightSym](); } columnDividerDown = (e: React.PointerEvent) => { @@ -347,7 +347,7 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { } onToggle = (checked: Boolean) => { - this.props.CollectionView.props.Document.chromeStatus = checked ? "collapsed" : "view-mode"; + this.props.ContainingCollectionDoc && (this.props.ContainingCollectionDoc.chromeStatus = checked ? "collapsed" : "view-mode"); } onContextMenu = (e: React.MouseEvent): void => { @@ -391,10 +391,10 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { style={{ width: this.columnWidth / this.numGroupColumns - 10, marginTop: 10 }}>
} - {this.props.CollectionView.props.Document.chromeStatus !== 'disabled' ? : null} diff --git a/src/client/views/collections/CollectionStackingViewFieldColumn.tsx b/src/client/views/collections/CollectionStackingViewFieldColumn.tsx index b3b7b40dd..240adf428 100644 --- a/src/client/views/collections/CollectionStackingViewFieldColumn.tsx +++ b/src/client/views/collections/CollectionStackingViewFieldColumn.tsx @@ -266,7 +266,7 @@ export class CollectionStackingViewFieldColumn extends React.Component {/* the default bucket (no key value) has a tooltip that describes what it is. Further, it does not have a color and cannot be deleted. */} @@ -297,7 +297,7 @@ export class CollectionStackingViewFieldColumn extends React.Component : (null); for (let i = 0; i < cols; i++) templatecols += `${style.columnWidth / style.numGroupColumns}px `; return ( -
{headingView}
- {(this.props.parent.props.CollectionView.props.Document.chromeStatus !== 'view-mode' && this.props.parent.props.CollectionView.props.Document.chromeStatus !== 'disabled') ? + {(this.props.parent.props.ContainingCollectionDoc && this.props.parent.props.ContainingCollectionDoc.chromeStatus !== 'view-mode' && this.props.parent.props.ContainingCollectionDoc.chromeStatus !== 'disabled') ?
diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index c11dd6150..069269b06 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -1,12 +1,12 @@ import { action, computed, IReactionDisposer, reaction } from "mobx"; import * as rp from 'request-promise'; import CursorField from "../../../new_fields/CursorField"; -import { Doc, DocListCast } from "../../../new_fields/Doc"; +import { Doc, DocListCast, Opt } from "../../../new_fields/Doc"; import { Id } from "../../../new_fields/FieldSymbols"; import { List } from "../../../new_fields/List"; import { listSpec } from "../../../new_fields/Schema"; import { ScriptField } from "../../../new_fields/ScriptField"; -import { BoolCast, Cast } from "../../../new_fields/Types"; +import { Cast } from "../../../new_fields/Types"; import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; import { RouteStore } from "../../../server/RouteStore"; import { Utils } from "../../../Utils"; @@ -30,10 +30,11 @@ export interface CollectionViewProps extends FieldViewProps { PanelWidth: () => number; PanelHeight: () => number; chromeCollapsed: boolean; + setPreviewCursor?: (func: (x: number, y: number, drag: boolean) => void) => void; } export interface SubCollectionViewProps extends CollectionViewProps { - CollectionView: CollectionView | CollectionPDFView | CollectionVideoView; + CollectionView: Opt; ruleProvider: Doc | undefined; } diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index 5f4742834..d3072ff1e 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -18,6 +18,7 @@ import { CollectionSchemaView } from "./CollectionSchemaView"; import { CollectionStackingView } from './CollectionStackingView'; import { CollectionTreeView } from "./CollectionTreeView"; import { CollectionViewBaseChrome } from './CollectionViewChromes'; +import { AddCustomFreeFormLayout } from './collectionFreeForm/CollectionFreeFormLayoutEngines'; export const COLLECTION_BORDER_WIDTH = 2; library.add(faTh, faTree, faSquare, faProjectDiagram, faSignature, faThList, faFingerprint, faColumns, faEllipsisV, faImage, faEye as any, faCopy); @@ -59,8 +60,10 @@ export class CollectionView extends React.Component { case CollectionViewType.Tree: return (); case CollectionViewType.Stacking: { this.props.Document.singleColumn = true; return (); } case CollectionViewType.Masonry: { this.props.Document.singleColumn = false; return (); } + case CollectionViewType.Pivot: { this.props.Document.freeformLayoutEngine = "pivot"; return (); } case CollectionViewType.Freeform: default: + this.props.Document.freeformLayoutEngine = undefined; return (); } return (null); @@ -89,7 +92,7 @@ export class CollectionView extends React.Component { if (!this.isAnnotationOverlay && !e.isPropagationStopped() && this.props.Document[Id] !== CurrentUserUtils.MainDocId) { // need to test this because GoldenLayout causes a parallel hierarchy in the React DOM for its children and the main document view7 let existingVm = ContextMenu.Instance.findByDescription("View Modes..."); let subItems: ContextMenuProps[] = existingVm && "subitems" in existingVm ? existingVm.subitems : []; - subItems.push({ description: "Freeform", event: () => { this.props.Document.viewType = CollectionViewType.Freeform; delete this.props.Document.usePivotLayout; }, icon: "signature" }); + subItems.push({ description: "Freeform", event: () => { this.props.Document.viewType = CollectionViewType.Freeform; }, icon: "signature" }); if (CollectionBaseView.InSafeMode()) { ContextMenu.Instance.addItem({ description: "Test Freeform", event: () => this.props.Document.viewType = CollectionViewType.Invalid, icon: "project-diagram" }); } @@ -103,10 +106,10 @@ export class CollectionView extends React.Component { }, icon: "ellipsis-v" }); subItems.push({ description: "Masonry", event: () => this.props.Document.viewType = CollectionViewType.Masonry, icon: "columns" }); + subItems.push({ description: "Pivot", event: () => this.props.Document.viewType = CollectionViewType.Pivot, icon: "columns" }); switch (this.props.Document.viewType) { case CollectionViewType.Freeform: { - subItems.push({ description: "Custom", icon: "fingerprint", event: CollectionFreeFormView.AddCustomLayout(this.props.Document, this.props.fieldKey) }); - subItems.push({ description: "Pivot", icon: "copy", event: () => this.props.Document.usePivotLayout = true }); + subItems.push({ description: "Custom", icon: "fingerprint", event: AddCustomFreeFormLayout(this.props.Document, this.props.fieldKey) }); break; } } diff --git a/src/client/views/collections/CollectionViewChromes.tsx b/src/client/views/collections/CollectionViewChromes.tsx index 20786f690..cefa9eebc 100644 --- a/src/client/views/collections/CollectionViewChromes.tsx +++ b/src/client/views/collections/CollectionViewChromes.tsx @@ -260,7 +260,7 @@ export class CollectionViewBaseChrome extends React.Component { - if (!this.document.usePivotLayout) { + if (StrCast(this.document.freeformLayoutEngine) !== "pivot") { return (null); } return (Tree View +
ViewDefResult[]) { + let layoutPoolData: Map<{ layout: Doc, data?: Doc }, any> = new Map(); + const pivotAxisWidth = NumCast(pivotDoc.pivotWidth, 200); + const pivotColumnGroups = new Map, Doc[]>(); + + for (const doc of childDocs) { + const val = doc[StrCast(pivotDoc.pivotField, "title")]; + if (val) { + !pivotColumnGroups.get(val) && pivotColumnGroups.set(val, []); + pivotColumnGroups.get(val)!.push(doc); + } + } + + const minSize = Array.from(pivotColumnGroups.entries()).reduce((min, pair) => Math.min(min, pair[1].length), Infinity); + const numCols = NumCast(pivotDoc.pivotNumColumns, Math.ceil(Math.sqrt(minSize))); + const docMap = new Map(); + const groupNames: PivotData[] = []; + + let x = 0; + pivotColumnGroups.forEach((val, key) => { + let y = 0; + let xCount = 0; + groupNames.push({ + type: "text", + text: String(key), + x, + y: pivotAxisWidth + 50, + width: pivotAxisWidth * 1.25 * numCols, + height: 100, + fontSize: NumCast(pivotDoc.pivotFontSize, 10) + }); + for (const doc of val) { + docMap.set(doc, { + x: x + xCount * pivotAxisWidth * 1.25, + y: -y, + width: pivotAxisWidth, + height: doc.nativeWidth ? (NumCast(doc.nativeHeight) / NumCast(doc.nativeWidth)) * pivotAxisWidth : pivotAxisWidth + }); + xCount++; + if (xCount >= numCols) { + xCount = 0; + y += pivotAxisWidth * 1.25; + } + } + x += pivotAxisWidth * 1.25 * (numCols + 1); + }); + + childPairs.map(pair => { + let defaultPosition = { + x: NumCast(pair.layout.x), + y: NumCast(pair.layout.y), + z: NumCast(pair.layout.z), + width: NumCast(pair.layout.width), + height: NumCast(pair.layout.height) + }; + const pos = docMap.get(pair.layout) || defaultPosition; + layoutPoolData.set(pair, { transition: "transform 1s", ...pos }); + }); + return { map: layoutPoolData, elements: viewDefsToJSX(groupNames) }; +} + +export function AddCustomFreeFormLayout(doc: Doc, dataKey: string): () => void { + return () => { + let addOverlay = (key: "arrangeScript" | "arrangeInit", options: OverlayElementOptions, params?: Record, requiredType?: string) => { + let overlayDisposer: () => void = emptyFunction; // filled in below after we have a reference to the scriptingBox + const scriptField = Cast(doc[key], ScriptField); + let scriptingBox = overlayDisposer()} // don't get rid of the function wrapper-- we don't want to use the current value of overlayDiposer, but the one set below + onSave={(text, onError) => { + const script = CompileScript(text, { params, requiredType, typecheck: false }); + if (!script.compiled) { + onError(script.errors.map(error => error.messageText).join("\n")); + } else { + doc[key] = new ScriptField(script); + overlayDisposer(); + } + }} />; + overlayDisposer = OverlayView.Instance.addWindow(scriptingBox, options); + }; + addOverlay("arrangeInit", { x: 400, y: 100, width: 400, height: 300, title: "Layout Initialization" }, { collection: "Doc", docs: "Doc[]" }, undefined); + addOverlay("arrangeScript", { x: 400, y: 500, width: 400, height: 300, title: "Layout Script" }, { doc: "Doc", index: "number", collection: "Doc", state: "any", docs: "Doc[]" }, "{x: number, y: number, width?: number, height?: number}"); + }; +} diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 5157d0c75..c6e8d7cf7 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -1,22 +1,23 @@ import { library } from "@fortawesome/fontawesome-svg-core"; import { faEye } from "@fortawesome/free-regular-svg-icons"; import { faBraille, faChalkboard, faCompass, faCompressArrowsAlt, faExpandArrowsAlt, faPaintBrush, faTable, faUpload } from "@fortawesome/free-solid-svg-icons"; -import { action, computed, IReactionDisposer, observable, reaction, trace } from "mobx"; +import { action, computed, observable } from "mobx"; import { observer } from "mobx-react"; -import { Doc, DocListCastAsync, Field, FieldResult, HeightSym, Opt, WidthSym, DocListCast } from "../../../../new_fields/Doc"; +import { Doc, DocListCast, HeightSym, Opt, WidthSym } from "../../../../new_fields/Doc"; import { Id } from "../../../../new_fields/FieldSymbols"; import { InkField, StrokeData } from "../../../../new_fields/InkField"; import { createSchema, makeInterface } from "../../../../new_fields/Schema"; import { ScriptField } from "../../../../new_fields/ScriptField"; -import { BoolCast, Cast, FieldValue, NumCast, StrCast, PromiseValue, DateCast } from "../../../../new_fields/Types"; -import { emptyFunction, returnEmptyString, returnOne, Utils } from "../../../../Utils"; +import { BoolCast, Cast, DateCast, NumCast, StrCast } from "../../../../new_fields/Types"; +import { CurrentUserUtils } from "../../../../server/authentication/models/current_user_utils"; +import { aggregateBounds, emptyFunction, intersectRect, returnEmptyString, returnOne, Utils } from "../../../../Utils"; import { CognitiveServices } from "../../../cognitive_services/CognitiveServices"; +import { DocServer } from "../../../DocServer"; import { Docs } from "../../../documents/Documents"; import { DocumentType } from "../../../documents/DocumentTypes"; import { DocumentManager } from "../../../util/DocumentManager"; import { DragManager } from "../../../util/DragManager"; import { HistoryUtil } from "../../../util/History"; -import { CompileScript } from "../../../util/Scripting"; import { SelectionManager } from "../../../util/SelectionManager"; import { Transform } from "../../../util/Transform"; import { undoBatch, UndoManager } from "../../../util/UndoManager"; @@ -26,12 +27,12 @@ import { ContextMenuProps } from "../../ContextMenuItem"; import { InkingCanvas } from "../../InkingCanvas"; import { CollectionFreeFormDocumentView, positionSchema } from "../../nodes/CollectionFreeFormDocumentView"; import { DocumentContentsView } from "../../nodes/DocumentContentsView"; -import { DocumentViewProps, documentSchema } from "../../nodes/DocumentView"; +import { documentSchema, DocumentViewProps } from "../../nodes/DocumentView"; +import { FormattedTextBox } from "../../nodes/FormattedTextBox"; import { pageSchema } from "../../nodes/ImageBox"; -import { OverlayElementOptions, OverlayView } from "../../OverlayView"; import PDFMenu from "../../pdf/PDFMenu"; -import { ScriptBox } from "../../ScriptBox"; import { CollectionSubView } from "../CollectionSubView"; +import { computePivotLayout, ViewDefResult } from "./CollectionFreeFormLayoutEngines"; import { CollectionFreeFormLinksView } from "./CollectionFreeFormLinksView"; import { CollectionFreeFormRemoteCursors } from "./CollectionFreeFormRemoteCursors"; import "./CollectionFreeFormView.scss"; @@ -40,9 +41,6 @@ import React = require("react"); import v5 = require("uuid/v5"); import { Timeline } from "../../animationtimeline/Timeline"; import { number } from "prop-types"; -import { DocServer } from "../../../DocServer"; -import { FormattedTextBox } from "../../nodes/FormattedTextBox"; -import { CurrentUserUtils } from "../../../../server/authentication/models/current_user_utils"; library.add(faEye as any, faTable, faPaintBrush, faExpandArrowsAlt, faCompressArrowsAlt, faCompass, faUpload, faBraille, faChalkboard); @@ -54,132 +52,10 @@ export const panZoomSchema = createSchema({ arrangeInit: ScriptField, useClusters: "boolean", isRuleProvider: "boolean", - fitToBox: "boolean" + fitToBox: "boolean", + panTransformType: "string", }); -export interface ViewDefBounds { - x: number; - y: number; - z?: number; - width: number; - height: number; -} - -export interface ViewDefResult { - ele: JSX.Element; - bounds?: ViewDefBounds; -} - -export namespace PivotView { - - export interface PivotData { - type: string; - text: string; - x: number; - y: number; - width: number; - height: number; - fontSize: number; - } - - export const elements = (target: CollectionFreeFormView) => { - let collection = target.Document; - const field = StrCast(collection.pivotField) || "title"; - const width = NumCast(collection.pivotWidth) || 200; - const groups = new Map, Doc[]>(); - - for (const doc of target.childDocs) { - const val = doc[field]; - if (val === undefined) continue; - - const l = groups.get(val); - if (l) { - l.push(doc); - } else { - groups.set(val, [doc]); - } - } - - let minSize = Infinity; - - groups.forEach((val, key) => minSize = Math.min(minSize, val.length)); - - const numCols = NumCast(collection.pivotNumColumns) || Math.ceil(Math.sqrt(minSize)); - const fontSize = NumCast(collection.pivotFontSize); - - const docMap = new Map(); - const groupNames: PivotData[] = []; - - let x = 0; - groups.forEach((val, key) => { - let y = 0; - let xCount = 0; - groupNames.push({ - type: "text", - text: String(key), - x, - y: width + 50, - width: width * 1.25 * numCols, - height: 100, fontSize: fontSize - }); - for (const doc of val) { - docMap.set(doc, { - x: x + xCount * width * 1.25, - y: -y, - width, - height: width - }); - xCount++; - if (xCount >= numCols) { - xCount = 0; - y += width * 1.25; - } - } - x += width * 1.25 * (numCols + 1); - }); - - let elements = target.viewDefsToJSX(groupNames); - let docViews = target.childDocs.reduce((prev, doc) => { - let minim = BoolCast(doc.isMinimized); - if (minim === undefined || !minim) { - let defaultPosition = (): ViewDefBounds => { - return { - x: NumCast(doc.x), - y: NumCast(doc.y), - z: NumCast(doc.z), - width: NumCast(doc.width), - height: NumCast(doc.height) - }; - }; - const pos = docMap.get(doc) || defaultPosition(); - prev.push({ - ele: , - bounds: { - x: pos.x, - y: pos.y, - z: pos.z, - width: NumCast(pos.width), - height: NumCast(pos.height) - } - }); - } - return prev; - }, elements); - - return docViews; - }; - -} - type PanZoomDocument = makeInterface<[typeof panZoomSchema, typeof documentSchema, typeof positionSchema, typeof pageSchema]>; const PanZoomDocument = makeInterface(panZoomSchema, documentSchema, positionSchema, pageSchema); @@ -187,50 +63,26 @@ const PanZoomDocument = makeInterface(panZoomSchema, documentSchema, positionSch export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { private _lastX: number = 0; private _lastY: number = 0; - private get _pwidth() { return this.props.PanelWidth(); } - private get _pheight() { return this.props.PanelHeight(); } - private _timelineRef = React.createRef(); - private get parentScaling() { - return (this.props as any).ContentScaling && this.fitToBox && !this.isAnnotationOverlay ? (this.props as any).ContentScaling() : 1; - } - - ComputeContentBounds(boundsList: { x: number, y: number, width: number, height: number }[]) { - let bounds = boundsList.reduce((bounds, b) => { - var [sptX, sptY] = [b.x, b.y]; - let [bptX, bptY] = [sptX + NumCast(b.width, 1), sptY + NumCast(b.height, 1)]; - return { - x: Math.min(sptX, bounds.x), y: Math.min(sptY, bounds.y), - r: Math.max(bptX, bounds.r), b: Math.max(bptY, bounds.b) - }; - }, { x: Number.MAX_VALUE, y: Number.MAX_VALUE, r: -Number.MAX_VALUE, b: -Number.MAX_VALUE }); - return bounds; - } - - @computed get actualContentBounds() { - return this.fitToBox && !this.isAnnotationOverlay ? this.ComputeContentBounds(this.elements.filter(e => e.bounds && !e.bounds.z).map(e => e.bounds!)) : undefined; - } - - @computed get contentBounds() { - let bounds = this.actualContentBounds; - let res = { - panX: bounds ? (bounds.x + bounds.r) / 2 : this.Document.panX || 0, - panY: bounds ? (bounds.y + bounds.b) / 2 : this.Document.panY || 0, - scale: (bounds ? Math.min(this.props.PanelHeight() / (bounds.b - bounds.y), this.props.PanelWidth() / (bounds.r - bounds.x)) : this.Document.scale || 1) / this.parentScaling - }; - if (res.scale === 0) res.scale = 1; - return res; - } - - @computed get fitToBox() { return this.props.fitToBox || this.Document.fitToBox; } - @computed get nativeWidth() { return this.fitToBox ? 0 : this.Document.nativeWidth || 0; } - @computed get nativeHeight() { return this.fitToBox ? 0 : this.Document.nativeHeight || 0; } - public get isAnnotationOverlay() { return this.props.fieldExt ? true : false; } // fieldExt will be "" or "annotation". should maybe generalize this, or make it more specific (ie, 'annotation' instead of 'fieldExt') + private _clusterDistance: number = 75; + private _hitCluster = false; + @observable _clusterSets: (Doc[])[] = []; + @observable _timelineRef = React.createRef(); + + @computed get fitToContent() { return (this.props.fitToBox || this.Document.fitToBox) && !this.isAnnotationOverlay; } + @computed get parentScaling() { return this.props.ContentScaling && this.fitToContent && !this.isAnnotationOverlay ? this.props.ContentScaling() : 1; } + @computed get contentBounds() { return aggregateBounds(this.elements.filter(e => e.bounds && !e.bounds.z).map(e => e.bounds!)); } + @computed get nativeWidth() { return this.fitToContent ? 0 : this.Document.nativeWidth || 0; } + @computed get nativeHeight() { return this.fitToContent ? 0 : this.Document.nativeHeight || 0; } + private get isAnnotationOverlay() { return this.props.fieldExt ? true : false; } // fieldExt will be "" or "annotation". should maybe generalize this, or make it more specific (ie, 'annotation' instead of 'fieldExt') private get borderWidth() { return this.isAnnotationOverlay ? 0 : COLLECTION_BORDER_WIDTH; } - private panX = () => this.contentBounds.panX; - private panY = () => this.contentBounds.panY; - private zoomScaling = () => this.contentBounds.scale; - private centeringShiftX = () => !this.nativeWidth && !this.isAnnotationOverlay ? this._pwidth / 2 / this.parentScaling : 0; // shift so pan position is at center of window for non-overlay collections - private centeringShiftY = () => !this.nativeHeight && !this.isAnnotationOverlay ? this._pheight / 2 / this.parentScaling : 0;// shift so pan position is at center of window for non-overlay collections + private easing = () => this.props.Document.panTransformType === "Ease"; + private panX = () => this.fitToContent ? (this.contentBounds.x + this.contentBounds.r) / 2 : this.Document.panX || 0; + private panY = () => this.fitToContent ? (this.contentBounds.y + this.contentBounds.b) / 2 : this.Document.panY || 0; + private zoomScaling = () => (1 / this.parentScaling) * (this.fitToContent ? + Math.min(this.props.PanelHeight() / (this.contentBounds.b - this.contentBounds.y), this.props.PanelWidth() / (this.contentBounds.r - this.contentBounds.x)) : + this.Document.scale || 1); + private centeringShiftX = () => !this.nativeWidth && !this.isAnnotationOverlay ? this.props.PanelWidth() / 2 / this.parentScaling : 0; // shift so pan position is at center of window for non-overlay collections + private centeringShiftY = () => !this.nativeHeight && !this.isAnnotationOverlay ? this.props.PanelHeight() / 2 / this.parentScaling : 0;// shift so pan position is at center of window for non-overlay collections private getTransform = (): Transform => this.props.ScreenToLocalTransform().translate(-this.borderWidth + 1, -this.borderWidth + 1).translate(-this.centeringShiftX(), -this.centeringShiftY()).transform(this.getLocalTransform()); private getTransformOverlay = (): Transform => this.props.ScreenToLocalTransform().translate(-this.borderWidth + 1, -this.borderWidth + 1); private getContainerTransform = (): Transform => this.props.ScreenToLocalTransform().translate(-this.borderWidth, -this.borderWidth); @@ -248,47 +100,31 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { this.addDocument(newBox, false); } private addDocument = (newBox: Doc, allowDuplicates: boolean) => { - this.props.addDocument(newBox, false); - this.bringToFront(newBox); - this.updateCluster(newBox); - return true; + let added = this.props.addDocument(newBox, false); + added && this.bringToFront(newBox); + added && this.updateCluster(newBox); + return added; } private selectDocuments = (docs: Doc[]) => { SelectionManager.DeselectAll(); - docs.map(doc => DocumentManager.Instance.getDocumentView(doc)).filter(dv => dv).map(dv => - SelectionManager.SelectDoc(dv!, true)); + docs.map(doc => DocumentManager.Instance.getDocumentView(doc)).map(dv => dv && SelectionManager.SelectDoc(dv, true)); } + public isCurrent(doc: Doc) { return !this.props.Document.isMinimized && (Math.abs(NumCast(doc.page, -1) - NumCast(this.Document.curPage, -1)) < 1.5 || NumCast(doc.page, -1) === -1); } + public getActiveDocuments = () => { - const curPage = FieldValue(this.Document.curPage, -1); - return this.childLayoutPairs.filter(pair => { - var page = NumCast(pair.layout!.page, -1); - return page === curPage || page === -1; - }).map(pair => pair.layout); + return this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map(pair => pair.layout); } @computed get fieldExtensionDoc() { return Doc.fieldExtensionDoc(this.props.DataDoc || this.props.Document, this.props.fieldKey); } - intersectRect(r1: { left: number, top: number, width: number, height: number }, - r2: { left: number, top: number, width: number, height: number }) { - return !(r2.left > r1.left + r1.width || r2.left + r2.width < r1.left || r2.top > r1.top + r1.height || r2.top + r2.height < r1.top); - } - _clusterDistance = 75; - boundsOverlap(doc: Doc, doc2: Doc) { - var x2 = NumCast(doc2.x) - this._clusterDistance; - var y2 = NumCast(doc2.y) - this._clusterDistance; - var w2 = NumCast(doc2.width) + this._clusterDistance; - var h2 = NumCast(doc2.height) + this._clusterDistance; - var x = NumCast(doc.x) - this._clusterDistance; - var y = NumCast(doc.y) - this._clusterDistance; - var w = NumCast(doc.width) + this._clusterDistance; - var h = NumCast(doc.height) + this._clusterDistance; - if (doc.z === doc2.z && this.intersectRect({ left: x, top: y, width: w, height: h }, { left: x2, top: y2, width: w2, height: h2 })) { - return true; - } - return false; + @action + onDrop = (e: React.DragEvent): void => { + var pt = this.getTransform().transformPoint(e.pageX, e.pageY); + super.onDrop(e, { x: pt[0], y: pt[1] }); } + @undoBatch @action drop = (e: Event, de: DragManager.DropEvent) => { @@ -304,7 +140,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { let y = (z ? ypo : yp) - de.data.offset[1]; let dropX = NumCast(de.data.droppedDocuments[0].x); let dropY = NumCast(de.data.droppedDocuments[0].y); - de.data.droppedDocuments.forEach(d => { + de.data.droppedDocuments.forEach(action((d: Doc) => { d.x = x + NumCast(d.x) - dropX; d.y = y + NumCast(d.y) - dropY; if (!NumCast(d.width)) { @@ -316,7 +152,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { d.height = nw && nh ? nh / nw * NumCast(d.width) : 300; } this.bringToFront(d); - }); + })); de.data.droppedDocuments.length === 1 && this.updateCluster(de.data.droppedDocuments[0]); } @@ -339,18 +175,18 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { return false; } - tryDragCluster(e: PointerEvent) { - let probe = this.getTransform().transformPoint(e.clientX, e.clientY); - let cluster = this.childLayoutPairs.map(pair => pair.layout).reduce((cluster, cd) => { + pickCluster(probe: number[]) { + return this.childLayoutPairs.map(pair => pair.layout).reduce((cluster, cd) => { let cx = NumCast(cd.x) - this._clusterDistance; let cy = NumCast(cd.y) - this._clusterDistance; let cw = NumCast(cd.width) + 2 * this._clusterDistance; let ch = NumCast(cd.height) + 2 * this._clusterDistance; - if (!cd.z && this.intersectRect({ left: cx, top: cy, width: cw, height: ch }, { left: probe[0], top: probe[1], width: 1, height: 1 })) { - return NumCast(cd.cluster); - } - return cluster; + return !cd.z && intersectRect({ left: cx, top: cy, width: cw, height: ch }, { left: probe[0], top: probe[1], width: 1, height: 1 }) ? + NumCast(cd.cluster) : cluster; }, -1); + } + tryDragCluster(e: PointerEvent) { + let cluster = this.pickCluster(this.getTransform().transformPoint(e.clientX, e.clientY)); if (cluster !== -1) { let eles = this.childLayoutPairs.map(pair => pair.layout).filter(cd => NumCast(cd.cluster) === cluster); @@ -375,36 +211,12 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { return false; } - @observable sets: (Doc[])[] = []; @undoBatch - @action updateClusters(useClusters: boolean) { - this.Document.useClusters = useClusters; - this.sets.length = 0; - this.childLayoutPairs.map(pair => pair.layout).map(c => { - let included = []; - for (let i = 0; i < this.sets.length; i++) { - for (let member of this.sets[i]) { - if (this.boundsOverlap(c, member)) { - included.push(i); - break; - } - } - } - if (included.length === 0) { - this.sets.push([c]); - } else if (included.length === 1) { - this.sets[included[0]].push(c); - } else { - this.sets[included[0]].push(c); - for (let s = 1; s < included.length; s++) { - this.sets[included[0]].push(...this.sets[included[s]]); - this.sets[included[s]].length = 0; - } - } - }); - this.sets.map((set, i) => set.map(member => member.cluster = i)); + this.props.Document.useClusters = useClusters; + this._clusterSets.length = 0; + this.childLayoutPairs.map(pair => pair.layout).map(c => this.updateCluster(c)); } @undoBatch @@ -412,28 +224,28 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { updateCluster(doc: Doc) { let childLayouts = this.childLayoutPairs.map(pair => pair.layout); if (this.props.Document.useClusters) { - this.sets.map(set => Doc.IndexOf(doc, set) !== -1 && set.splice(Doc.IndexOf(doc, set), 1)); + this._clusterSets.map(set => Doc.IndexOf(doc, set) !== -1 && set.splice(Doc.IndexOf(doc, set), 1)); let preferredInd = NumCast(doc.cluster); doc.cluster = -1; - this.sets.map((set, i) => set.map(member => { - if (doc.cluster === -1 && Doc.IndexOf(member, childLayouts) !== -1 && this.boundsOverlap(doc, member)) { + this._clusterSets.map((set, i) => set.map(member => { + if (doc.cluster === -1 && Doc.IndexOf(member, childLayouts) !== -1 && Doc.overlapping(doc, member, this._clusterDistance)) { doc.cluster = i; } })); - if (doc.cluster === -1 && preferredInd !== -1 && (!this.sets[preferredInd] || !this.sets[preferredInd].filter(member => Doc.IndexOf(member, childLayouts) !== -1).length)) { + if (doc.cluster === -1 && preferredInd !== -1 && (!this._clusterSets[preferredInd] || !this._clusterSets[preferredInd].filter(member => Doc.IndexOf(member, childLayouts) !== -1).length)) { doc.cluster = preferredInd; } - this.sets.map((set, i) => { + this._clusterSets.map((set, i) => { if (doc.cluster === -1 && !set.filter(member => Doc.IndexOf(member, childLayouts) !== -1).length) { doc.cluster = i; } }); if (doc.cluster === -1) { - doc.cluster = this.sets.length; - this.sets.push([doc]); + doc.cluster = this._clusterSets.length; + this._clusterSets.push([doc]); } else { - for (let i = this.sets.length; i <= doc.cluster; i++) !this.sets[i] && this.sets.push([]); - this.sets[doc.cluster].push(doc); + for (let i = this._clusterSets.length; i <= doc.cluster; i++) !this._clusterSets[i] && this._clusterSets.push([]); + this._clusterSets[doc.cluster].push(doc); } } } @@ -442,13 +254,13 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { let clusterColor = ""; let cluster = NumCast(doc.cluster); if (this.Document.useClusters) { - if (this.sets.length <= cluster) { + if (this._clusterSets.length <= cluster) { setTimeout(() => this.updateCluster(doc), 0); } else { // choose a cluster color from a palette let colors = ["#da42429e", "#31ea318c", "#8c4000", "#4a7ae2c4", "#d809ff", "#ff7601", "#1dffff", "yellow", "#1b8231f2", "#000000ad"]; clusterColor = colors[cluster % colors.length]; - let set = this.sets.length > cluster ? this.sets[cluster].filter(s => s.backgroundColor && (s.backgroundColor !== s.defaultBackgroundColor)) : undefined; + let set = this._clusterSets[cluster] && this._clusterSets[cluster].filter(s => s.backgroundColor && (s.backgroundColor !== s.defaultBackgroundColor)); // override the cluster color with an explicitly set color on a non-background document. then override that with an explicitly set color on a background document set && set.filter(s => !s.isBackground).map(s => clusterColor = StrCast(s.backgroundColor)); set && set.filter(s => s.isBackground).map(s => clusterColor = StrCast(s.backgroundColor)); @@ -459,6 +271,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { @action onPointerDown = (e: React.PointerEvent): void => { + this._hitCluster = this.props.Document.useClusters ? this.pickCluster(this.getTransform().transformPoint(e.clientX, e.clientY)) !== -1 : false; if (e.button === 0 && !e.shiftKey && !e.altKey && (!this.isAnnotationOverlay || this.zoomScaling() !== 1) && this.props.active()) { document.removeEventListener("pointermove", this.onPointerMove); document.removeEventListener("pointerup", this.onPointerUp); @@ -476,8 +289,8 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { @action onPointerMove = (e: PointerEvent): void => { - if (!e.cancelBubble) { - if (this.props.Document.useClusters && this.tryDragCluster(e)) { + if (!e.cancelBubble && !this.isAnnotationOverlay) { + if (this._hitCluster && this.tryDragCluster(e)) { e.stopPropagation(); // doesn't actually stop propagation since all our listeners are listening to events on 'document' however it does mark the event as cancelBubble=true which we test for in the move event handlers e.preventDefault(); document.removeEventListener("pointermove", this.onPointerMove); @@ -512,8 +325,8 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } let cscale = this.props.ContainingCollectionDoc ? NumCast(this.props.ContainingCollectionDoc.scale) : 1; - let panelDim = this.props.ScreenToLocalTransform().transformDirection(this._pwidth / this.zoomScaling() * cscale, - this._pheight / this.zoomScaling() * cscale); + let panelDim = this.props.ScreenToLocalTransform().transformDirection(this.props.PanelWidth() / this.zoomScaling() * cscale, + this.props.PanelHeight() / this.zoomScaling() * cscale); if (ranges[0][0] - dx > (this.panX() + panelDim[0] / 2)) x = ranges[0][1] + panelDim[0] / 2; if (ranges[0][1] - dx < (this.panX() - panelDim[0] / 2)) x = ranges[0][0] - panelDim[0] / 2; if (ranges[1][0] - dy > (this.panY() + panelDim[1] / 2)) y = ranges[1][1] + panelDim[1] / 2; @@ -529,7 +342,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { @action onPointerWheel = (e: React.WheelEvent): void => { - if (BoolCast(this.props.Document.lockedPosition)) return; + if (this.props.Document.lockedPosition || this.isAnnotationOverlay) return; if (!e.ctrlKey && this.props.Document.scrollHeight !== undefined) { // things that can scroll vertically should do that instead of zooming e.stopPropagation(); } @@ -546,13 +359,12 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { let safeScale = Math.min(Math.max(0.15, localTransform.Scale), 40); this.props.Document.scale = Math.abs(safeScale); this.setPan(-localTransform.TranslateX / safeScale, -localTransform.TranslateY / safeScale); - e.preventDefault(); } } @action setPan(panX: number, panY: number) { - if (!BoolCast(this.props.Document.lockedPosition)) { + if (!this.props.Document.lockedPosition) { this.props.Document.panTransformType = "None"; var scale = this.getLocalTransform().inverse().Scale; const newPanX = Math.min((1 - 1 / scale) * this.nativeWidth, Math.max(0, panX)); @@ -562,12 +374,6 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } } - @action - onDrop = (e: React.DragEvent): void => { - var pt = this.getTransform().transformPoint(e.pageX, e.pageY); - super.onDrop(e, { x: pt[0], y: pt[1] }); - } - bringToFront = (doc: Doc, sendToBack?: boolean) => { if (sendToBack || doc.isBackground) { doc.zIndex = 0; @@ -599,54 +405,45 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } } SelectionManager.DeselectAll(); - const newPanX = NumCast(doc.x) + NumCast(doc.width) / 2; - const newPanY = NumCast(doc.y) + NumCast(doc.height) / 2; - const newState = HistoryUtil.getState(); - newState.initializers![this.Document[Id]] = { panX: newPanX, panY: newPanY }; - HistoryUtil.pushState(newState); - - let px = this.Document.panX; - let py = this.Document.panY; - let s = this.Document.scale; - this.setPan(newPanX, newPanY); - - this.props.Document.panTransformType = "Ease"; - this.props.focus(this.props.Document); - if (willZoom) { - this.setScaleToZoom(doc, scale); + if (this.props.Document.scrollHeight) { + let annotOn = Cast(doc.annotationOn, Doc) as Doc; + let offset = annotOn && (NumCast(annotOn.height) / 2); + this.props.Document.scrollY = NumCast(doc.y) - offset; + } else { + const newPanX = NumCast(doc.x) + NumCast(doc.width) / 2; + const newPanY = NumCast(doc.y) + NumCast(doc.height) / 2; + const newState = HistoryUtil.getState(); + newState.initializers![this.Document[Id]] = { panX: newPanX, panY: newPanY }; + HistoryUtil.pushState(newState); + + let savedState = { px: this.Document.panX, py: this.Document.panY, s: this.Document.scale, pt: this.Document.panTransformType }; + + this.setPan(newPanX, newPanY); + this.Document.panTransformType = "Ease"; + this.props.focus(this.props.Document); + willZoom && this.setScaleToZoom(doc, scale); + + afterFocus && setTimeout(() => { + if (afterFocus && afterFocus()) { + this.Document.panX = savedState.px; + this.Document.panY = savedState.py; + this.Document.scale = savedState.s; + this.Document.panTransformType = savedState.pt; + } + }, 1000); } - console.log("Focused " + this.Document.title + " " + s); - afterFocus && setTimeout(() => { - if (afterFocus && afterFocus()) { - console.log("UnFocused " + this.Document.title + " " + s); - this.Document.panX = px; - this.Document.panY = py; - this.Document.scale = s; - } - }, 1000); + } setScaleToZoom = (doc: Doc, scale: number = 0.5) => { - let p = this.props; - let PanelHeight = p.PanelHeight(); - let panelWidth = p.PanelWidth(); - - let docHeight = NumCast(doc.height); - let docWidth = NumCast(doc.width); - let targetHeight = scale * PanelHeight; - let targetWidth = scale * panelWidth; - - let maxScaleX: number = targetWidth / docWidth; - let maxScaleY: number = targetHeight / docHeight; - let maxApplicableScale = Math.min(maxScaleX, maxScaleY); - this.Document.scale = maxApplicableScale; + this.Document.scale = scale * Math.min(this.props.PanelWidth() / NumCast(doc.width), this.props.PanelHeight() / NumCast(doc.height)); } zoomToScale = (scale: number) => { this.Document.scale = scale; } - getScale = () => this.Document.scale ? this.Document.scale : 1; + getScale = () => this.Document.scale || 1; getChildDocumentViewProps(childLayout: Doc, childData?: Doc): DocumentViewProps { return { @@ -663,7 +460,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { PanelHeight: childLayout[HeightSym], ContentScaling: returnOne, ContainingCollectionView: this.props.CollectionView, - ContainingCollectionDoc: this.props.CollectionView.props.Document, + ContainingCollectionDoc: this.props.Document, focus: this.focusDocument, backgroundColor: this.getClusterColor, parentActive: this.props.active, @@ -690,7 +487,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { PanelHeight: layoutDoc[HeightSym], ContentScaling: returnOne, ContainingCollectionView: this.props.CollectionView, - ContainingCollectionDoc: this.props.CollectionView.props.Document, + ContainingCollectionDoc: this.props.ContainingCollectionDoc, focus: this.focusDocument, backgroundColor: returnEmptyString, parentActive: this.props.active, @@ -713,110 +510,109 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } viewDefsToJSX = (views: any[]) => { - let elements: ViewDefResult[] = []; - if (Array.isArray(views)) { - elements = views.reduce((prev, ele) => { - const jsx = this.viewDefToJSX(ele); - jsx && prev.push(jsx); - return prev; - }, elements); - } - return elements; + return !Array.isArray(views) ? [] : views.filter(ele => this.viewDefToJSX(ele)).map(ele => this.viewDefToJSX(ele)!); } private viewDefToJSX(viewDef: any): Opt { if (viewDef.type === "text") { - const text = Cast(viewDef.text, "string"); + const text = Cast(viewDef.text, "string"); // don't use NumCast, StrCast, etc since we want to test for undefined below const x = Cast(viewDef.x, "number"); const y = Cast(viewDef.y, "number"); const z = Cast(viewDef.z, "number"); const width = Cast(viewDef.width, "number"); const height = Cast(viewDef.height, "number"); const fontSize = Cast(viewDef.fontSize, "number"); - if ([text, x, y, width, height].some(val => val === undefined)) { - return undefined; - } - - return { - ele:
{text}
, bounds: { x: x!, y: y!, z: z, width: width!, height: height! } - }; + return [text, x, y, width, height].some(val => val === undefined) ? undefined : + { + ele:
+ {text} +
, + bounds: { x: x!, y: y!, z: z, width: width!, height: height! } + }; } } - @computed.struct - get elements() { - if (this.Document.usePivotLayout) return PivotView.elements(this); - let curPage = FieldValue(this.Document.curPage, -1); - const initScript = this.Document.arrangeInit; - let state: any = undefined; - let pairs = this.childLayoutPairs; - let elements: ViewDefResult[] = []; - if (initScript) { - const initResult = initScript.script.run({ docs: pairs.map(pair => pair.layout), collection: this.Document }, console.log); - if (initResult.success) { - const result = initResult.result; - const { state: scriptState, views } = result; - state = scriptState; - elements = this.viewDefsToJSX(views); - } + lookupLayout = (doc: Doc, dataDoc?: Doc) => { + let data: any = undefined; + let computedElementData: { map: Map<{ layout: Doc, data?: Doc | undefined }, any>, elements: ViewDefResult[] }; + switch (this.Document.freeformLayoutEngine) { + case "pivot": computedElementData = this.doPivotLayout; break; + default: computedElementData = this.doFreeformLayout; break; } - let docviews = pairs.reduce((prev, pair) => { - var page = NumCast(pair.layout.page, -1); - if (!pair.layout.isMinimized && ((Math.abs(Math.round(page) - Math.round(curPage)) < 3) || page === -1)) { - const pos = this.getCalculatedPositions({ doc: pair.layout, index: prev.length, collection: this.Document, docs: pairs.map(pair => pair.layout), state }); - state = pos.state === undefined ? state : pos.state; - prev.push({ - ele: , - bounds: { x: pos.x || 0, y: pos.y || 0, z: pos.z, width: pos.width || 0, height: pos.height || 0 } - }); + computedElementData.map.forEach((value: any, key: { layout: Doc, data?: Doc }) => { + if (key.layout === doc && key.data === dataDoc) { + data = value; } - // } - return prev; - }, elements); + }); + return data && { x: data.x, y: data.y, z: data.z, width: data.width, height: data.height, transition: data.transition }; + } - return docviews; + @computed + get doPivotLayout() { + return computePivotLayout(this.props.Document, this.childDocs, + this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)), this.viewDefsToJSX); } - @computed.struct - get views() { - return this.elements.filter(ele => ele.bounds && !ele.bounds.z).map(ele => ele.ele); + @computed + get doFreeformLayout() { + let layoutPoolData: Map<{ layout: Doc, data?: Doc }, any> = new Map(); + let layoutDocs = this.childLayoutPairs.map(pair => pair.layout); + const initResult = this.Document.arrangeInit && this.Document.arrangeInit.script.run({ docs: layoutDocs, collection: this.Document }, console.log); + let state = initResult && initResult.success ? initResult.result.scriptState : undefined; + let elements = initResult && initResult.success ? this.viewDefsToJSX(initResult.result.views) : []; + + this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map((pair, i) => { + const pos = this.getCalculatedPositions({ doc: pair.layout, index: i, collection: this.Document, docs: layoutDocs, state }); + state = pos.state === undefined ? state : pos.state; + layoutPoolData.set(pair, pos); + }); + return { map: layoutPoolData, elements: elements }; } - @computed.struct - get overlayViews() { - return this.elements.filter(ele => ele.bounds && ele.bounds.z).map(ele => ele.ele); + + @computed + get doLayoutComputation() { + let computedElementData: { map: Map<{ layout: Doc, data?: Doc | undefined }, any>, elements: ViewDefResult[] }; + switch (this.Document.freeformLayoutEngine) { + case "pivot": computedElementData = this.doPivotLayout; break; + default: computedElementData = this.doFreeformLayout; break; + } + this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).forEach(pair => + computedElementData.elements.push({ + ele: , + bounds: this.lookupLayout(pair.layout, pair.data) + })); + + return computedElementData; } + @computed.struct get elements() { return this.doLayoutComputation.elements; } + @computed.struct get views() { return this.elements.filter(ele => ele.bounds && !ele.bounds.z).map(ele => ele.ele); } + @computed.struct get overlayViews() { return this.elements.filter(ele => ele.bounds && ele.bounds.z).map(ele => ele.ele); } + @action onCursorMove = (e: React.PointerEvent) => { super.setCursorPosition(this.getTransform().transformPoint(e.clientX, e.clientY)); } - arrangeContents = async () => { - const docs = await DocListCastAsync(this.Document[this.props.fieldKey]); + layoutDocsInGrid = () => { UndoManager.RunInBatch(() => { - if (docs) { - let startX = this.Document.panX || 0; - let x = startX; - let y = this.Document.panY || 0; - let i = 0; - const width = Math.max(...docs.map(doc => NumCast(doc.width))); - const height = Math.max(...docs.map(doc => NumCast(doc.height))); - for (const doc of docs) { - doc.x = x; - doc.y = y; - x += width + 20; - if (++i === 6) { - i = 0; - x = startX; - y += height + 20; - } + const docs = DocListCast(this.Document[this.props.fieldKey]); + let startX = this.Document.panX || 0; + let x = startX; + let y = this.Document.panY || 0; + let i = 0; + const width = Math.max(...docs.map(doc => NumCast(doc.width))); + const height = Math.max(...docs.map(doc => NumCast(doc.height))); + for (const doc of docs) { + doc.x = x; + doc.y = y; + x += width + 20; + if (++i === 6) { + i = 0; + x = startX; + y += height + 20; } } }, "arrange contents"); @@ -852,10 +648,10 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } this._timelineRef.current!.timelineContextMenu(e.nativeEvent); layoutItems.push({ description: "reset view", event: () => { this.props.Document.panX = this.props.Document.panY = 0; this.props.Document.scale = 1; }, icon: "compress-arrows-alt" }); - layoutItems.push({ description: `${this.fitToBox ? "Unset" : "Set"} Fit To Container`, event: async () => this.Document.fitToBox = !this.fitToBox, icon: !this.fitToBox ? "expand-arrows-alt" : "compress-arrows-alt" }); + layoutItems.push({ description: `${this.fitToContent ? "Unset" : "Set"} Fit To Container`, event: async () => this.Document.fitToBox = !this.fitToContent, icon: !this.fitToContent ? "expand-arrows-alt" : "compress-arrows-alt" }); layoutItems.push({ description: `${this.Document.useClusters ? "Uncluster" : "Use Clusters"}`, event: () => this.updateClusters(!this.Document.useClusters), icon: "braille" }); layoutItems.push({ description: `${this.Document.isRuleProvider ? "Stop Auto Format" : "Auto Format"}`, event: this.autoFormat, icon: "chalkboard" }); - layoutItems.push({ description: "Arrange contents in grid", event: this.arrangeContents, icon: "table" }); + layoutItems.push({ description: "Arrange contents in grid", event: this.layoutDocsInGrid, icon: "table" }); layoutItems.push({ description: "Analyze Strokes", event: this.analyzeStrokes, icon: "paint-brush" }); layoutItems.push({ description: "Jitter Rotation", event: action(() => this.props.Document.jitterRotation = 10), icon: "paint-brush" }); layoutItems.push({ @@ -910,54 +706,26 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { , ...this.views ] - - public static AddCustomLayout(doc: Doc, dataKey: string): () => void { - return () => { - let addOverlay = (key: "arrangeScript" | "arrangeInit", options: OverlayElementOptions, params?: Record, requiredType?: string) => { - let overlayDisposer: () => void = emptyFunction; - const script = Cast(doc[key], ScriptField); - let originalText: string | undefined = undefined; - if (script) originalText = script.script.originalScript; - // tslint:disable-next-line: no-unnecessary-callback-wrapper - let scriptingBox = overlayDisposer()} onSave={(text, onError) => { - const script = CompileScript(text, { - params, - requiredType, - typecheck: false - }); - if (!script.compiled) { - onError(script.errors.map(error => error.messageText).join("\n")); - return; - } - doc[key] = new ScriptField(script); - overlayDisposer(); - }} />; - overlayDisposer = OverlayView.Instance.addWindow(scriptingBox, options); - }; - addOverlay("arrangeInit", { x: 400, y: 100, width: 400, height: 300, title: "Layout Initialization" }, { collection: "Doc", docs: "Doc[]" }, undefined); - addOverlay("arrangeScript", { x: 400, y: 500, width: 400, height: 300, title: "Layout Script" }, { doc: "Doc", index: "number", collection: "Doc", state: "any", docs: "Doc[]" }, "{x: number, y: number, width?: number, height?: number}"); - }; - } render() { // update the actual dimensions of the collection so that they can inquired (e.g., by a minimap) - this.props.Document.fitX = this.actualContentBounds && this.actualContentBounds.x; - this.props.Document.fitY = this.actualContentBounds && this.actualContentBounds.y; - this.props.Document.fitW = this.actualContentBounds && (this.actualContentBounds.r - this.actualContentBounds.x); - this.props.Document.fitH = this.actualContentBounds && (this.actualContentBounds.b - this.actualContentBounds.y); + this.props.Document.fitX = this.contentBounds && this.contentBounds.x; + this.props.Document.fitY = this.contentBounds && this.contentBounds.y; + this.props.Document.fitW = this.contentBounds && (this.contentBounds.r - this.contentBounds.x); + this.props.Document.fitH = this.contentBounds && (this.contentBounds.b - this.contentBounds.y); // if fieldExt is set, then children will be stored in the extension document for the fieldKey. // otherwise, they are stored in fieldKey. All annotations to this document are stored in the extension document Doc.UpdateDocumentExtensionForField(this.props.DataDoc || this.props.Document, this.props.fieldKey); - const easing = () => this.props.Document.panTransformType === "Ease"; return (
+ addDocument={this.addDocument} removeDocument={this.props.removeDocument} addLiveTextDocument={this.addLiveTextBox} setPreviewCursor={this.props.setPreviewCursor} + getContainerTransform={this.getContainerTransform} getTransform={this.getTransform} isAnnotationOverlay={this.isAnnotationOverlay}> + easing={this.easing} zoomScaling={this.zoomScaling} panX={this.panX} panY={this.panY}> - + {this.childViews} @@ -974,23 +742,18 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { @observer class CollectionFreeFormOverlayView extends React.Component boolean }> { - @computed get overlayView() { - return (); - } render() { - return this.overlayView; + return ; } } @observer class CollectionFreeFormBackgroundView extends React.Component boolean }> { - @computed get backgroundView() { - return (); - } render() { - return this.props.Document.backgroundLayout ? this.backgroundView : (null); + return !this.props.Document.backgroundLayout ? (null) : + (); } } diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index bbea4a555..82193aefa 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -30,6 +30,8 @@ interface MarqueeViewProps { removeDocument: (doc: Doc) => boolean; addLiveTextDocument: (doc: Doc) => void; isSelected: () => boolean; + isAnnotationOverlay: boolean; + setPreviewCursor?: (func: (x: number, y: number, drag: boolean) => void) => void; } @observer @@ -43,6 +45,10 @@ export class MarqueeView extends React.Component @observable _visible: boolean = false; _commandExecuted = false; + componentDidMount() { + this.props.setPreviewCursor && this.props.setPreviewCursor(this.setPreviewCursor); + } + @action cleanupInteractions = (all: boolean = false) => { if (all) { @@ -145,15 +151,10 @@ export class MarqueeView extends React.Component } @action onPointerDown = (e: React.PointerEvent): void => { - this._downX = this._lastX = e.pageX; - this._downY = this._lastY = e.pageY; - this._commandExecuted = false; - PreviewCursor.Visible = false; - this.cleanupInteractions(true); + this._downX = this._lastX = e.clientX; + this._downY = this._lastY = e.clientY; if (e.button === 2 || (e.button === 0 && e.altKey)) { - document.addEventListener("pointermove", this.onPointerMove, true); - document.addEventListener("pointerup", this.onPointerUp, true); - document.addEventListener("keydown", this.marqueeCommand, true); + this.setPreviewCursor(e.clientX, e.clientY, true); if (e.altKey) { //e.stopPropagation(); // bcz: removed so that you can alt-click on button in a collection to switch link following behaviors. e.preventDefault(); @@ -176,6 +177,8 @@ export class MarqueeView extends React.Component e.stopPropagation(); e.preventDefault(); } + } else { + this.cleanupInteractions(true); // stop listening for events if another lower-level handle (e.g. another Marquee) has stopPropagated this } if (e.altKey) { e.preventDefault(); @@ -185,16 +188,13 @@ export class MarqueeView extends React.Component @action onPointerUp = (e: PointerEvent): void => { if (!this.props.container.props.active()) this.props.selectDocuments([this.props.container.props.Document]); - // console.log("pointer up!"); if (this._visible) { - // console.log("visible"); let mselect = this.marqueeSelect(); if (!e.shiftKey) { SelectionManager.DeselectAll(mselect.length ? undefined : this.props.container.props.Document); } this.props.selectDocuments(mselect.length ? mselect : [this.props.container.props.Document]); } - //console.log("invisible"); this.cleanupInteractions(true); if (e.altKey) { @@ -202,11 +202,28 @@ export class MarqueeView extends React.Component } } + setPreviewCursor = (x: number, y: number, drag: boolean) => { + if (drag) { + this._downX = this._lastX = x; + this._downY = this._lastY = y; + this._commandExecuted = false; + PreviewCursor.Visible = false; + this.cleanupInteractions(true); + document.addEventListener("pointermove", this.onPointerMove, true); + document.addEventListener("pointerup", this.onPointerUp, true); + document.addEventListener("keydown", this.marqueeCommand, true); + } else { + this._downX = x; + this._downY = y; + PreviewCursor.Show(x, y, this.onKeyPress, this.props.addLiveTextDocument, this.props.getTransform, this.props.addDocument); + } + } + @action onClick = (e: React.MouseEvent): void => { if (Math.abs(e.clientX - this._downX) < Utils.DRAG_THRESHOLD && Math.abs(e.clientY - this._downY) < Utils.DRAG_THRESHOLD) { - PreviewCursor.Show(e.clientX, e.clientY, this.onKeyPress, this.props.addLiveTextDocument, this.props.getTransform, this.props.addDocument); + this.setPreviewCursor(e.clientX, e.clientY, false); // let the DocumentView stopPropagation of this event when it selects this document } else { // why do we get a click event when the cursor have moved a big distance? // let's cut it off here so no one else has to deal with it. @@ -297,8 +314,8 @@ export class MarqueeView extends React.Component y: bounds.top, panX: 0, panY: 0, - backgroundColor: this.props.container.isAnnotationOverlay ? undefined : chosenColor, - defaultBackgroundColor: this.props.container.isAnnotationOverlay ? undefined : chosenColor, + backgroundColor: this.props.isAnnotationOverlay ? undefined : chosenColor, + defaultBackgroundColor: this.props.isAnnotationOverlay ? undefined : chosenColor, width: bounds.width, height: bounds.height, title: "a nested collection", diff --git a/src/client/views/linking/LinkFollowBox.tsx b/src/client/views/linking/LinkFollowBox.tsx index 81b0249dd..cad404d1f 100644 --- a/src/client/views/linking/LinkFollowBox.tsx +++ b/src/client/views/linking/LinkFollowBox.tsx @@ -18,6 +18,7 @@ import { DocServer } from "../../DocServer"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faTimes } from '@fortawesome/free-solid-svg-icons'; import { docs_v1 } from "googleapis"; +import { Utils } from "../../../Utils"; enum FollowModes { OPENTAB = "Open in Tab", @@ -242,6 +243,7 @@ export class LinkFollowBox extends React.Component { let proto = Doc.GetProto(LinkFollowBox.linkDoc); let targetContext = await Cast(proto.targetContext, Doc); let sourceContext = await Cast(proto.sourceContext, Doc); + let guid = StrCast(LinkFollowBox.linkDoc[Id]); const shouldZoom = options ? options.shouldZoom : false; let dockingFunc = (document: Doc) => { (this._addDocTab || this.props.addDocTab)(document, undefined, "inTab"); SelectionManager.DeselectAll(); }; @@ -251,6 +253,14 @@ export class LinkFollowBox extends React.Component { } else if (LinkFollowBox.destinationDoc === LinkFollowBox.linkDoc.anchor1 && sourceContext) { DocumentManager.Instance.jumpToDocument(jumpToDoc, shouldZoom, false, document => dockingFunc(sourceContext!)); + if (LinkFollowBox.sourceDoc && LinkFollowBox.destinationDoc) { + if (guid) { + let views = DocumentManager.Instance.getDocumentViews(jumpToDoc); + views.length && (views[0].props.Document.scrollToLinkID = guid); + } else { + jumpToDoc.linkHref = Utils.prepend("/doc/" + StrCast(LinkFollowBox.linkDoc[Id])); + } + } } else if (DocumentManager.Instance.getDocumentView(jumpToDoc)) { DocumentManager.Instance.jumpToDocument(jumpToDoc, shouldZoom, undefined, undefined, diff --git a/src/client/views/linking/LinkMenuItem.tsx b/src/client/views/linking/LinkMenuItem.tsx index 82fe3df23..835554ac0 100644 --- a/src/client/views/linking/LinkMenuItem.tsx +++ b/src/client/views/linking/LinkMenuItem.tsx @@ -28,9 +28,7 @@ interface LinkMenuItemProps { export class LinkMenuItem extends React.Component { private _drag = React.createRef(); @observable private _showMore: boolean = false; - @action toggleShowMore() { - this._showMore = !this._showMore; - } + @action toggleShowMore() { this._showMore = !this._showMore; } onEdit = (e: React.PointerEvent): void => { e.stopPropagation(); diff --git a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx index fcf483659..dd063ec9d 100644 --- a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx +++ b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx @@ -12,6 +12,7 @@ import { Doc, WidthSym, HeightSym } from "../../../new_fields/Doc"; import { random } from "animejs"; export interface CollectionFreeFormDocumentViewProps extends DocumentViewProps { + dataProvider?: (doc: Doc, dataDoc?: Doc) => { x: number, y: number, width: number, height: number, z: number, transition?: string } | undefined; x?: number; y?: number; width?: number; @@ -32,14 +33,14 @@ export const PositionDocument = makeInterface(documentSchema, positionSchema); @observer export class CollectionFreeFormDocumentView extends DocComponent(PositionDocument) { _disposer: IReactionDisposer | undefined = undefined; - @computed get transform() { return `scale(${this.props.ContentScaling()}) translate(${this.X}px, ${this.Y}px) rotate(${random(-1, 1) * this.props.jitterRotation}deg)`; } - @computed get X() { return this._animPos !== undefined ? this._animPos[0] : this.renderScriptDim ? this.renderScriptDim.x : this.props.x !== undefined ? this.props.x : this.Document.x || 0; } - @computed get Y() { return this._animPos !== undefined ? this._animPos[1] : this.renderScriptDim ? this.renderScriptDim.y : this.props.y !== undefined ? this.props.y : this.Document.y || 0; } - @computed get width() { return this.renderScriptDim ? this.renderScriptDim.width : this.props.width !== undefined ? this.props.width : this.props.Document[WidthSym](); } - @computed get height() { return this.renderScriptDim ? this.renderScriptDim.height : this.props.height !== undefined ? this.props.height : this.props.Document[HeightSym](); } + get transform() { return `scale(${this.props.ContentScaling()}) translate(${this.X}px, ${this.Y}px) rotate(${random(-1, 1) * this.props.jitterRotation}deg)`; } + get X() { return this._animPos !== undefined ? this._animPos[0] : this.renderScriptDim ? this.renderScriptDim.x : this.props.x !== undefined ? this.props.x : this.dataProvider ? this.dataProvider.x : this.Document.x || 0; } + get Y() { return this._animPos !== undefined ? this._animPos[1] : this.renderScriptDim ? this.renderScriptDim.y : this.props.y !== undefined ? this.props.y : this.dataProvider ? this.dataProvider.y : this.Document.y || 0; } + get width() { return this.renderScriptDim ? this.renderScriptDim.width : this.props.width !== undefined ? this.props.width : this.props.dataProvider && this.dataProvider ? this.dataProvider.width : this.props.Document[WidthSym](); } + get height() { return this.renderScriptDim ? this.renderScriptDim.height : this.props.height !== undefined ? this.props.height : this.props.dataProvider && this.dataProvider ? this.dataProvider.height : this.props.Document[HeightSym](); } + @computed get dataProvider() { return this.props.dataProvider && this.props.dataProvider(this.props.Document, this.props.DataDoc) ? this.props.dataProvider(this.props.Document, this.props.DataDoc) : undefined; } @computed get nativeWidth() { return FieldValue(this.Document.nativeWidth, 0); } @computed get nativeHeight() { return FieldValue(this.Document.nativeHeight, 0); } - @computed get scaleToOverridingWidth() { return this.width / FieldValue(this.Document.width, this.width); } @computed get renderScriptDim() { if (this.Document.renderScript) { @@ -72,11 +73,12 @@ export class CollectionFreeFormDocumentView extends DocComponent this.props.PanelHeight(); getTransform = (): Transform => this.props.ScreenToLocalTransform() .translate(-this.X, -this.Y) - .scale(1 / this.contentScaling()).scale(1 / this.scaleToOverridingWidth) + .scale(1 / this.contentScaling()) borderRounding = () => { let ruleRounding = this.props.ruleProvider ? StrCast(this.props.ruleProvider["ruleRounding_" + this.Document.heading]) : undefined; - let br = StrCast(((this.layoutDoc.layout as Doc) || this.Document).borderRounding); + let ld = this.layoutDoc.layout instanceof Doc ? this.layoutDoc.layout : undefined; + let br = StrCast((ld || this.props.Document).borderRounding); br = !br && ruleRounding ? ruleRounding : br; if (br.endsWith("%")) { let nativeDim = Math.min(NumCast(this.layoutDoc.nativeWidth), NumCast(this.layoutDoc.nativeHeight)); @@ -98,6 +100,9 @@ export class CollectionFreeFormDocumentView extends DocComponent this.dataProvider ? this.dataProvider.width : this.panelWidth(); + finalPanelHeight = () => this.dataProvider ? this.dataProvider.height : this.panelHeight(); + render() { return (
); diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 2ac68fea3..ea669b23c 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -227,15 +227,18 @@ export class DocumentView extends DocComponent(Docu else if (linkedDocs.length) { SelectionManager.DeselectAll(); let first = linkedDocs.filter(d => Doc.AreProtosEqual(d.anchor1 as Doc, this.props.Document) && !d.anchor1anchored); + let second = linkedDocs.filter(d => Doc.AreProtosEqual(d.anchor2 as Doc, this.props.Document) && !d.anchor2anchored); let firstUnshown = first.filter(d => DocumentManager.Instance.getDocumentViews(d.anchor2 as Doc).length === 0); + let secondUnshown = second.filter(d => DocumentManager.Instance.getDocumentViews(d.anchor1 as Doc).length === 0); if (firstUnshown.length) first = [firstUnshown[0]]; - let linkedFwdDocs = first.length ? [first[0].anchor2 as Doc, first[0].anchor1 as Doc] : [expandedDocs[0], expandedDocs[0]]; + if (secondUnshown.length) second = [secondUnshown[0]]; + let linkedFwdDocs = first.length ? [first[0].anchor2 as Doc, first[0].anchor1 as Doc] : second.length ? [second[0].anchor1 as Doc, second[0].anchor1 as Doc] : undefined; // @TODO: shouldn't always follow target context let linkedFwdContextDocs = [first.length ? await (first[0].targetContext) as Doc : undefined, undefined]; let linkedFwdPage = [first.length ? NumCast(first[0].anchor2Page, undefined) : undefined, undefined]; - if (!linkedFwdDocs.some(l => l instanceof Promise)) { + if (linkedFwdDocs && !linkedFwdDocs.some(l => l instanceof Promise)) { let maxLocation = StrCast(linkedFwdDocs[0].maximizeLocation, "inTab"); let targetContext = !Doc.AreProtosEqual(linkedFwdContextDocs[altKey ? 1 : 0], this.props.ContainingCollectionDoc) ? linkedFwdContextDocs[altKey ? 1 : 0] : undefined; DocumentManager.Instance.jumpToDocument(linkedFwdDocs[altKey ? 1 : 0], ctrlKey, false, @@ -258,7 +261,7 @@ export class DocumentView extends DocComponent(Docu this._hitTemplateDrag = true; } } - if (this.active) e.stopPropagation(); // events stop at the lowest document that is active. + if (this.active && e.button === 0 && !this.Document.lockedPosition) e.stopPropagation(); // events stop at the lowest document that is active. if right dragging, we let it go through though to allow for context menu clicks. PointerMove callbacks should remove themselves if the move event gets stopPropagated by a lower-level handler (e.g, marquee drag); document.removeEventListener("pointermove", this.onPointerMove); document.removeEventListener("pointerup", this.onPointerUp); document.addEventListener("pointermove", this.onPointerMove); @@ -266,11 +269,11 @@ export class DocumentView extends DocComponent(Docu } onPointerMove = (e: PointerEvent): void => { if (e.cancelBubble && this.active) { - document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointermove", this.onPointerMove); // stop listening to pointerMove if something else has stopPropagated it (e.g., the MarqueeView) } - else if (!e.cancelBubble && this.active) { + else if (!e.cancelBubble && (SelectionManager.IsSelected(this) || this.props.parentActive()) && !this.Document.lockedPosition) { if (Math.abs(this._downX - e.clientX) > 3 || Math.abs(this._downY - e.clientY) > 3) { - if (!e.altKey && !this.topMost && e.buttons === 1 && !BoolCast(this.Document.lockedPosition)) { + if (!e.altKey && !this.topMost && e.buttons === 1) { document.removeEventListener("pointermove", this.onPointerMove); document.removeEventListener("pointerup", this.onPointerUp); this.startDragging(this._downX, this._downY, e.ctrlKey || e.altKey ? "alias" : undefined, this._hitTemplateDrag); @@ -300,7 +303,7 @@ export class DocumentView extends DocComponent(Docu await swapViews(this.props.Document, "", "layoutNative"); let options = { title: "data", width: (this.Document.width || 0), x: -(this.Document.width || 0) / 2, y: - (this.Document.height || 0) / 2, }; - let fieldTemplate = this.Document.type === DocumentType.TEXT ? Docs.Create.TextDocument(options) : + let fieldTemplate = this.Document.type === DocumentType.TEXT ? Docs.Create.TextDocument(options) : this.Document.type === DocumentType.PDF ? Docs.Create.PdfDocument("http://www.msn.com", options) : this.Document.type === DocumentType.VID ? Docs.Create.VideoDocument("http://www.cs.brown.edu", options) : Docs.Create.ImageDocument("http://www.cs.brown.edu", options); @@ -354,7 +357,7 @@ export class DocumentView extends DocComponent(Docu // const docs = await SearchUtil.Search(`data_l:"${destDoc[Id]}"`, true); // const views = docs.map(d => DocumentManager.Instance.getDocumentView(d)).filter(d => d).map(d => d as DocumentView); de.data.linkSourceDocument !== this.props.Document && - (de.data.linkDocument = DocUtils.MakeLink(de.data.linkSourceDocument, this.props.Document, this.props.ContainingCollectionDoc)); + (de.data.linkDocument = DocUtils.MakeLink(de.data.linkSourceDocument, this.props.Document, this.props.ContainingCollectionDoc, undefined, "in-text link being created")); // TODODO this is where in text links get passed } } @@ -605,7 +608,7 @@ export class DocumentView extends DocComponent(Docu ruleColor && !colorSet ? ruleColor : StrCast(this.layoutDoc.backgroundColor) || this.props.backgroundColor(this.Document); const nativeWidth = this.nativeWidth > 0 && !this.Document.ignoreAspect ? `${this.nativeWidth}px` : "100%"; - const nativeHeight = this.Document.ignoreAspect ? this.props.PanelHeight() / this.props.ContentScaling() : this.nativeHeight > 0 ? `${this.nativeHeight}px` : "100%"; + const nativeHeight = this.Document.ignoreAspect || this.props.Document.fitWidth ? this.props.PanelHeight() / this.props.ContentScaling() : this.nativeHeight > 0 ? `${this.nativeHeight}px` : nativeWidth !== "100%" ? nativeWidth : "100%"; const showOverlays = this.props.showOverlays ? this.props.showOverlays(this.Document) : undefined; const showTitle = showOverlays && "title" in showOverlays ? showOverlays.title : this.getLayoutPropStr("showTitle"); const showCaption = showOverlays && "caption" in showOverlays ? showOverlays.caption : this.getLayoutPropStr("showCaption"); diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx index 49fc2263d..b93c78cfd 100644 --- a/src/client/views/nodes/FieldView.tsx +++ b/src/client/views/nodes/FieldView.tsx @@ -50,7 +50,6 @@ export interface FieldViewProps { PanelWidth: () => number; PanelHeight: () => number; setVideoBox?: (player: VideoBox) => void; - setPdfBox?: (player: PDFBox) => void; ContentScaling: () => number; ChromeHeight?: () => number; } @@ -96,7 +95,7 @@ export class FieldView extends React.Component { return

{field.date.toLocaleString()}

; } else if (field instanceof Doc) { - return

{field.title}

; + return

{field.title && field.title.toString()}

; //return

{field.title + " : id= " + field[Id]}

; // let returnHundred = () => 100; // return ( diff --git a/src/client/views/nodes/FormattedTextBox.scss b/src/client/views/nodes/FormattedTextBox.scss index 0d7277cbe..45e516015 100644 --- a/src/client/views/nodes/FormattedTextBox.scss +++ b/src/client/views/nodes/FormattedTextBox.scss @@ -164,13 +164,13 @@ ol { counter-reset: deci1 0;} .upper-alpha-ol {counter-reset: ualph; p { display: inline }; font-size: 18 } .lower-roman-ol {counter-reset: lroman; p { display: inline }; font-size: 14; } .lower-alpha-ol {counter-reset: lalpha; p { display: inline }; font-size: 10;} -.decimal1:before { content: counter(deci1) ")"; counter-increment: deci1; display:inline-block; width: 30} -.decimal2:before { content: counter(deci1) "." counter(deci2) ")"; counter-increment: deci2; display:inline-block; width: 35} -.decimal3:before { content: counter(deci1) "." counter(deci2) "." counter(deci3) ")"; counter-increment: deci3; display:inline-block; width: 35} -.decimal4:before { content: counter(deci1) "." counter(deci2) "." counter(deci3) "." counter(deci4) ")"; counter-increment: deci4; display:inline-block; width: 40} -.decimal5:before { content: counter(deci1) "." counter(deci2) "." counter(deci3) "." counter(deci4) "." counter(deci5) ")"; counter-increment: deci5; display:inline-block; width: 40} -.decimal6:before { content: counter(deci1) "." counter(deci2) "." counter(deci3) "." counter(deci4) "." counter(deci5) "." counter(deci6) ")"; counter-increment: deci6; display:inline-block; width: 45} -.decimal7:before { content: counter(deci1) "." counter(deci2) "." counter(deci3) "." counter(deci4) "." counter(deci5) "." counter(deci6) "." counter(deci7) ")"; counter-increment: deci7; display:inline-block; width: 50} -.upper-alpha:before { content: counter(deci1) "." counter(ualph, upper-alpha) ")"; counter-increment: ualph; display:inline-block; width: 35 } -.lower-roman:before { content: counter(deci1) "." counter(ualph, upper-alpha) "." counter(lroman, lower-roman) ")"; counter-increment: lroman;display:inline-block; width: 50 } -.lower-alpha:before { content: counter(deci1) "." counter(ualph, upper-alpha) "." counter(lroman, lower-roman) "." counter(lalpha, lower-alpha) ")"; counter-increment: lalpha; display:inline-block; width: 35} +.decimal1:before { content: counter(deci1) ") "; counter-increment: deci1; display:inline-block; min-width: 30;} +.decimal2:before { content: counter(deci1) "." counter(deci2) ") "; counter-increment: deci2; display:inline-block; min-width: 35} +.decimal3:before { content: counter(deci1) "." counter(deci2) "." counter(deci3) ") "; counter-increment: deci3; display:inline-block; min-width: 35} +.decimal4:before { content: counter(deci1) "." counter(deci2) "." counter(deci3) "." counter(deci4) ") "; counter-increment: deci4; display:inline-block; min-width: 40} +.decimal5:before { content: counter(deci1) "." counter(deci2) "." counter(deci3) "." counter(deci4) "." counter(deci5) ") "; counter-increment: deci5; display:inline-block; min-width: 40} +.decimal6:before { content: counter(deci1) "." counter(deci2) "." counter(deci3) "." counter(deci4) "." counter(deci5) "." counter(deci6) ") "; counter-increment: deci6; display:inline-block; min-width: 45} +.decimal7:before { content: counter(deci1) "." counter(deci2) "." counter(deci3) "." counter(deci4) "." counter(deci5) "." counter(deci6) "." counter(deci7) ") "; counter-increment: deci7; display:inline-block; min-width: 50} +.upper-alpha:before { content: counter(deci1) "." counter(ualph, upper-alpha) ") "; counter-increment: ualph; display:inline-block; min-width: 35 } +.lower-roman:before { content: counter(deci1) "." counter(ualph, upper-alpha) "." counter(lroman, lower-roman) ") "; counter-increment: lroman;display:inline-block; min-width: 50 } +.lower-alpha:before { content: counter(deci1) "." counter(ualph, upper-alpha) "." counter(lroman, lower-roman) "." counter(lalpha, lower-alpha) ") "; counter-increment: lalpha; display:inline-block; min-width: 35} diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx index eb4718581..63a16f90c 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/FormattedTextBox.tsx @@ -80,6 +80,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe private _nodeClicked: any; private _undoTyping?: UndoManager.Batch; private _searchReactionDisposer?: Lambda; + private _scrollToRegionReactionDisposer: Opt; private _reactionDisposer: Opt; private _textReactionDisposer: Opt; private _heightReactionDisposer: Opt; @@ -138,6 +139,52 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe if (this.props.isOverlay) { DragManager.StartDragFunctions.push(() => FormattedTextBox.InputBoxOverlay = undefined); } + + this._scrollToRegionReactionDisposer = reaction( + () => StrCast(this.props.Document.scrollToLinkID), + async (scrollToLinkID) => { + let findLinkFrag = (frag: Fragment, editor: EditorView) => { + const nodes: Node[] = []; + frag.forEach((node, index) => { + let examinedNode = findLinkNode(node, editor); + if (examinedNode && examinedNode.textContent) { + nodes.push(examinedNode); + start += index; + } + }); + return { frag: Fragment.fromArray(nodes), start: start }; + }; + let findLinkNode = (node: Node, editor: EditorView) => { + if (!node.isText) { + const content = findLinkFrag(node.content, editor); + return node.copy(content.frag); + } + const marks = [...node.marks]; + const linkIndex = marks.findIndex(mark => mark.type === editor.state.schema.marks.link); + return linkIndex !== -1 && scrollToLinkID === marks[linkIndex].attrs.href.replace(/.*\/doc\//, "") ? node : undefined; + }; + + let start = -1; + if (this._editorView && scrollToLinkID) { + let editor = this._editorView; + let ret = findLinkFrag(editor.state.doc.content, editor); + + if (ret.frag.size > 2 && ((!this.props.isOverlay && !this.props.isSelected()) || (this.props.isSelected() && this.props.isOverlay))) { + let selection = TextSelection.near(editor.state.doc.resolve(ret.start)); // default to near the start + if (ret.frag.firstChild) { + selection = TextSelection.between(editor.state.doc.resolve(ret.start + 2), editor.state.doc.resolve(ret.start + ret.frag.firstChild.nodeSize)); // bcz: looks better to not have the target selected + } + editor.dispatch(editor.state.tr.setSelection(new TextSelection(selection.$from, selection.$from)).scrollIntoView()); + const mark = editor.state.schema.mark(this._editorView.state.schema.marks.search_highlight); + setTimeout(() => editor.dispatch(editor.state.tr.addMark(selection.from, selection.to, mark)), 0); + setTimeout(() => this.unhighlightSearchTerms(), 2000); + } + this.props.Document.scrollToLinkID = undefined; + } + + }, + { fireImmediately: true } + ); } public get CurrentDiv(): HTMLDivElement { return this._ref.current!; } @@ -696,6 +743,15 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe this._editorView && this._editorView.destroy(); this._editorView = new EditorView(this._proseRef, { state: field && field.Data ? EditorState.fromJSON(config, JSON.parse(field.Data)) : EditorState.create(config), + handleScrollToSelection: (editorView) => { + let ref = editorView.domAtPos(editorView.state.selection.from); + let refNode = ref.node as any; + while (refNode && !("getBoundingClientRect" in refNode)) refNode = refNode.parentElement; + let r1 = refNode && refNode.getBoundingClientRect(); + let r3 = self._ref.current!.getBoundingClientRect(); + r1 && (self._ref.current!.scrollTop += (r1.top - r3.top) * self.props.ScreenToLocalTransform().Scale); + return true; + }, dispatchTransaction: this.dispatchTransaction, nodeViews: { image(node, view, getPos) { return new ImageResizeView(node, view, getPos, self.props.addDocTab); }, @@ -736,6 +792,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe } componentWillUnmount() { + this._scrollToRegionReactionDisposer && this._scrollToRegionReactionDisposer(); this._rulesReactionDisposer && this._rulesReactionDisposer(); this._reactionDisposer && this._reactionDisposer(); this._proxyReactionDisposer && this._proxyReactionDisposer(); @@ -757,6 +814,40 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe if (e.button === 0 && this.props.isSelected() && !e.altKey && !e.ctrlKey && !e.metaKey) { e.stopPropagation(); } + let ctrlKey = e.ctrlKey; + if (e.button === 2 || (e.button === 0 && e.ctrlKey)) { + e.preventDefault(); + } + } + + onPointerUp = (e: React.PointerEvent): void => { + FormattedTextBoxComment.textBox = this; + if (e.buttons === 1 && this.props.isSelected() && !e.altKey) { + e.stopPropagation(); + } + } + + @action + onFocused = (e: React.FocusEvent): void => { + document.removeEventListener("keypress", this.recordKeyHandler); + document.addEventListener("keypress", this.recordKeyHandler); + this.tryUpdateHeight(); + if (!this.props.isOverlay) { + FormattedTextBox.InputBoxOverlay = this; + } else { + if (this._ref.current) { + this._ref.current.scrollTop = FormattedTextBox.InputBoxOverlayScroll; + } + } + } + onPointerWheel = (e: React.WheelEvent): void => { + // if a text note is not selected and scrollable, this prevents us from being able to scroll and zoom out at the same time + if (this.props.isSelected() || e.currentTarget.scrollHeight > e.currentTarget.clientHeight) { + e.stopPropagation(); + } + } + + onClick = (e: React.MouseEvent): void => { let ctrlKey = e.ctrlKey; if (e.button === 0 && ((!this.props.isSelected() && !e.ctrlKey) || (this.props.isSelected() && e.ctrlKey)) && !e.metaKey && e.target) { let href = (e.target as any).href; @@ -771,8 +862,10 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe let node = pcords && this._editorView!.state.doc.nodeAt(pcords.pos); if (node) { let link = node.marks.find(m => m.type === this._editorView!.state.schema.marks.link); - href = link && link.attrs.href; - location = link && link.attrs.location; + if (link && !(link.attrs.docref && link.attrs.title)) { // bcz: getting hacky. this indicates that we clicked on a PDF excerpt quotation. In this case, we don't want to follow the link (we follow only the actual hyperlink for the quotation which is handled above). + href = link && link.attrs.href; + location = link && link.attrs.location; + } } if (href) { if (href.indexOf(Utils.prepend("/doc/")) === 0) { @@ -783,14 +876,14 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe let proto = Doc.GetProto(linkDoc); let targetContext = await Cast(proto.targetContext, Doc); let jumpToDoc = await Cast(linkDoc.anchor2, Doc); + if (jumpToDoc) { if (DocumentManager.Instance.getDocumentView(jumpToDoc)) { - DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey, undefined, undefined, NumCast((jumpToDoc === linkDoc.anchor2 ? linkDoc.anchor2Page : linkDoc.anchor1Page))); return; } } - if (targetContext) { + if (targetContext && (!jumpToDoc || targetContext !== await jumpToDoc.annotationOn)) { DocumentManager.Instance.jumpToDocument(targetContext, ctrlKey, false, document => this.props.addDocTab(document, undefined, location ? location : "inTab")); } else if (jumpToDoc) { DocumentManager.Instance.jumpToDocument(jumpToDoc, ctrlKey, false, document => this.props.addDocTab(document, undefined, location ? location : "inTab")); @@ -812,39 +905,6 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe } } - if (e.button === 2 || (e.button === 0 && e.ctrlKey)) { - e.preventDefault(); - } - } - - onPointerUp = (e: React.PointerEvent): void => { - FormattedTextBoxComment.textBox = this; - if (e.buttons === 1 && this.props.isSelected() && !e.altKey) { - e.stopPropagation(); - } - } - - @action - onFocused = (e: React.FocusEvent): void => { - document.removeEventListener("keypress", this.recordKeyHandler); - document.addEventListener("keypress", this.recordKeyHandler); - this.tryUpdateHeight(); - if (!this.props.isOverlay) { - FormattedTextBox.InputBoxOverlay = this; - } else { - if (this._ref.current) { - this._ref.current.scrollTop = FormattedTextBox.InputBoxOverlayScroll; - } - } - } - onPointerWheel = (e: React.WheelEvent): void => { - // if a text note is not selected and scrollable, this prevents us from being able to scroll and zoom out at the same time - if (this.props.isSelected() || e.currentTarget.scrollHeight > e.currentTarget.clientHeight) { - e.stopPropagation(); - } - } - - onClick = (e: React.MouseEvent): void => { // this hackiness handles clicking on the list item bullets to do expand/collapse. the bullets are ::before pseudo elements so there's no real way to hit test against them. if (this.props.isSelected() && e.nativeEvent.offsetX < 40) { let pos = this._editorView!.posAtCoords({ left: e.clientX, top: e.clientY }); @@ -852,7 +912,18 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe let node = this._editorView!.state.doc.nodeAt(pos.pos); let node2 = node && node.type === schema.nodes.paragraph ? this._editorView!.state.doc.nodeAt(pos.pos - 1) : undefined; if (node === this._nodeClicked && node2 && (node2.type === schema.nodes.ordered_list || node2.type === schema.nodes.list_item)) { - this._editorView!.dispatch(this._editorView!.state.tr.setNodeMarkup(pos.pos - 1, node2.type, { ...node2.attrs, visibility: !node2.attrs.visibility })); + let hit = this._editorView!.domAtPos(pos.pos).node as any; + let beforeEle = document.querySelector("." + hit.className) as Element; + let before = beforeEle ? window.getComputedStyle(beforeEle, ':before') : undefined; + let beforeWidth = before ? Number(before.getPropertyValue('width').replace("px", "")) : undefined; + if (beforeWidth && e.nativeEvent.offsetX < beforeWidth) { + let ol = this._editorView!.state.doc.nodeAt(pos.pos - 2) ? this._editorView!.state.doc.nodeAt(pos.pos - 2) : undefined; + if (ol && ol.type === schema.nodes.ordered_list && !e.shiftKey) { + this._editorView!.dispatch(this._editorView!.state.tr.setSelection(new NodeSelection(this._editorView!.state.doc.resolve(pos.pos - 2)))); + } else { + this._editorView!.dispatch(this._editorView!.state.tr.setNodeMarkup(pos.pos - 1, node2.type, { ...node2.attrs, visibility: !node2.attrs.visibility })); + } + } } } } @@ -903,7 +974,7 @@ export class FormattedTextBox extends DocComponent<(FieldViewProps & FormattedTe if (e.key === "Tab" || e.key === "Enter") { e.preventDefault(); } - this._editorView!.state.tr.removeStoredMark(schema.marks.user_mark.create({})).addStoredMark(schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: timenow() })); + this._editorView!.dispatch(this._editorView!.state.tr.removeStoredMark(schema.marks.user_mark.create({})).addStoredMark(schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: timenow() }))); if (!this._undoTyping) { this._undoTyping = UndoManager.StartBatch("undoTyping"); diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index 624593245..004f50590 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -38,6 +38,7 @@ library.add(faFileAudio, faAsterisk); export const pageSchema = createSchema({ curPage: "number", + fitWidth: "boolean" }); interface Window { diff --git a/src/client/views/nodes/PDFBox.scss b/src/client/views/nodes/PDFBox.scss index c88a94c28..2917c81cb 100644 --- a/src/client/views/nodes/PDFBox.scss +++ b/src/client/views/nodes/PDFBox.scss @@ -1,77 +1,67 @@ .pdfBox-cont, .pdfBox-cont-interactive { - display: flex; + display: inline-block; flex-direction: row; height: 100%; - overflow-y: scroll; - overflow-x: hidden; - .pdfBox-scrollHack { - pointer-events: none; - } + width:100%; + overflow: hidden; + position:absolute; + z-index: -1; } .pdfBox-cont { pointer-events: none; - .pdfPage-textlayer { - span { - pointer-events: none !important; - user-select: none; + .collectionFreeFormView-none { + pointer-events: none; + } + .pdfViewer-text { + .textLayer { + span { + user-select: none; + } } } } .pdfBox-cont-interactive { pointer-events: all; - .pdfPage-textlayer { - span { - pointer-events: all !important; - user-select: text; + .pdfViewer-text { + .textLayer { + span { + user-select: text; + } } } } -.react-pdf__Page { - transform-origin: left top; - position: absolute; - top: 0; - left: 0; -} - -.react-pdf__Page__textContent span { - user-select: text; -} - -.react-pdf__Document { - position: absolute; -} - .pdfBox-settingsCont { position: absolute; right: 0; - top: 0; + top: 3; + pointer-events: all; .pdfBox-settingsButton { border-bottom-left-radius: 50%; display: flex; justify-content: space-evenly; align-items: center; - height: 70px; + height: 30px; background: none; padding: 0; .pdfBox-settingsButton-arrow { width: 0; height: 0; - border-top: 25px solid transparent; - border-bottom: 25px solid transparent; - border-right: 25px solid #121721; + border-top: 15px solid transparent; + border-bottom: 15px solid transparent; + border-right: 15px solid #121721; transition: all 0.5s; } .pdfBox-settingsButton-iconCont { background: #121721; - height: 50px; + height: 30px; width: 70px; display: flex; justify-content: center; @@ -86,16 +76,15 @@ } .pdfBox-settingsFlyout { - width: 600px; position: absolute; background: #323232; box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25); - left: -400px; + right: 20px; border-radius: 7px; padding: 20px; display: flex; flex-direction: column; - font-size: 30px; + font-size: 14px; transition: all 0.5s; .pdfBox-settingsFlyout-title { @@ -108,4 +97,69 @@ grid-template-columns: 47.5% 5% 47.5%; } } -} \ No newline at end of file +} + +.pdfBox-overlayCont { + position: absolute; + width: 100%; + height: 40px; + background: #121721; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + padding: 20px; + overflow: hidden; + transition: left .5s; + pointer-events: all; + + .pdfBox-searchBar { + width: 70%; + font-size: 14px; + } +} + +.pdfBox-overlayButton { + border-bottom-left-radius: 50%; + display: flex; + justify-content: space-evenly; + align-items: center; + height: 30px; + background: none; + padding: 0; + position: absolute; + pointer-events: all; + + .pdfBox-overlayButton-arrow { + width: 0; + height: 0; + border-top: 15px solid transparent; + border-bottom: 15px solid transparent; + border-right: 15px solid #121721; + transition: all 0.5s; + } + + .pdfBox-overlayButton-iconCont, + .pdfBox-nextIcon, + .pdfBox-prevIcon { + background: #121721; + height: 30px; + width: 70px; + display: flex; + justify-content: center; + align-items: center; + margin-left: -2px; + border-radius: 3px; + } +} + +.pdfBox-overlayButton:hover { + background: none; +} + +.pdfBox-nextIcon { + left: 20; top: 5; height: 30px; position: absolute; +} +.pdfBox-prevIcon { + left: 50; top: 5; height: 30px; position: absolute; +} diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx index 764051d62..fe71e76fd 100644 --- a/src/client/views/nodes/PDFBox.tsx +++ b/src/client/views/nodes/PDFBox.tsx @@ -1,12 +1,12 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { action, computed, IReactionDisposer, observable, reaction, runInAction } from 'mobx'; +import { action, computed, IReactionDisposer, observable, reaction, runInAction, untracked, trace } from 'mobx'; import { observer } from "mobx-react"; import * as Pdfjs from "pdfjs-dist"; import "pdfjs-dist/web/pdf_viewer.css"; import 'react-image-lightbox/style.css'; import { Doc, Opt, WidthSym } from "../../../new_fields/Doc"; import { makeInterface } from "../../../new_fields/Schema"; -import { ComputedField, ScriptField } from '../../../new_fields/ScriptField'; +import { ScriptField } from '../../../new_fields/ScriptField'; import { Cast, NumCast } from "../../../new_fields/Types"; import { PdfField } from "../../../new_fields/URLField"; import { KeyCodes } from '../../northstar/utils/KeyCodes'; @@ -19,82 +19,59 @@ import { FieldView, FieldViewProps } from './FieldView'; import { pageSchema } from "./ImageBox"; import "./PDFBox.scss"; import React = require("react"); +import { undoBatch } from '../../util/UndoManager'; +import { ContextMenuProps } from '../ContextMenuItem'; +import { ContextMenu } from '../ContextMenu'; +import { Utils } from '../../../Utils'; type PdfDocument = makeInterface<[typeof documentSchema, typeof panZoomSchema, typeof pageSchema]>; const PdfDocument = makeInterface(documentSchema, panZoomSchema, pageSchema); @observer export class PDFBox extends DocComponent(PdfDocument) { - public static LayoutString() { return FieldView.LayoutString(PDFBox); } - private _mainCont: React.RefObject = React.createRef(); - private _reactionDisposer?: IReactionDisposer; + public static LayoutString(fieldExt?: string) { return FieldView.LayoutString(PDFBox, "data", fieldExt); } private _keyValue: string = ""; private _valueValue: string = ""; private _scriptValue: string = ""; + private _searchString: string = ""; + private _isChildActive = false; + private _pdfViewer: PDFViewer | undefined; private _keyRef: React.RefObject = React.createRef(); private _valueRef: React.RefObject = React.createRef(); private _scriptRef: React.RefObject = React.createRef(); + @observable private _searching: boolean = false; @observable private _flyout: boolean = false; - @observable private _alt = false; @observable private _pdf: Opt; + @observable private _pageControls = false; @computed get extensionDoc() { return Doc.fieldExtensionDoc(this.dataDoc, this.props.fieldKey); } - @computed get dataDoc() { return this.props.DataDoc && this.props.Document.isTemplate ? this.props.DataDoc : Doc.GetProto(this.props.Document); } componentDidMount() { - this.props.setPdfBox && this.props.setPdfBox(this); - - this.props.Document.curPage = ComputedField.MakeFunction("Math.floor(Number(this.panY) / Number(this.nativeHeight) + 1)"); - const pdfUrl = Cast(this.dataDoc[this.props.fieldKey], PdfField); if (pdfUrl instanceof PdfField) { Pdfjs.getDocument(pdfUrl.url.pathname).promise.then(pdf => runInAction(() => this._pdf = pdf)); } - this._reactionDisposer = reaction( - () => this.Document.panY, - () => this._mainCont.current && this._mainCont.current.scrollTo({ top: this.Document.panY || 0, behavior: "auto" }) - ); - } - - componentWillUnmount() { - this._reactionDisposer && this._reactionDisposer(); - } - - public GetPage() { - return Math.floor((this.Document.panY || 0) / (this.Document.nativeHeight || 0)) + 1; - } - - @action - public BackPage() { - let cp = Math.ceil((this.Document.panY || 0) / (this.Document.nativeHeight || 0)) + 1; - cp = cp - 1; - if (cp > 0) { - this.Document.panY = (cp - 1) * (this.Document.nativeHeight || 0); - } } - - @action - public GotoPage = (p: number) => { - if (p > 0 && p <= NumCast(this.dataDoc.numPages)) { - this.Document.panY = (p - 1) * (this.Document.nativeHeight || 0); + loaded = (nw: number, nh: number, np: number) => { + this.dataDoc.numPages = np; + if (!this.Document.nativeWidth || !this.Document.nativeHeight || !this.Document.scrollHeight) { + let oldaspect = (this.Document.nativeHeight || 0) / (this.Document.nativeWidth || 1); + this.Document.nativeWidth = nw * 96 / 72; + this.Document.nativeHeight = this.Document.nativeHeight ? nw * 96 / 72 * oldaspect : nh * 96 / 72; } + !this.Document.fitWidth && !this.Document.ignoreAspect && (this.Document.height = this.Document[WidthSym]() * (nh / nw)); } - @action - public ForwardPage() { - let cp = this.GetPage() + 1; - if (cp <= NumCast(this.dataDoc.numPages)) { - this.Document.panY = (cp - 1) * (this.Document.nativeHeight || 0); - } - } - - @action - setPanY = (y: number) => { - this.Document.panY = y; - } + public search(string: string, fwd: boolean) { this._pdfViewer && this._pdfViewer.search(string, fwd); } + public prevAnnotation() { this._pdfViewer && this._pdfViewer.prevAnnotation(); } + public nextAnnotation() { this._pdfViewer && this._pdfViewer.nextAnnotation(); } + public backPage() { this._pdfViewer!.gotoPage(NumCast(this.props.Document.curPage) - 1); } + public gotoPage = (p: number) => { this._pdfViewer!.gotoPage(p); }; + public forwardPage() { this._pdfViewer!.gotoPage(NumCast(this.props.Document.curPage) + 1); } + @undoBatch @action private applyFilter = () => { let scriptText = this._scriptValue ? this._scriptValue : @@ -102,10 +79,6 @@ export class PDFBox extends DocComponent(PdfDocumen this.props.Document.filterScript = ScriptField.MakeFunction(scriptText); } - scrollTo = (y: number) => { - this._mainCont.current && this._mainCont.current.scrollTo({ top: Math.max(y - (this._mainCont.current.offsetHeight / 2), 0), behavior: "auto" }); - } - private resetFilters = () => { this._keyValue = this._valueValue = this._scriptValue = ""; this._keyRef.current && (this._keyRef.current.value = ""); @@ -117,83 +90,112 @@ export class PDFBox extends DocComponent(PdfDocumen private newValueChange = (e: React.ChangeEvent) => this._valueValue = e.currentTarget.value; private newScriptChange = (e: React.ChangeEvent) => this._scriptValue = e.currentTarget.value; + whenActiveChanged = (isActive: boolean) => this.props.whenActiveChanged(this._isChildActive = isActive); + active = () => this.props.isSelected() || this._isChildActive || this.props.renderDepth === 0; + setPdfViewer = (pdfViewer: PDFViewer) => { this._pdfViewer = pdfViewer; }; + searchStringChanged = (e: React.ChangeEvent) => this._searchString = e.currentTarget.value; + settingsPanel() { + let pageBtns = <> + + + ; return !this.props.active() ? (null) : - (
e.stopPropagation()}> - + + +
+ -
-
- Annotation View Settings -
-
- - -
-
- -
-
- - + this.gotoPage(Number(e.currentTarget.value))} + style={{ left: 20, top: 5, height: "30px", width: "30px", position: "absolute", pointerEvents: "all" }} + onClick={action(() => this._pageControls = !this._pageControls)} /> + {this._pageControls ? pageBtns : (null)} +
e.stopPropagation()}> + +
+
+ Annotation View Settings +
+
+ + +
+
+ +
+
+ + +
); } - loaded = (nw: number, nh: number, np: number) => { - this.dataDoc.numPages = np; - if (!this.Document.nativeWidth || !this.Document.nativeHeight || !this.Document.scrollHeight) { - let oldaspect = (this.Document.nativeHeight || 0) / (this.Document.nativeWidth || 1); - this.Document.nativeWidth = nw; - this.Document.nativeHeight = this.Document.nativeHeight ? nw * oldaspect : nh; - this.Document.height = this.Document[WidthSym]() * (nh / nw); - this.Document.scrollHeight = np * this.Document.nativeHeight; - } - } + specificContextMenu = (e: React.MouseEvent): void => { + const pdfUrl = Cast(this.dataDoc[this.props.fieldKey], PdfField); + let funcs: ContextMenuProps[] = []; + pdfUrl && funcs.push({ description: "Copy path", event: () => Utils.CopyText(pdfUrl.url.pathname), icon: "expand-arrows-alt" }); + funcs.push({ description: "Toggle Fit Width " + (this.Document.fitWidth ? "Off" : "On"), event: () => this.Document.fitWidth = !this.Document.fitWidth, icon: "expand-arrows-alt" }); - @action - onScroll = (e: React.UIEvent) => { - if (e.currentTarget && this.props.ContainingCollectionDoc) { - this.props.Document.panTransformType = "None"; - this.Document.panY = e.currentTarget.scrollTop; - } + ContextMenu.Instance.addItem({ description: "Pdf Funcs...", subitems: funcs, icon: "asterisk" }); } - render() { const pdfUrl = Cast(this.dataDoc[this.props.fieldKey], PdfField); - let classname = "pdfBox-cont" + (this.props.active() && !InkingControl.Instance.selectedTool && !this._alt ? "-interactive" : ""); + let classname = "pdfBox-cont" + (InkingControl.Instance.selectedTool || !this.active ? "" : "-interactive"); return (!(pdfUrl instanceof PdfField) || !this._pdf ?
{`pdf, ${this.dataDoc[this.props.fieldKey]}, not found`}
: -
-
- { + let hit = document.elementFromPoint(e.clientX, e.clientY); + if (hit && hit.localName === "span" && this.props.isSelected()) { // drag selecting text stops propagation + e.button === 0 && e.stopPropagation(); + } + }}> + {this.settingsPanel()}
); diff --git a/src/client/views/nodes/PresBox.tsx b/src/client/views/nodes/PresBox.tsx index e376fbddb..5afd85430 100644 --- a/src/client/views/nodes/PresBox.tsx +++ b/src/client/views/nodes/PresBox.tsx @@ -161,10 +161,8 @@ export class PresBox extends React.Component { //FieldViewProps? if (zoomOut || this.presElementsMappings.get(docAtCurrent)!.showButton) { let prevScale = NumCast(this.childrenDocs[prevSelected].viewScale, null); let curScale = DocumentManager.Instance.getScaleOfDocView(this.childrenDocs[current]); - if (prevScale !== undefined) { - if (prevScale !== curScale) { - DocumentManager.Instance.zoomIntoScale(docAtCurrent, prevScale); - } + if (prevScale !== undefined && prevScale !== curScale) { + DocumentManager.Instance.zoomIntoScale(docAtCurrent, prevScale); } } } diff --git a/src/client/views/pdf/Annotation.tsx b/src/client/views/pdf/Annotation.tsx index a9fa883c8..3ed85f6a5 100644 --- a/src/client/views/pdf/Annotation.tsx +++ b/src/client/views/pdf/Annotation.tsx @@ -85,7 +85,15 @@ class RegionAnnotation extends React.Component { @action onPointerDown = async (e: React.PointerEvent) => { - if (e.button === 0) { + if (e.button === 2 || e.ctrlKey) { + PDFMenu.Instance.Status = "annotation"; + PDFMenu.Instance.Delete = this.deleteAnnotation.bind(this); + PDFMenu.Instance.Pinned = false; + PDFMenu.Instance.AddTag = this.addTag.bind(this); + PDFMenu.Instance.PinToPres = this.pinToPres; + PDFMenu.Instance.jumpTo(e.clientX, e.clientY, true); + } + else if (e.button === 0) { let targetDoc = await Cast(this.props.document.target, Doc); if (targetDoc) { let context = await Cast(targetDoc.targetContext, Doc); @@ -96,14 +104,6 @@ class RegionAnnotation extends React.Component { } } } - if (e.button === 2) { - PDFMenu.Instance.Status = "annotation"; - PDFMenu.Instance.Delete = this.deleteAnnotation.bind(this); - PDFMenu.Instance.Pinned = false; - PDFMenu.Instance.AddTag = this.addTag.bind(this); - PDFMenu.Instance.PinToPres = this.pinToPres; - PDFMenu.Instance.jumpTo(e.clientX, e.clientY, true); - } } addTag = (key: string, value: string): boolean => { diff --git a/src/client/views/pdf/PDFMenu.tsx b/src/client/views/pdf/PDFMenu.tsx index 3ed81faef..2202351ee 100644 --- a/src/client/views/pdf/PDFMenu.tsx +++ b/src/client/views/pdf/PDFMenu.tsx @@ -1,11 +1,10 @@ import React = require("react"); import "./PDFMenu.scss"; -import { observable, action, runInAction } from "mobx"; +import { observable, action, } from "mobx"; import { observer } from "mobx-react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { emptyFunction, returnFalse } from "../../../Utils"; import { Doc } from "../../../new_fields/Doc"; -import { handleBackspace } from "../nodes/PDFBox"; @observer export default class PDFMenu extends React.Component { @@ -238,8 +237,8 @@ export default class PDFMenu extends React.Component { ,
- - + +
, , diff --git a/src/client/views/pdf/PDFViewer.scss b/src/client/views/pdf/PDFViewer.scss index a2f3911c5..8027e93a3 100644 --- a/src/client/views/pdf/PDFViewer.scss +++ b/src/client/views/pdf/PDFViewer.scss @@ -1,93 +1,66 @@ - -.pdfViewer-viewer { - pointer-events:inherit; + +.pdfViewer-viewer, .pdfViewer-viewer-zoomed { + pointer-events: inherit; width: 100%; - .pdfViewer-visibleElements { - .pdfPage-cont { - .pdfPage-textLayer { - div { - user-select: text; - } - span { - color: transparent; - position: absolute; - white-space: pre; - cursor: text; - -webkit-transform-origin: 0% 0%; - transform-origin: 0% 0%; - } - } - } + height: 100%; + position: absolute; + overflow-y: auto; + overflow-x: hidden; + + // .canvasWrapper { + // transform: scale(0.75); + // transform-origin: top left; + // } + // .textLayer { + // transform: scale(0.75); + // transform-origin: top left; + // } + + .page { + position: relative; } - .pdfViewer-text { - transform: scale(1.5); - transform-origin: top left; + .collectionfreeformview-container { + pointer-events: none; + } + + .pdfViewer-dragAnnotationBox { + position:absolute; + background-color: transparent; + opacity: 0.1; } + .pdfViewer-overlay { + transform: scale(2.14359); + transform-origin: left top; + position: absolute; + top: 0px; + left: 0px; + display: inline-block; + width:100%; + } .pdfViewer-annotationLayer { position: absolute; top: 0; width: 100%; pointer-events: none; + .pdfPage-annotationBox { position: absolute; background-color: red; opacity: 0.1; } } - - .pdfViewer-overlayCont { - position: absolute; - width: 100%; - height: 100px; - background: #121721; - bottom: 0; - display: flex; - justify-content: center; - align-items: center; - padding: 20px; - overflow: hidden; - transition: left .5s; - .pdfViewer-overlaySearchBar { - width: 20%; - height: 100%; - font-size: 30px; - padding: 5px; - } - } - - .pdfViewer-overlayButton { - border-bottom-left-radius: 50%; - display: flex; - justify-content: space-evenly; - align-items: center; - height: 70px; - background: none; - padding: 0; + .pdfViewer-waiting { + width: 70%; + height: 70%; + margin : 15%; + transition: 0.4s opacity ease; + opacity: 0.7; position: absolute; - - .pdfViewer-overlayButton-arrow { - width: 0; - height: 0; - border-top: 25px solid transparent; - border-bottom: 25px solid transparent; - border-right: 25px solid #121721; - transition: all 0.5s; - } - - .pdfViewer-overlayButton-iconCont { - background: #121721; - height: 50px; - width: 70px; - display: flex; - justify-content: center; - align-items: center; - margin-left: -2px; - border-radius: 3px; - } - } - - .pdfViewer-overlayButton:hover { - background: none; + z-index: 10; } } +.pdfViewer-viewer-zoomed { + overflow-x: scroll; +} + \ No newline at end of file diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx index c94b4e3a4..13fd8ea98 100644 --- a/src/client/views/pdf/PDFViewer.tsx +++ b/src/client/views/pdf/PDFViewer.tsx @@ -1,28 +1,33 @@ -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { action, computed, IReactionDisposer, observable, reaction, runInAction, trace } from "mobx"; +import { action, computed, IReactionDisposer, observable, reaction, trace, runInAction } from "mobx"; import { observer } from "mobx-react"; import * as Pdfjs from "pdfjs-dist"; import "pdfjs-dist/web/pdf_viewer.css"; -import * as rp from "request-promise"; import { Dictionary } from "typescript-collections"; -import { Doc, DocListCast, FieldResult } from "../../../new_fields/Doc"; +import { Doc, DocListCast, FieldResult, WidthSym, Opt, HeightSym } from "../../../new_fields/Doc"; import { Id } from "../../../new_fields/FieldSymbols"; import { List } from "../../../new_fields/List"; +import { listSpec } from "../../../new_fields/Schema"; import { ScriptField } from "../../../new_fields/ScriptField"; import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; -import { Utils, numberRange } from "../../../Utils"; +import smoothScroll, { Utils, emptyFunction, returnOne } from "../../../Utils"; import { DocServer } from "../../DocServer"; import { Docs, DocUtils } from "../../documents/Documents"; -import { KeyCodes } from "../../northstar/utils/KeyCodes"; -import { CompileScript, CompiledScript } from "../../util/Scripting"; -import Annotation from "./Annotation"; -import Page from "./Page"; +import { DragManager } from "../../util/DragManager"; +import { CompiledScript, CompileScript } from "../../util/Scripting"; +import { Transform } from "../../util/Transform"; +import PDFMenu from "./PDFMenu"; import "./PDFViewer.scss"; import React = require("react"); -import requestPromise = require("request-promise"); +import * as rp from "request-promise"; +import { CollectionPDFView } from "../collections/CollectionPDFView"; +import { CollectionVideoView } from "../collections/CollectionVideoView"; +import { CollectionView } from "../collections/CollectionView"; +import Annotation from "./Annotation"; +import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; const PDFJSViewer = require("pdfjs-dist/web/pdf_viewer"); +const pdfjsLib = require("pdfjs-dist"); -export const scale = 2; +pdfjsLib.GlobalWorkerOptions.workerSrc = `/assets/pdf.worker.js`; interface IViewerProps { pdf: Pdfjs.PDFDocumentProxy; @@ -31,15 +36,23 @@ interface IViewerProps { DataDoc?: Doc; fieldExtensionDoc: Doc; fieldKey: string; + fieldExt: string; + PanelWidth: () => number; + PanelHeight: () => number; + ContentScaling: () => number; + select: (isCtrlPressed: boolean) => void; + renderDepth: number; + isSelected: () => boolean; loaded: (nw: number, nh: number, np: number) => void; - panY: number; - scrollTo: (y: number) => void; active: () => boolean; - setPanY?: (n: number) => void; GoToPage?: (n: number) => void; addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => boolean; pinToPres: (document: Doc) => void; addDocument?: (doc: Doc, allowDuplicates?: boolean) => boolean; + setPdfViewer: (view: PDFViewer) => void; + ScreenToLocalTransform: () => Transform; + ContainingCollectionView: Opt; + whenActiveChanged: (isActive: boolean) => void; } /** @@ -47,36 +60,37 @@ interface IViewerProps { */ @observer export class PDFViewer extends React.Component { - @observable.shallow private _visibleElements: JSX.Element[] = []; // _visibleElements is the array of JSX elements that gets rendered - @observable private _isPage: string[] = [];// _isPage is an array that tells us whether or not an index is rendered as a page or as a placeholder @observable private _pageSizes: { width: number, height: number }[] = []; @observable private _annotations: Doc[] = []; @observable private _savedAnnotations: Dictionary = new Dictionary(); @observable private _script: CompiledScript = CompileScript("return true") as CompiledScript; - @observable private _searching: boolean = false; @observable private Index: number = -1; - - private _pageBuffer: number = 1; + @observable private _marqueeX: number = 0; + @observable private _marqueeY: number = 0; + @observable private _marqueeWidth: number = 0; + @observable private _marqueeHeight: number = 0; + @observable private _marqueeing: boolean = false; + @observable private _showWaiting = true; + @observable private _showCover = false; + @observable private _zoomed = 1; + + public pdfViewer: any; + private _isChildActive = false; + private _setPreviewCursor: undefined | ((x: number, y: number, drag: boolean) => void); private _annotationLayer: React.RefObject = React.createRef(); private _reactionDisposer?: IReactionDisposer; + private _selectionReactionDisposer?: IReactionDisposer; private _annotationReactionDisposer?: IReactionDisposer; private _filterReactionDisposer?: IReactionDisposer; private _viewer: React.RefObject = React.createRef(); private _mainCont: React.RefObject = React.createRef(); - public _pdfViewer: any; - private _pdfFindController: any; - private _searchString: string = ""; + private _marquee: React.RefObject = React.createRef(); private _selectionText: string = ""; - - @computed get panY(): number { return this.props.panY; } - - // startIndex: where to start rendering pages - @computed get startIndex(): number { return Math.max(0, this.getPageFromScroll(this.panY) - this._pageBuffer); } - - // endIndex: where to end rendering pages - @computed get endIndex(): number { - return Math.min(this.props.pdf.numPages - 1, this.getPageFromScroll(this.panY + (this._pageSizes[0] ? this._pageSizes[0].height : 0)) + this._pageBuffer); - } + private _startX: number = 0; + private _startY: number = 0; + private _downX: number = 0; + private _downY: number = 0; + private _coverPath: any; @computed get allAnnotations() { return DocListCast(this.props.fieldExtensionDoc.annotations).filter( @@ -87,42 +101,32 @@ export class PDFViewer extends React.Component { return this._annotations.filter(anno => this._script.run({ this: anno }, console.log, true).result); } - componentDidUpdate = (prevProps: IViewerProps) => this.panY !== prevProps.panY && this.renderPages(); - componentDidMount = async () => { - await this.initialLoad(); - + // change the address to be the file address of the PNG version of each page + // file address of the pdf + this._coverPath = JSON.parse(await rp.get(Utils.prepend(`/thumbnail${this.props.url.substring("files/".length, this.props.url.length - ".pdf".length)}-${NumCast(this.props.Document.curPage, 1)}.PNG`))); + runInAction(() => this._showWaiting = this._showCover = true); + this._selectionReactionDisposer = reaction(() => this.props.isSelected(), () => this.setupPdfJsViewer()); this._reactionDisposer = reaction( - () => [this.props.active(), this.startIndex, this._pageSizes.length ? this.endIndex : 0], - () => this.renderPages(), - { fireImmediately: true }); - - this._annotationReactionDisposer = reaction( - () => this.props.fieldExtensionDoc && DocListCast(this.props.fieldExtensionDoc.annotations), - annotations => annotations && annotations.length && this.renderAnnotations(annotations, true), - { fireImmediately: true }); - - this._filterReactionDisposer = reaction( - () => ({ scriptField: Cast(this.props.Document.filterScript, ScriptField), annos: this._annotations.slice() }), - action(({ scriptField, annos }: { scriptField: FieldResult, annos: Doc[] }) => { - let oldScript = this._script.originalScript; - this._script = scriptField && scriptField.script.compiled ? scriptField.script : CompileScript("return true") as CompiledScript; - if (this._script.originalScript !== oldScript) { - this.Index = -1; + () => this.props.Document.scrollY, + (scrollY) => { + if (scrollY !== undefined) { + if (this._showCover || this._showWaiting) { + this.setupPdfJsViewer(); + } + this._mainCont.current && smoothScroll(1000, this._mainCont.current, NumCast(this.props.Document.scrollY) || 0); + this.props.Document.scrollY = undefined; } - annos.forEach(d => d.opacity = this._script.run({ this: d }, console.log, 1).result ? 1 : 0); - }), + }, { fireImmediately: true } ); - - document.removeEventListener("copy", this.copy); - document.addEventListener("copy", this.copy); } componentWillUnmount = () => { this._reactionDisposer && this._reactionDisposer(); this._annotationReactionDisposer && this._annotationReactionDisposer(); this._filterReactionDisposer && this._filterReactionDisposer(); + this._selectionReactionDisposer && this._selectionReactionDisposer(); document.removeEventListener("copy", this.copy); } @@ -143,41 +147,74 @@ export class PDFViewer extends React.Component { } } - searchStringChanged = (e: React.ChangeEvent) => this._searchString = e.currentTarget.value; - - pageLoaded = (page: Pdfjs.PDFPageViewport): void => this.props.loaded(page.width, page.height, this.props.pdf.numPages); - setSelectionText = (text: string) => this._selectionText = text; - getIndex = () => this.Index; - @action initialLoad = async () => { if (this._pageSizes.length === 0) { - this._isPage = Array(this.props.pdf.numPages); this._pageSizes = Array<{ width: number, height: number }>(this.props.pdf.numPages); - this._visibleElements = Array(this.props.pdf.numPages); await Promise.all(this._pageSizes.map>((val, i) => this.props.pdf.getPage(i + 1).then(action((page: Pdfjs.PDFPageProxy) => { this._pageSizes.splice(i, 1, { - width: (page.view[page.rotate === 0 || page.rotate === 180 ? 2 : 3] - page.view[page.rotate === 0 || page.rotate === 180 ? 0 : 1]) * scale, - height: (page.view[page.rotate === 0 || page.rotate === 180 ? 3 : 2] - page.view[page.rotate === 0 || page.rotate === 180 ? 1 : 0]) * scale + width: (page.view[page.rotate === 0 || page.rotate === 180 ? 2 : 3] - page.view[page.rotate === 0 || page.rotate === 180 ? 0 : 1]), + height: (page.view[page.rotate === 0 || page.rotate === 180 ? 3 : 2] - page.view[page.rotate === 0 || page.rotate === 180 ? 1 : 0]) }); - this._visibleElements.splice(i, 1, -
- "PAGE IS LOADING... " -
); - this.getPlaceholderPage(i); + i === this.props.pdf.numPages - 1 && this.props.loaded((page.view[page.rotate === 0 || page.rotate === 180 ? 2 : 3] - page.view[page.rotate === 0 || page.rotate === 180 ? 0 : 1]), + (page.view[page.rotate === 0 || page.rotate === 180 ? 3 : 2] - page.view[page.rotate === 0 || page.rotate === 180 ? 1 : 0]), i); })))); - this.props.loaded(Math.max(...this._pageSizes.map(i => i.width)), this._pageSizes[0].height, this.props.pdf.numPages); - - let startY = NumCast(this.props.Document.startY, NumCast(this.props.Document.panY)); - this.props.setPanY && this.props.setPanY(startY); - this.props.scrollTo(startY); + Doc.GetProto(this.props.Document).scrollHeight = this._pageSizes.reduce((size, page) => size + page.height, 0) * 96 / 72; } } + @action + setupPdfJsViewer = async () => { + this._selectionReactionDisposer && this._selectionReactionDisposer(); + this._selectionReactionDisposer = undefined; + this._showWaiting = true; + this.props.setPdfViewer(this); + await this.initialLoad(); + + this._annotationReactionDisposer = reaction( + () => this.props.fieldExtensionDoc && DocListCast(this.props.fieldExtensionDoc.annotations), + annotations => annotations && annotations.length && this.renderAnnotations(annotations, true), + { fireImmediately: true }); + + this._filterReactionDisposer = reaction( + () => ({ scriptField: Cast(this.props.Document.filterScript, ScriptField), annos: this._annotations.slice() }), + action(({ scriptField, annos }: { scriptField: FieldResult, annos: Doc[] }) => { + let oldScript = this._script.originalScript; + this._script = scriptField && scriptField.script.compiled ? scriptField.script : CompileScript("return true") as CompiledScript; + if (this._script.originalScript !== oldScript) { + this.Index = -1; + } + annos.forEach(d => d.opacity = this._script.run({ this: d }, console.log, 1).result ? 1 : 0); + }), + { fireImmediately: true } + ); + + document.removeEventListener("copy", this.copy); + document.addEventListener("copy", this.copy); + document.addEventListener("pagesinit", action(() => { + this.pdfViewer.currentScaleValue = this._zoomed = 1; + this.gotoPage(NumCast(this.props.Document.curPage, 1)); + })); + document.addEventListener("pagerendered", action(() => this._showCover = this._showWaiting = false)); + var pdfLinkService = new PDFJSViewer.PDFLinkService(); + let pdfFindController = new PDFJSViewer.PDFFindController({ + linkService: pdfLinkService, + }); + this.pdfViewer = new PDFJSViewer.PDFViewer({ + container: this._mainCont.current, + viewer: this._viewer.current, + linkService: pdfLinkService, + findController: pdfFindController, + renderer: "canvas", + }); + pdfLinkService.setViewer(this.pdfViewer); + pdfLinkService.setDocument(this.props.pdf, null); + this.pdfViewer.setDocument(this.props.pdf); + } + @action makeAnnotationDocument = (sourceDoc: Doc | undefined, color: string, createLink: boolean = true): Doc => { let mainAnnoDoc = Docs.Create.InstanceFromProto(new Doc(), "", {}); @@ -198,9 +235,9 @@ export class PDFViewer extends React.Component { annoDocs.push(annoDoc); annoDoc.isButton = true; anno.remove(); - this.props.addDocument && this.props.addDocument(annoDoc, false); mainAnnoDoc = annoDoc; - mainAnnoDocProto = Doc.GetProto(annoDoc); + mainAnnoDocProto = Doc.GetProto(mainAnnoDoc); + mainAnnoDocProto.y = annoDoc.y; } else { this._savedAnnotations.forEach((key: number, value: HTMLDivElement[]) => value.map(anno => { let annoDoc = new Doc(); @@ -230,61 +267,6 @@ export class PDFViewer extends React.Component { return mainAnnoDoc; } - @action - getPlaceholderPage = (page: number) => { - if (this._isPage[page] !== "none") { - this._isPage[page] = "none"; - this._visibleElements[page] = ( -
- "PAGE IS LOADING... " -
); - } - } - - @action - getRenderedPage = (page: number) => { - if (this._isPage[page] !== "page") { - this._isPage[page] = "page"; - this._visibleElements[page] = (); - } - } - - // change the address to be the file address of the PNG version of each page - // file address of the pdf - @action - getPageImage = async (page: number) => { - if (this._isPage[page] !== "image") { - this._isPage[page] = "image"; - try { - let res = JSON.parse(await rp.get(Utils.prepend(`/thumbnail${this.props.url.substring("files/".length, this.props.url.length - ".pdf".length)}-${page + 1}.PNG`))); - runInAction(() => this._visibleElements[page] = - this.getRenderedPage(page)} - style={{ width: `${parseInt(res.width) * scale}px`, height: `${parseInt(res.height) * scale}px` }} />); - } catch (e) { - console.log(e); - } - } - } - - renderPages = () => { - numberRange(this.props.pdf.numPages).filter(p => this._isPage[p] !== undefined).map(i => - (i < this.startIndex || i > this.endIndex) ? this.getPlaceholderPage(i) : // pages outside of the pdf use empty stand-in divs - this.props.active() ? this.getRenderedPage(i) : this.getPageImage(i)); - } - @action renderAnnotations = (annotations: Doc[], removeOldAnnotations: boolean): void => { if (removeOldAnnotations) { @@ -297,23 +279,30 @@ export class PDFViewer extends React.Component { } @action - prevAnnotation = (e: React.MouseEvent) => { - e.stopPropagation(); + prevAnnotation = () => { this.Index = Math.max(this.Index - 1, 0); - let scrollToAnnotation = this.allAnnotations.sort((a, b) => NumCast(a.y) - NumCast(b.y))[this.Index]; - this.allAnnotations.forEach(d => Doc.UnBrushDoc(d)); - Doc.BrushDoc(scrollToAnnotation); - this.props.scrollTo(NumCast(scrollToAnnotation.y)); + this.scrollToAnnotation(this.allAnnotations.sort((a, b) => NumCast(a.y) - NumCast(b.y))[this.Index]); } @action - nextAnnotation = (e: React.MouseEvent) => { - e.stopPropagation(); + nextAnnotation = () => { this.Index = Math.min(this.Index + 1, this.allAnnotations.length - 1); - let scrollToAnnotation = this.allAnnotations.sort((a, b) => NumCast(a.y) - NumCast(b.y))[this.Index]; + this.scrollToAnnotation(this.allAnnotations.sort((a, b) => NumCast(a.y) - NumCast(b.y))[this.Index]); + } + + @action + gotoPage = (p: number) => { + this.pdfViewer && this.pdfViewer.scrollPageIntoView({ pageNumber: Math.min(Math.max(1, p), this._pageSizes.length) }); + } + + @action + scrollToAnnotation = (scrollToAnnotation: Doc) => { this.allAnnotations.forEach(d => Doc.UnBrushDoc(d)); + let windowHgt = this.props.PanelHeight() / this.props.ContentScaling(); + let scrollRange = this._mainCont.current!.scrollHeight - windowHgt; + let pgScroll = scrollRange / this._pageSizes.length; + this._mainCont.current!.scrollTo(0, NumCast(scrollToAnnotation.y) - pgScroll / 2); Doc.BrushDoc(scrollToAnnotation); - this.props.scrollTo(NumCast(scrollToAnnotation.y)); } sendAnnotations = (page: number) => { @@ -330,6 +319,11 @@ export class PDFViewer extends React.Component { } } + @action + onScroll = (e: React.UIEvent) => { + this.pdfViewer && (this.props.Document.curPage = this.pdfViewer.currentPageNumber); + } + // get the page index that the vertical offset passed in is on getPageFromScroll = (vOffset: number) => { let index = 0; @@ -340,15 +334,11 @@ export class PDFViewer extends React.Component { return index; } - getScrollFromPage = (index: number): number => { - return numberRange(Math.min(this.props.pdf.numPages, index)).reduce((counter, i) => counter + this._pageSizes[i].height, 0); - } - @action createAnnotation = (div: HTMLDivElement, page: number) => { if (this._annotationLayer.current) { if (div.style.top) { - div.style.top = (parseInt(div.style.top) + this.getScrollFromPage(page)).toString(); + div.style.top = (parseInt(div.style.top)/*+ this.getScrollFromPage(page)*/).toString(); } this._annotationLayer.current.append(div); let savedPage = this._savedAnnotations.getValue(page); @@ -363,11 +353,14 @@ export class PDFViewer extends React.Component { } @action - search = (searchString: string) => { - if (this._pdfViewer._pageViewsReady) { - this._pdfFindController.executeCommand('findagain', { + search = (searchString: string, fwd: boolean) => { + if (!searchString) { + fwd ? this.nextAnnotation() : this.prevAnnotation(); + } + else if (this.pdfViewer._pageViewsReady) { + this.pdfViewer.findController.executeCommand('findagain', { caseSensitive: false, - findPrevious: undefined, + findPrevious: !fwd, highlightAll: true, phraseSearch: true, query: searchString @@ -375,122 +368,354 @@ export class PDFViewer extends React.Component { } else if (this._mainCont.current) { let executeFind = () => { - this._pdfFindController.executeCommand('find', { + this.pdfViewer.findController.executeCommand('find', { caseSensitive: false, - findPrevious: undefined, + findPrevious: !fwd, highlightAll: true, phraseSearch: true, query: searchString }); - } + }; this._mainCont.current.addEventListener("pagesloaded", executeFind); this._mainCont.current.addEventListener("pagerendered", executeFind); } } - @action - toggleSearch = (e: React.MouseEvent) => { - e.stopPropagation(); - this._searching = !this._searching; - - if (this._searching) { - if (!this._pdfFindController && this._mainCont.current && this._viewer.current && !this._pdfViewer) { - let simpleLinkService = new SimpleLinkService(this); - this._pdfViewer = new PDFJSViewer.PDFViewer({ - container: this._mainCont.current, - viewer: this._viewer.current, - linkService: simpleLinkService - }) - simpleLinkService.setPDFJSview(this._pdfViewer); - this._mainCont.current.addEventListener("pagesinit", () => this._pdfViewer.currentScaleValue = 1); - this._mainCont.current.addEventListener("pagerendered", () => console.log("rendered")); - this._pdfViewer.setDocument(this.props.pdf); - this._pdfFindController = new PDFJSViewer.PDFFindController(this._pdfViewer); - this._pdfViewer.findController = this._pdfFindController; + onPointerDown = (e: React.PointerEvent): void => { + // if alt+left click, drag and annotate + this._downX = e.clientX; + this._downY = e.clientY; + if (NumCast(this.props.Document.scale, 1) !== 1) return; + if ((e.button !== 0 || e.altKey) && this.active()) { + this._setPreviewCursor && this._setPreviewCursor(e.clientX, e.clientY, true); + } + this._marqueeing = false; + if (!e.altKey && e.button === 0 && this.active()) { + PDFMenu.Instance.StartDrag = this.startDrag; + PDFMenu.Instance.Highlight = this.highlight; + PDFMenu.Instance.Snippet = this.createSnippet; + PDFMenu.Instance.Status = "pdf"; + PDFMenu.Instance.fadeOut(true); + if (e.target && (e.target as any).parentElement.className === "textLayer") { + if (!e.ctrlKey) { + this.receiveAnnotations([], -1); + } + } + else { + // set marquee x and y positions to the spatially transformed position + if (this._mainCont.current) { + let boundingRect = this._mainCont.current.getBoundingClientRect(); + this._startX = this._marqueeX = (e.clientX - boundingRect.left) * (this._mainCont.current.offsetWidth / boundingRect.width); + this._startY = this._marqueeY = (e.clientY - boundingRect.top) * (this._mainCont.current.offsetHeight / boundingRect.height) + this._mainCont.current.scrollTop; + } + this._marqueeing = true; + let marquees = this._mainCont.current!.getElementsByClassName("pdfViewer-dragAnnotationBox"); + if (marquees && marquees.length) { // make a copy of the marquee + let marquee = marquees[0] as HTMLDivElement; + marquee.style.opacity = "0.2"; + } + this.receiveAnnotations([], -1); } + document.removeEventListener("pointermove", this.onSelectMove); + document.addEventListener("pointermove", this.onSelectMove); + document.removeEventListener("pointerup", this.onSelectEnd); + document.addEventListener("pointerup", this.onSelectEnd); } } - @computed get visibleElementWrapper() { - trace(); - return this._visibleElements; + + @action + onSelectMove = (e: PointerEvent): void => { + if (this._marqueeing && this._mainCont.current) { + // transform positions and find the width and height to set the marquee to + let boundingRect = this._mainCont.current.getBoundingClientRect(); + this._marqueeWidth = ((e.clientX - boundingRect.left) * (this._mainCont.current.offsetWidth / boundingRect.width)) - this._startX; + this._marqueeHeight = ((e.clientY - boundingRect.top) * (this._mainCont.current.offsetHeight / boundingRect.height)) - this._startY + this._mainCont.current.scrollTop; + this._marqueeX = Math.min(this._startX, this._startX + this._marqueeWidth); + this._marqueeY = Math.min(this._startY, this._startY + this._marqueeHeight); + this._marqueeWidth = Math.abs(this._marqueeWidth); + this._marqueeHeight = Math.abs(this._marqueeHeight); + e.stopPropagation(); + e.preventDefault(); + } + else if (e.target && (e.target as any).parentElement === this._mainCont.current) { + e.stopPropagation(); + } } - render() { - return (
-
- {this.visibleElementWrapper} -
-
-
- {this.nonDocAnnotations.sort((a, b) => NumCast(a.y) - NumCast(b.y)).map((anno, index) => - )} -
-
e.stopPropagation()} - style={{ bottom: -this.props.panY, left: `${this._searching ? 0 : 100}%` }}> - -
- - - -
); + @action + createTextAnnotation = (sel: Selection, selRange: Range) => { + if (this._mainCont.current) { + let boundingRect = this._mainCont.current.getBoundingClientRect(); + let clientRects = selRange.getClientRects(); + for (let i = 0; i < clientRects.length; i++) { + let rect = clientRects.item(i); + if (rect/* && rect.width !== this._mainCont.current.getBoundingClientRect().width && rect.height !== this._mainCont.current.getBoundingClientRect().height / this.props.pdf.numPages*/) { + let scaleY = this._mainCont.current.offsetHeight / boundingRect.height; + let scaleX = this._mainCont.current.offsetWidth / boundingRect.width; + if (rect.width !== this._mainCont.current.clientWidth) { + let annoBox = document.createElement("div"); + annoBox.className = "pdfPage-annotationBox"; + // transforms the positions from screen onto the pdf div + annoBox.style.top = ((rect.top - boundingRect.top) * scaleY + this._mainCont.current.scrollTop).toString(); + annoBox.style.left = ((rect.left - boundingRect.left) * scaleX).toString(); + annoBox.style.width = (rect.width * this._mainCont.current.offsetWidth / boundingRect.width).toString(); + annoBox.style.height = (rect.height * this._mainCont.current.offsetHeight / boundingRect.height).toString(); + this.createAnnotation(annoBox, this.getPageFromScroll(rect.top)); + } + } + } + } + let text = selRange.cloneContents().textContent; + text && this.setSelectionText(text); + + // clear selection + if (sel.empty) { // Chrome + sel.empty(); + } else if (sel.removeAllRanges) { // Firefox + sel.removeAllRanges(); + } } -} -export enum AnnotationTypes { Region } + @action + onSelectEnd = (e: PointerEvent): void => { + if (this._marqueeing) { + if (this._marqueeWidth > 10 || this._marqueeHeight > 10) { + let marquees = this._mainCont.current!.getElementsByClassName("pdfViewer-dragAnnotationBox"); + if (marquees && marquees.length) { // make a copy of the marquee + let copy = document.createElement("div"); + let marquee = marquees[0] as HTMLDivElement; + let style = marquee.style; + copy.style.left = style.left; + copy.style.top = style.top; + copy.style.width = style.width; + copy.style.height = style.height; + copy.style.border = style.border; + copy.style.opacity = style.opacity; + copy.className = "pdfPage-annotationBox"; + this.createAnnotation(copy, this.getPageFromScroll(this._marqueeY)); + marquee.style.opacity = "0"; + } + + if (!e.ctrlKey) { + PDFMenu.Instance.Status = "snippet"; + PDFMenu.Instance.Marquee = { left: this._marqueeX, top: this._marqueeY, width: this._marqueeWidth, height: this._marqueeHeight }; + } + PDFMenu.Instance.jumpTo(e.clientX, e.clientY); + } + + this._marqueeHeight = this._marqueeWidth = 0; + } + else { + let sel = window.getSelection(); + if (sel && sel.type === "Range") { + let selRange = sel.getRangeAt(0); + this.createTextAnnotation(sel, selRange); + PDFMenu.Instance.jumpTo(e.clientX, e.clientY); + } + } -class SimpleLinkService { - _viewer: PDFViewer; - _pdfjsView: any; + if (PDFMenu.Instance.Highlighting) { + this.highlight(undefined, "goldenrod"); + } + else { + PDFMenu.Instance.StartDrag = this.startDrag; + PDFMenu.Instance.Highlight = this.highlight; + } + document.removeEventListener("pointermove", this.onSelectMove); + document.removeEventListener("pointerup", this.onSelectEnd); + } - constructor(viewer: PDFViewer) { - this._viewer = viewer; + @action + highlight = (targetDoc: Doc | undefined, color: string) => { + // creates annotation documents for current highlights + let annotationDoc = this.makeAnnotationDocument(targetDoc, color, false); + Doc.AddDocToList(this.props.fieldExtensionDoc, this.props.fieldExt, annotationDoc); + return annotationDoc; } - setPDFJSview(v: any) { this._pdfjsView = v; } - navigateTo() { } + /** + * This is temporary for creating annotations from highlights. It will + * start a drag event and create or put the necessary info into the drag event. + */ + @action + startDrag = (e: PointerEvent, ele: HTMLElement): void => { + e.preventDefault(); + e.stopPropagation(); + let targetDoc = Docs.Create.TextDocument({ width: 200, height: 200, title: "New Annotation" }); + targetDoc.targetPage = this.getPageFromScroll(this._marqueeY); + let annotationDoc = this.highlight(undefined, "red"); + annotationDoc.linkedToDoc = false; + let dragData = new DragManager.AnnotationDragData(this.props.Document, annotationDoc, targetDoc); + DragManager.StartAnnotationDrag([ele], dragData, e.pageX, e.pageY, { + handlers: { + dragComplete: () => { + if (!annotationDoc.linkedToDoc) { + let annotations = DocListCast(annotationDoc.annotations); + annotations && annotations.forEach(anno => anno.target = targetDoc); + DocUtils.MakeLink(annotationDoc, targetDoc, dragData.targetContext, `Annotation from ${StrCast(this.props.Document.title)}`); + } + } + }, + hideSource: false + }); + } - getDestinationHash() { return "#"; } + createSnippet = (marquee: { left: number, top: number, width: number, height: number }): void => { + let view = Doc.MakeAlias(this.props.Document); + let data = Doc.MakeDelegate(Doc.GetProto(this.props.Document)); + data.title = StrCast(data.title) + "_snippet"; + view.proto = data; + view.nativeHeight = marquee.height; + view.height = (this.props.Document[WidthSym]() / NumCast(this.props.Document.nativeWidth)) * marquee.height; + view.nativeWidth = this.props.Document.nativeWidth; + view.startY = marquee.top; + view.width = this.props.Document[WidthSym](); + DragManager.StartDocumentDrag([], new DragManager.DocumentDragData([view]), 0, 0); + } - getAnchorUrl() { return "#"; } + // this is called with the document that was dragged and the collection to move it into. + // if the target collection is the same as this collection, then the move will be allowed. + // otherwise, the document being moved must be able to be removed from its container before + // moving it into the target. + @action.bound + moveDocument(doc: Doc, targetCollection: Doc, addDocument: (doc: Doc) => boolean): boolean { + if (Doc.AreProtosEqual(this.props.Document, targetCollection)) { + return true; + } + return this.removeDocument(doc) ? addDocument(doc) : false; + } - setHash() { } - isPageVisible(page: number) { return true; } + @action.bound + removeDocument(doc: Doc): boolean { + //TODO This won't create the field if it doesn't already exist + let targetDataDoc = this.props.fieldExtensionDoc; + let targetField = this.props.fieldExt; + let value = Cast(targetDataDoc[targetField], listSpec(Doc), []); + let index = value.reduce((p, v, i) => (v instanceof Doc && v === doc) ? i : p, -1); + index = index !== -1 ? index : value.reduce((p, v, i) => (v instanceof Doc && Doc.AreProtosEqual(v, doc)) ? i : p, -1); + index !== -1 && value.splice(index, 1); + return true; + } + scrollXf = () => { + return this._mainCont.current ? this.props.ScreenToLocalTransform().translate(0, this._mainCont.current.scrollTop) : this.props.ScreenToLocalTransform(); + } + setPreviewCursor = (func?: (x: number, y: number, drag: boolean) => void) => { + this._setPreviewCursor = func; + } + onClick = (e: React.MouseEvent) => { + this._setPreviewCursor && + e.button === 0 && + Math.abs(e.clientX - this._downX) < 3 && + Math.abs(e.clientY - this._downY) < 3 && + this._setPreviewCursor(e.clientX, e.clientY, false); + } + whenActiveChanged = (isActive: boolean) => { + this._isChildActive = isActive; + this.props.whenActiveChanged(isActive); + } + active = () => { + return this.props.isSelected() || this._isChildActive || this.props.renderDepth === 0; + } - executeNamedAction() { } + getCoverImage = () => { + if (!this.props.Document[HeightSym]()) { + setTimeout(() => { + this.props.Document.height = this.props.Document[WidthSym]() * this._coverPath.height / this._coverPath.width; + this.props.Document.nativeHeight = nativeWidth * this._coverPath.height / this._coverPath.width; + }, 0); + } + let nativeWidth = NumCast(this.props.Document.nativeWidth); + let nativeHeight = NumCast(this.props.Document.nativeHeight); + return this._showWaiting = false)} + style={{ position: "absolute", display: "inline-block", top: 0, left: 0, width: `${nativeWidth}px`, height: `${nativeHeight}px` }} />; + } - cachePageRef() { } - get pagesCount() { return this._viewer._pdfViewer.pagesCount; } + @action + onZoomWheel = (e: React.WheelEvent) => { + e.stopPropagation(); + if (e.ctrlKey) { + let curScale = Number(this.pdfViewer.currentScaleValue); + this.pdfViewer.currentScaleValue = Math.max(1, Math.min(10, curScale + curScale * e.deltaY / 1000)); + this._zoomed = Number(this.pdfViewer.currentScaleValue); + } + } - get page() { return NumCast(this._viewer.props.Document.curPage); } - set page(p: number) { - this._pdfjsView._ensurePdfPageLoaded(this._pdfjsView._pages[p - 1]).then(() => { - this._pdfjsView.renderingQueue.renderView(this._pdfjsView._pages[p - 1]); - if (this._viewer.props.GoToPage) - this._viewer.props.GoToPage(p); - }); + @computed get annotationLayer() { + return
+ {this.nonDocAnnotations.sort((a, b) => NumCast(a.y) - NumCast(b.y)).map((anno, index) => + )} +
; + } + @computed get pdfViewerDiv() { + return
; } + @computed get standinViews() { + return <> + {this._showCover ? this.getCoverImage() : (null)} + {this._showWaiting ? : (null)} + ; + } + marqueeWidth = () => this._marqueeWidth; + marqueeHeight = () => this._marqueeHeight; + marqueeX = () => this._marqueeX; + marqueeY = () => this._marqueeY; + marqueeing = () => this._marqueeing; + render() { + trace(); + return (
+ {this.pdfViewerDiv} + +
+ {this.annotationLayer} + NumCast(this.props.Document.scrollHeight, NumCast(this.props.Document.nativeHeight))} + PanelWidth={() => this._pageSizes.length && this._pageSizes[0] ? this._pageSizes[0].width : NumCast(this.props.Document.nativeWidth)} + focus={emptyFunction} + isSelected={this.props.isSelected} + select={emptyFunction} + active={this.active} + ContentScaling={returnOne} + whenActiveChanged={this.whenActiveChanged} + removeDocument={this.removeDocument} + moveDocument={this.moveDocument} + addDocument={(doc: Doc, allow: boolean | undefined) => { Doc.AddDocToList(this.props.fieldExtensionDoc, this.props.fieldExt, doc); return true; }} + CollectionView={this.props.ContainingCollectionView} + ScreenToLocalTransform={this.scrollXf} + ruleProvider={undefined} + renderDepth={this.props.renderDepth + 1} + ContainingCollectionDoc={this.props.ContainingCollectionView && this.props.ContainingCollectionView.props.Document} + chromeCollapsed={true}> + +
+ {this.standinViews} +
); + } +} + +interface PdfViewerMarqueeProps { + isMarqueeing: () => boolean; + width: () => number; + height: () => number; + x: () => number; + y: () => number; +} + +@observer +class PdfViewerMarquee extends React.Component { + render() { + return !this.props.isMarqueeing() ? (null) :
+
; + } +} - get rotation() { return 0; } - set rotation(value: any) { } -} \ No newline at end of file +export enum AnnotationTypes { Region } diff --git a/src/client/views/pdf/Page.scss b/src/client/views/pdf/Page.scss deleted file mode 100644 index d8034b4b4..000000000 --- a/src/client/views/pdf/Page.scss +++ /dev/null @@ -1,36 +0,0 @@ - -.pdfViewer-text { - .page { - position: relative; - } -} -.pdfPage-cont { - position: relative; - - .pdfPage-canvasContainer { - position: absolute; - } - - .pdfPage-dragAnnotationBox { - position: absolute; - background-color: transparent; - opacity: 0.1; - } - - .pdfPage-textLayer { - position: absolute; - width: 100%; - height: 100%; - div { - user-select: text; - } - span { - color: transparent; - position: absolute; - white-space: pre; - cursor: text; - -webkit-transform-origin: 0% 0%; - transform-origin: 0% 0%; - } - } -} \ No newline at end of file diff --git a/src/client/views/pdf/Page.tsx b/src/client/views/pdf/Page.tsx deleted file mode 100644 index 533247170..000000000 --- a/src/client/views/pdf/Page.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import { action, IReactionDisposer, observable } from "mobx"; -import { observer } from "mobx-react"; -import * as Pdfjs from "pdfjs-dist"; -import "pdfjs-dist/web/pdf_viewer.css"; -import { Doc, DocListCastAsync, Opt, WidthSym } from "../../../new_fields/Doc"; -import { BoolCast, Cast, NumCast, StrCast } from "../../../new_fields/Types"; -import { Docs, DocUtils } from "../../documents/Documents"; -import { DragManager } from "../../util/DragManager"; -import PDFMenu from "./PDFMenu"; -import { scale } from "./PDFViewer"; -import "./Page.scss"; -import React = require("react"); - - -interface IPageProps { - size: { width: number, height: number }; - pdf: Pdfjs.PDFDocumentProxy; - name: string; - numPages: number; - page: number; - pageLoaded: (page: Pdfjs.PDFPageViewport) => void; - fieldExtensionDoc: Doc; - Document: Doc; - renderAnnotations: (annotations: Doc[], removeOld: boolean) => void; - sendAnnotations: (annotations: HTMLDivElement[], page: number) => void; - createAnnotation: (div: HTMLDivElement, page: number) => void; - makeAnnotationDocuments: (doc: Doc | undefined, color: string, linkTo: boolean) => Doc; - getScrollFromPage: (page: number) => number; - setSelectionText: (text: string) => void; -} - -@observer -export default class Page extends React.Component { - @observable private _state: "N/A" | "rendering" = "N/A"; - @observable private _width: number = this.props.size.width; - @observable private _height: number = this.props.size.height; - @observable private _page: Opt; - @observable private _currPage: number = this.props.page + 1; - @observable private _marqueeX: number = 0; - @observable private _marqueeY: number = 0; - @observable private _marqueeWidth: number = 0; - @observable private _marqueeHeight: number = 0; - - private _canvas: React.RefObject = React.createRef(); - private _textLayer: React.RefObject = React.createRef(); - private _marquee: React.RefObject = React.createRef(); - private _marqueeing: boolean = false; - private _reactionDisposer?: IReactionDisposer; - private _startY: number = 0; - private _startX: number = 0; - - componentDidMount = (): void => this.loadPage(this.props.pdf); - - componentDidUpdate = (): void => this.loadPage(this.props.pdf); - - componentWillUnmount = (): void => this._reactionDisposer && this._reactionDisposer(); - - loadPage = (pdf: Pdfjs.PDFDocumentProxy): void => { - pdf.getPage(this._currPage).then(page => this.renderPage(page)); - } - - @action - renderPage = (page: Pdfjs.PDFPageProxy): void => { - // lower scale = easier to read at small sizes, higher scale = easier to read at large sizes - if (this._state !== "rendering" && !this._page && this._canvas.current && this._textLayer.current) { - this._state = "rendering"; - let viewport = page.getViewport(scale); - this._canvas.current.width = this._width = viewport.width; - this._canvas.current.height = this._height = viewport.height; - this.props.pageLoaded(viewport); - let ctx = this._canvas.current.getContext("2d"); - if (ctx) { - //@ts-ignore - page.render({ canvasContext: ctx, viewport: viewport, enableWebGL: true }); // renders the page onto the canvas context - page.getTextContent().then(res => // renders text onto the text container - //@ts-ignore - Pdfjs.renderTextLayer({ - textContent: res, - container: this._textLayer.current, - viewport: viewport - })); - - this._page = page; - } - } - } - - @action - highlight = (targetDoc: Doc | undefined, color: string) => { - // creates annotation documents for current highlights - let annotationDoc = this.props.makeAnnotationDocuments(targetDoc, color, false); - Doc.AddDocToList(this.props.fieldExtensionDoc, "annotations", annotationDoc); - return annotationDoc; - } - - /** - * This is temporary for creating annotations from highlights. It will - * start a drag event and create or put the necessary info into the drag event. - */ - @action - startDrag = (e: PointerEvent, ele: HTMLElement): void => { - e.preventDefault(); - e.stopPropagation(); - if (this._textLayer.current) { - let targetDoc = Docs.Create.TextDocument({ width: 200, height: 200, title: "New Annotation" }); - targetDoc.targetPage = this.props.page; - let annotationDoc = this.highlight(undefined, "red"); - annotationDoc.linkedToDoc = false; - let dragData = new DragManager.AnnotationDragData(this.props.Document, annotationDoc, targetDoc); - DragManager.StartAnnotationDrag([ele], dragData, e.pageX, e.pageY, { - handlers: { - dragComplete: async () => { - if (!BoolCast(annotationDoc.linkedToDoc)) { - let annotations = await DocListCastAsync(annotationDoc.annotations); - annotations && annotations.forEach(anno => anno.target = targetDoc); - DocUtils.MakeLink(annotationDoc, targetDoc, dragData.targetContext, `Annotation from ${StrCast(this.props.Document.title)}`); - } - } - }, - hideSource: false - }); - } - } - - // cleans up events and boolean - endDrag = (e: PointerEvent): void => { - e.stopPropagation(); - } - - createSnippet = (marquee: { left: number, top: number, width: number, height: number }): void => { - let view = Doc.MakeAlias(this.props.Document); - let data = Doc.MakeDelegate(Doc.GetProto(this.props.Document)); - data.title = StrCast(data.title) + "_snippet"; - view.proto = data; - view.nativeHeight = marquee.height; - view.height = (this.props.Document[WidthSym]() / NumCast(this.props.Document.nativeWidth)) * marquee.height; - view.nativeWidth = this.props.Document.nativeWidth; - view.startY = marquee.top + this.props.getScrollFromPage(this.props.page); - view.width = this.props.Document[WidthSym](); - DragManager.StartDocumentDrag([], new DragManager.DocumentDragData([view]), 0, 0); - } - - @action - onPointerDown = (e: React.PointerEvent): void => { - // if alt+left click, drag and annotate - if (NumCast(this.props.Document.scale, 1) !== 1) return; - if (!e.altKey && e.button === 0) { - PDFMenu.Instance.StartDrag = this.startDrag; - PDFMenu.Instance.Highlight = this.highlight; - PDFMenu.Instance.Snippet = this.createSnippet; - PDFMenu.Instance.Status = "pdf"; - PDFMenu.Instance.fadeOut(true); - if (e.target && (e.target as any).parentElement === this._textLayer.current) { - e.stopPropagation(); - if (!e.ctrlKey) { - this.props.sendAnnotations([], -1); - } - } - else { - // set marquee x and y positions to the spatially transformed position - if (this._textLayer.current) { - let boundingRect = this._textLayer.current.getBoundingClientRect(); - this._startX = this._marqueeX = (e.clientX - boundingRect.left) * (this._textLayer.current.offsetWidth / boundingRect.width); - this._startY = this._marqueeY = (e.clientY - boundingRect.top) * (this._textLayer.current.offsetHeight / boundingRect.height); - } - this._marqueeing = true; - this._marquee.current && (this._marquee.current.style.opacity = "0.2"); - this.props.sendAnnotations([], -1); - } - document.removeEventListener("pointermove", this.onSelectStart); - document.addEventListener("pointermove", this.onSelectStart); - document.removeEventListener("pointerup", this.onSelectEnd); - document.addEventListener("pointerup", this.onSelectEnd); - } - } - - @action - onSelectStart = (e: PointerEvent): void => { - if (this._marqueeing && this._textLayer.current) { - // transform positions and find the width and height to set the marquee to - let boundingRect = this._textLayer.current.getBoundingClientRect(); - this._marqueeWidth = ((e.clientX - boundingRect.left) * (this._textLayer.current.offsetWidth / boundingRect.width)) - this._startX; - this._marqueeHeight = ((e.clientY - boundingRect.top) * (this._textLayer.current.offsetHeight / boundingRect.height)) - this._startY; - this._marqueeX = Math.min(this._startX, this._startX + this._marqueeWidth); - this._marqueeY = Math.min(this._startY, this._startY + this._marqueeHeight); - this._marqueeWidth = Math.abs(this._marqueeWidth); - e.stopPropagation(); - e.preventDefault(); - } - else if (e.target && (e.target as any).parentElement === this._textLayer.current) { - e.stopPropagation(); - } - } - - @action - onSelectEnd = (e: PointerEvent): void => { - if (this._marqueeing) { - this._marqueeing = false; - if (this._marqueeWidth > 10 || this._marqueeHeight > 10) { - if (this._marquee.current) { // make a copy of the marquee - let copy = document.createElement("div"); - let style = this._marquee.current.style; - copy.style.left = style.left; - copy.style.top = style.top; - copy.style.width = style.width; - copy.style.height = style.height; - copy.style.border = style.border; - copy.style.opacity = style.opacity; - copy.className = "pdfPage-annotationBox"; - this.props.createAnnotation(copy, this.props.page); - this._marquee.current.style.opacity = "0"; - } - - if (!e.ctrlKey) { - PDFMenu.Instance.Status = "snippet"; - PDFMenu.Instance.Marquee = { left: this._marqueeX, top: this._marqueeY, width: this._marqueeWidth, height: this._marqueeHeight }; - } - PDFMenu.Instance.jumpTo(e.clientX, e.clientY); - } - - this._marqueeHeight = this._marqueeWidth = 0; - } - else { - let sel = window.getSelection(); - if (sel && sel.type === "Range") { - let selRange = sel.getRangeAt(0); - this.createTextAnnotation(sel, selRange); - PDFMenu.Instance.jumpTo(e.clientX, e.clientY); - } - } - - if (PDFMenu.Instance.Highlighting) { - this.highlight(undefined, "goldenrod"); - } - else { - PDFMenu.Instance.StartDrag = this.startDrag; - PDFMenu.Instance.Highlight = this.highlight; - } - document.removeEventListener("pointermove", this.onSelectStart); - document.removeEventListener("pointerup", this.onSelectEnd); - } - - @action - createTextAnnotation = (sel: Selection, selRange: Range) => { - if (this._textLayer.current) { - let boundingRect = this._textLayer.current.getBoundingClientRect(); - let clientRects = selRange.getClientRects(); - for (let i = 0; i < clientRects.length; i++) { - let rect = clientRects.item(i); - if (rect && rect.width !== this._textLayer.current.getBoundingClientRect().width && rect.height !== this._textLayer.current.getBoundingClientRect().height) { - let annoBox = document.createElement("div"); - annoBox.className = "pdfPage-annotationBox"; - // transforms the positions from screen onto the pdf div - annoBox.style.top = ((rect.top - boundingRect.top) * (this._textLayer.current.offsetHeight / boundingRect.height)).toString(); - annoBox.style.left = ((rect.left - boundingRect.left) * (this._textLayer.current.offsetWidth / boundingRect.width)).toString(); - annoBox.style.width = (rect.width * this._textLayer.current.offsetWidth / boundingRect.width).toString(); - annoBox.style.height = (rect.height * this._textLayer.current.offsetHeight / boundingRect.height).toString(); - this.props.createAnnotation(annoBox, this.props.page); - } - } - } - let text = selRange.cloneContents().textContent; - text && this.props.setSelectionText(text); - - // clear selection - if (sel.empty) { // Chrome - sel.empty(); - } else if (sel.removeAllRanges) { // Firefox - sel.removeAllRanges(); - } - } - - doubleClick = (e: React.MouseEvent) => { - if (e.target && (e.target as any).parentElement === this._textLayer.current) { - // do something to select the paragraph ideally - } - } - - render() { - return ( -
- -
-
-
-
); - } -} \ No newline at end of file diff --git a/src/client/views/presentationview/PresentationModeMenu.tsx b/src/client/views/presentationview/PresentationModeMenu.tsx index 4de8da587..0dd2b7731 100644 --- a/src/client/views/presentationview/PresentationModeMenu.tsx +++ b/src/client/views/presentationview/PresentationModeMenu.tsx @@ -21,10 +21,12 @@ export interface PresModeMenuProps { export default class PresModeMenu extends React.Component { @observable private _top: number = 20; - @observable private _right: number = 0; + @observable private _left: number = window.innerWidth - 160; @observable private _opacity: number = 1; @observable private _transition: string = "opacity 0.5s"; @observable private _transitionDelay: string = ""; + private _offsetY: number = 0; + private _offsetX: number = 0; private _mainCont: React.RefObject = React.createRef(); @@ -35,8 +37,8 @@ export default class PresModeMenu extends React.Component { */ @action dragging = (e: PointerEvent) => { - this._right -= e.movementX; - this._top += e.movementY; + this._left = e.pageX - this._offsetX; + this._top = e.pageY - this._offsetY; e.stopPropagation(); e.preventDefault(); @@ -63,6 +65,9 @@ export default class PresModeMenu extends React.Component { document.removeEventListener("pointerup", this.dragEnd); document.addEventListener("pointerup", this.dragEnd); + this._offsetX = this._mainCont.current!.getBoundingClientRect().width - e.nativeEvent.offsetX; + this._offsetY = e.nativeEvent.offsetY; + e.stopPropagation(); e.preventDefault(); } @@ -82,7 +87,7 @@ export default class PresModeMenu extends React.Component { render() { return (
+ style={{ left: this._left, top: this._top, opacity: this._opacity, transition: this._transition, transitionDelay: this._transitionDelay }}> {this.renderPlayPauseButton()} diff --git a/src/debug/Test.tsx b/src/debug/Test.tsx index 79f87f4ac..3baedce4b 100644 --- a/src/debug/Test.tsx +++ b/src/debug/Test.tsx @@ -2,39 +2,12 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { DocServer } from '../client/DocServer'; import { Doc } from '../new_fields/Doc'; +import * as Pdfjs from "pdfjs-dist"; +import "pdfjs-dist/web/pdf_viewer.css"; +import { Utils } from '../Utils'; +const PDFJSViewer = require("pdfjs-dist/web/pdf_viewer"); const protoId = "protoDoc"; const delegateId = "delegateDoc"; class Test extends React.Component { - onCreateClick = () => { - const proto = new Doc(protoId, true); - const delegate = Doc.MakeDelegate(proto, delegateId); - } - - onReadClick = async () => { - console.log("reading"); - const docs = await DocServer.GetRefFields([delegateId, protoId]); - console.log("done"); - console.log(docs); - } - - onDeleteClick = () => { - DocServer.DeleteDocuments([protoId, delegateId]); - } - - render() { - return ( -
- - - -
- ); - } } - -DocServer.init(window.location.protocol, window.location.hostname, 4321, "test"); -ReactDOM.render( - , - document.getElementById('root') -); \ No newline at end of file diff --git a/src/new_fields/Doc.ts b/src/new_fields/Doc.ts index fef3b8cc5..605877efa 100644 --- a/src/new_fields/Doc.ts +++ b/src/new_fields/Doc.ts @@ -13,6 +13,7 @@ import { listSpec } from "./Schema"; import { ComputedField } from "./ScriptField"; import { BoolCast, Cast, FieldValue, NumCast, PromiseValue, StrCast, ToConstructor } from "./Types"; import { deleteProperty, getField, getter, makeEditable, makeReadOnly, setter, updateFunction } from "./util"; +import { intersectRect } from "../Utils"; export namespace Field { export function toKeyValueString(doc: Doc, key: string): string { @@ -343,7 +344,7 @@ export namespace Doc { let list = Cast(target[key], listSpec(Doc)); if (list) { if (allowDuplicates !== true) { - let pind = list.reduce((l, d, i) => d instanceof Doc && Doc.AreProtosEqual(d, doc) ? i : l, -1); + let pind = list.reduce((l, d, i) => d instanceof Doc && d[Id] === doc[Id] ? i : l, -1); if (pind !== -1) { list.splice(pind, 1); } @@ -614,6 +615,18 @@ export namespace Doc { }), 0); } + export function overlapping(doc: Doc, doc2: Doc, clusterDistance: number) { + var x2 = NumCast(doc2.x) - clusterDistance; + var y2 = NumCast(doc2.y) - clusterDistance; + var w2 = NumCast(doc2.width) + clusterDistance; + var h2 = NumCast(doc2.height) + clusterDistance; + var x = NumCast(doc.x) - clusterDistance; + var y = NumCast(doc.y) - clusterDistance; + var w = NumCast(doc.width) + clusterDistance; + var h = NumCast(doc.height) + clusterDistance; + return doc.z === doc2.z && intersectRect({ left: x, top: y, width: w, height: h }, { left: x2, top: y2, width: w2, height: h2 }); + } + export function isBrushedHighlightedDegree(doc: Doc) { if (Doc.IsHighlighted(doc)) { return 3; -- cgit v1.2.3-70-g09d2 From 69de8c235a6580ac222ef3f5b31746f6bc144659 Mon Sep 17 00:00:00 2001 From: Bob Zeleznik Date: Tue, 28 Apr 2020 10:48:16 -0400 Subject: rearranged text files --- .../apis/google_docs/GooglePhotosClientUtils.ts | 2 +- src/client/documents/Documents.ts | 2 +- src/client/util/DashDocCommentView.tsx | 95 -- src/client/util/DashDocView.tsx | 269 ---- src/client/util/DashFieldView.scss | 36 - src/client/util/DashFieldView.tsx | 211 ---- src/client/util/FootnoteView.tsx | 162 --- src/client/util/ImageResizeView.tsx | 138 --- src/client/util/ParagraphNodeSpec.ts | 143 --- src/client/util/ProsemirrorExampleTransfer.ts | 241 ---- src/client/util/RichTextMenu.scss | 121 -- src/client/util/RichTextMenu.tsx | 875 ------------- src/client/util/RichTextRules.ts | 4 +- src/client/util/RichTextSchema.tsx | 718 ----------- src/client/util/SummaryView.tsx | 81 -- src/client/util/TooltipTextMenu.scss | 372 ------ src/client/util/marks_rts.ts | 296 ----- src/client/util/nodes_rts.ts | 264 ---- src/client/util/prosemirrorPatches.js | 139 --- src/client/util/schema_rts.ts | 26 - src/client/views/DocumentButtonBar.tsx | 2 +- src/client/views/InkingControl.tsx | 2 +- src/client/views/MainView.tsx | 2 +- .../views/collections/CollectionCarouselView.tsx | 2 +- src/client/views/collections/CollectionSubView.tsx | 2 +- .../collectionFreeForm/CollectionFreeFormView.tsx | 2 +- .../collections/collectionFreeForm/MarqueeView.tsx | 2 +- src/client/views/nodes/DocumentContentsView.tsx | 2 +- src/client/views/nodes/FormattedTextBox.scss | 265 ---- src/client/views/nodes/FormattedTextBox.tsx | 1306 -------------------- .../views/nodes/FormattedTextBoxComment.scss | 33 - src/client/views/nodes/FormattedTextBoxComment.tsx | 236 ---- .../nodes/formattedText/DashDocCommentView.tsx | 95 ++ .../views/nodes/formattedText/DashDocView.tsx | 269 ++++ .../views/nodes/formattedText/DashFieldView.scss | 36 + .../views/nodes/formattedText/DashFieldView.tsx | 211 ++++ .../views/nodes/formattedText/FootnoteView.tsx | 162 +++ .../nodes/formattedText/FormattedTextBox.scss | 265 ++++ .../views/nodes/formattedText/FormattedTextBox.tsx | 1303 +++++++++++++++++++ .../formattedText/FormattedTextBoxComment.scss | 33 + .../formattedText/FormattedTextBoxComment.tsx | 236 ++++ .../views/nodes/formattedText/ImageResizeView.tsx | 138 +++ .../views/nodes/formattedText/ParagraphNodeSpec.ts | 143 +++ .../formattedText/ProsemirrorExampleTransfer.ts | 241 ++++ .../views/nodes/formattedText/RichTextMenu.scss | 121 ++ .../views/nodes/formattedText/RichTextMenu.tsx | 875 +++++++++++++ .../views/nodes/formattedText/RichTextRules.ts | 319 +++++ .../views/nodes/formattedText/RichTextSchema.tsx | 718 +++++++++++ .../views/nodes/formattedText/SummaryView.tsx | 81 ++ .../views/nodes/formattedText/TooltipTextMenu.scss | 372 ++++++ src/client/views/nodes/formattedText/marks_rts.ts | 296 +++++ src/client/views/nodes/formattedText/nodes_rts.ts | 264 ++++ .../nodes/formattedText/prosemirrorPatches.js | 139 +++ src/client/views/nodes/formattedText/schema_rts.ts | 26 + src/mobile/MobileInterface.tsx | 2 +- src/new_fields/RichTextUtils.ts | 4 +- .../authentication/models/current_user_utils.ts | 2 +- 57 files changed, 6359 insertions(+), 6043 deletions(-) delete mode 100644 src/client/util/DashDocCommentView.tsx delete mode 100644 src/client/util/DashDocView.tsx delete mode 100644 src/client/util/DashFieldView.scss delete mode 100644 src/client/util/DashFieldView.tsx delete mode 100644 src/client/util/FootnoteView.tsx delete mode 100644 src/client/util/ImageResizeView.tsx delete mode 100644 src/client/util/ParagraphNodeSpec.ts delete mode 100644 src/client/util/ProsemirrorExampleTransfer.ts delete mode 100644 src/client/util/RichTextMenu.scss delete mode 100644 src/client/util/RichTextMenu.tsx delete mode 100644 src/client/util/RichTextSchema.tsx delete mode 100644 src/client/util/SummaryView.tsx delete mode 100644 src/client/util/TooltipTextMenu.scss delete mode 100644 src/client/util/marks_rts.ts delete mode 100644 src/client/util/nodes_rts.ts delete mode 100644 src/client/util/prosemirrorPatches.js delete mode 100644 src/client/util/schema_rts.ts delete mode 100644 src/client/views/nodes/FormattedTextBox.scss delete mode 100644 src/client/views/nodes/FormattedTextBox.tsx delete mode 100644 src/client/views/nodes/FormattedTextBoxComment.scss delete mode 100644 src/client/views/nodes/FormattedTextBoxComment.tsx create mode 100644 src/client/views/nodes/formattedText/DashDocCommentView.tsx create mode 100644 src/client/views/nodes/formattedText/DashDocView.tsx create mode 100644 src/client/views/nodes/formattedText/DashFieldView.scss create mode 100644 src/client/views/nodes/formattedText/DashFieldView.tsx create mode 100644 src/client/views/nodes/formattedText/FootnoteView.tsx create mode 100644 src/client/views/nodes/formattedText/FormattedTextBox.scss create mode 100644 src/client/views/nodes/formattedText/FormattedTextBox.tsx create mode 100644 src/client/views/nodes/formattedText/FormattedTextBoxComment.scss create mode 100644 src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx create mode 100644 src/client/views/nodes/formattedText/ImageResizeView.tsx create mode 100644 src/client/views/nodes/formattedText/ParagraphNodeSpec.ts create mode 100644 src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts create mode 100644 src/client/views/nodes/formattedText/RichTextMenu.scss create mode 100644 src/client/views/nodes/formattedText/RichTextMenu.tsx create mode 100644 src/client/views/nodes/formattedText/RichTextRules.ts create mode 100644 src/client/views/nodes/formattedText/RichTextSchema.tsx create mode 100644 src/client/views/nodes/formattedText/SummaryView.tsx create mode 100644 src/client/views/nodes/formattedText/TooltipTextMenu.scss create mode 100644 src/client/views/nodes/formattedText/marks_rts.ts create mode 100644 src/client/views/nodes/formattedText/nodes_rts.ts create mode 100644 src/client/views/nodes/formattedText/prosemirrorPatches.js create mode 100644 src/client/views/nodes/formattedText/schema_rts.ts (limited to 'src/client/views/MainView.tsx') diff --git a/src/client/apis/google_docs/GooglePhotosClientUtils.ts b/src/client/apis/google_docs/GooglePhotosClientUtils.ts index 8c0149a89..e3f801c46 100644 --- a/src/client/apis/google_docs/GooglePhotosClientUtils.ts +++ b/src/client/apis/google_docs/GooglePhotosClientUtils.ts @@ -10,7 +10,7 @@ import { MediaItem, NewMediaItemResult } from "../../../server/apis/google/Share import { Utils } from "../../../Utils"; import { Docs, DocumentOptions } from "../../documents/Documents"; import { Networking } from "../../Network"; -import { FormattedTextBox } from "../../views/nodes/FormattedTextBox"; +import { FormattedTextBox } from "../../views/nodes/formattedText/FormattedTextBox"; import GoogleAuthenticationManager from "../GoogleAuthenticationManager"; import Photos = require('googlephotos'); diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index c64916897..f96e3bcd1 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -1,7 +1,7 @@ import { CollectionView } from "../views/collections/CollectionView"; import { CollectionViewType } from "../views/collections/CollectionView"; import { AudioBox } from "../views/nodes/AudioBox"; -import { FormattedTextBox } from "../views/nodes/FormattedTextBox"; +import { FormattedTextBox } from "../views/nodes/formattedText/FormattedTextBox"; import { ImageBox } from "../views/nodes/ImageBox"; import { KeyValueBox } from "../views/nodes/KeyValueBox"; import { PDFBox } from "../views/nodes/PDFBox"; diff --git a/src/client/util/DashDocCommentView.tsx b/src/client/util/DashDocCommentView.tsx deleted file mode 100644 index e716fac53..000000000 --- a/src/client/util/DashDocCommentView.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { IReactionDisposer, observable, reaction, runInAction } from "mobx"; -import { baseKeymap, toggleMark } from "prosemirror-commands"; -import { redo, undo } from "prosemirror-history"; -import { keymap } from "prosemirror-keymap"; -import { DOMOutputSpecArray, Fragment, MarkSpec, Node, NodeSpec, Schema, Slice } from "prosemirror-model"; -import { bulletList, listItem, orderedList } from 'prosemirror-schema-list'; -import { EditorState, NodeSelection, Plugin, TextSelection } from "prosemirror-state"; -import { StepMap } from "prosemirror-transform"; -import { EditorView } from "prosemirror-view"; -import * as ReactDOM from 'react-dom'; -import { Doc, DocListCast, Field, HeightSym, WidthSym } from "../../new_fields/Doc"; -import { Id } from "../../new_fields/FieldSymbols"; -import { List } from "../../new_fields/List"; -import { ObjectField } from "../../new_fields/ObjectField"; -import { listSpec } from "../../new_fields/Schema"; -import { SchemaHeaderField } from "../../new_fields/SchemaHeaderField"; -import { ComputedField } from "../../new_fields/ScriptField"; -import { BoolCast, Cast, NumCast, StrCast } from "../../new_fields/Types"; -import { emptyFunction, returnEmptyString, returnFalse, returnOne, Utils, returnZero } from "../../Utils"; -import { DocServer } from "../DocServer"; - -import React = require("react"); - -import { schema } from "./schema_rts"; - -interface IDashDocCommentView { - node: any; - view: any; - getPos: any; -} - -export class DashDocCommentView extends React.Component{ - constructor(props: IDashDocCommentView) { - super(props); - } - - targetNode = () => { // search forward in the prosemirror doc for the attached dashDocNode that is the target of the comment anchor - for (let i = this.props.getPos() + 1; i < this.props.view.state.doc.content.size; i++) { - const m = this.props.view.state.doc.nodeAt(i); - if (m && m.type === this.props.view.state.schema.nodes.dashDoc && m.attrs.docid === this.props.node.attrs.docid) { - return { node: m, pos: i, hidden: m.attrs.hidden } as { node: any, pos: number, hidden: boolean }; - } - } - const dashDoc = this.props.view.state.schema.nodes.dashDoc.create({ width: 75, height: 35, title: "dashDoc", docid: this.props.node.attrs.docid, float: "right" }); - this.props.view.dispatch(this.props.view.state.tr.insert(this.props.getPos() + 1, dashDoc)); - setTimeout(() => { try { this.props.view.dispatch(this.props.view.state.tr.setSelection(TextSelection.create(this.props.view.state.tr.doc, this.props.getPos() + 2))); } catch (e) { } }, 0); - return undefined; - } - - onPointerDownCollapse = (e: any) => e.stopPropagation(); - - onPointerUpCollapse = (e: any) => { - const target = this.targetNode(); - if (target) { - const expand = target.hidden; - const tr = this.props.view.state.tr.setNodeMarkup(target.pos, undefined, { ...target.node.attrs, hidden: target.node.attrs.hidden ? false : true }); - this.props.view.dispatch(tr.setSelection(TextSelection.create(tr.doc, this.props.getPos() + (expand ? 2 : 1)))); // update the attrs - setTimeout(() => { - expand && DocServer.GetRefField(this.props.node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc)); - try { this.props.view.dispatch(this.props.view.state.tr.setSelection(TextSelection.create(this.props.view.state.tr.doc, this.props.getPos() + (expand ? 2 : 1)))); } catch (e) { } - }, 0); - } - e.stopPropagation(); - } - - onPointerEnterCollapse = (e: any) => { - DocServer.GetRefField(this.props.node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc, false)); - e.preventDefault(); - e.stopPropagation(); - } - - onPointerLeaveCollapse = (e: any) => { - DocServer.GetRefField(this.props.node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowUnhighlight()); - e.preventDefault(); - e.stopPropagation(); - } - - render() { - - const collapsedId = "DashDocCommentView-" + this.props.node.attrs.docid; - - return ( - - - - ); - } -} \ No newline at end of file diff --git a/src/client/util/DashDocView.tsx b/src/client/util/DashDocView.tsx deleted file mode 100644 index 39809187f..000000000 --- a/src/client/util/DashDocView.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import { IReactionDisposer, reaction } from "mobx"; -import { NodeSelection } from "prosemirror-state"; -import { Doc, HeightSym, WidthSym } from "../../new_fields/Doc"; -import { Id } from "../../new_fields/FieldSymbols"; -import { ObjectField } from "../../new_fields/ObjectField"; -import { ComputedField } from "../../new_fields/ScriptField"; -import { BoolCast, Cast, NumCast, StrCast } from "../../new_fields/Types"; -import { emptyFunction, returnEmptyString, returnFalse, Utils, returnZero } from "../../Utils"; -import { DocServer } from "../DocServer"; -import { Docs } from "../documents/Documents"; -import { DocumentView } from "../views/nodes/DocumentView"; -import { FormattedTextBox } from "../views/nodes/FormattedTextBox"; -import { Transform } from "./Transform"; -import React = require("react"); - -interface IDashDocView { - node: any; - view: any; - getPos: any; - tbox?: FormattedTextBox; - self: any; -} - -export class DashDocView extends React.Component { - - _dashDoc: Doc | undefined; - _reactionDisposer: IReactionDisposer | undefined; - _renderDisposer: IReactionDisposer | undefined; - _textBox: FormattedTextBox; - _finalLayout: any; - _resolvedDataDoc: any; - - - // constructor(node: any, view: any, getPos: any, tbox: FormattedTextBox) { - - constructor(props: IDashDocView) { - super(props); - - const node = this.props.node; - this._textBox = this.props.tbox as FormattedTextBox; - - const alias = node.attrs.alias; - const docid = node.attrs.docid || this._textBox.props.Document[Id]; - - DocServer.GetRefField(docid + alias).then(async dashDoc => { - if (!(dashDoc instanceof Doc)) { - alias && DocServer.GetRefField(docid).then(async dashDocBase => { - if (dashDocBase instanceof Doc) { - const aliasedDoc = Doc.MakeAlias(dashDocBase, docid + alias); - aliasedDoc.layoutKey = "layout"; - node.attrs.fieldKey && DocumentView.makeCustomViewClicked(aliasedDoc, Docs.Create.StackingDocument, node.attrs.fieldKey, undefined); - this._dashDoc = aliasedDoc; - // self.doRender(aliasedDoc, removeDoc, node, view, getPos); - } - }); - } else { - this._dashDoc = dashDoc; - // self.doRender(dashDoc, removeDoc, node, view, getPos); - } - }); - - this.onPointerLeave = this.onPointerLeave.bind(this); - this.onPointerEnter = this.onPointerEnter.bind(this); - this.onKeyDown = this.onKeyDown.bind(this); - this.onKeyPress = this.onKeyPress.bind(this); - this.onKeyUp = this.onKeyUp.bind(this); - this.onWheel = this.onWheel.bind(this); - } - /* #region Internal functions */ - - removeDoc = () => { - const view = this.props.view; - const pos = this.props.getPos(); - const ns = new NodeSelection(view.state.doc.resolve(pos)); - view.dispatch(view.state.tr.setSelection(ns).deleteSelection()); - return true; - } - - getDocTransform = () => { - const outerElement = document.getElementById('dash-document-view-outer') as HTMLElement; - const { scale, translateX, translateY } = Utils.GetScreenTransform(outerElement); - return new Transform(-translateX, -translateY, 1).scale(1 / this.contentScaling() / scale); - } - contentScaling = () => NumCast(this._dashDoc!._nativeWidth) > 0 ? this._dashDoc![WidthSym]() / NumCast(this._dashDoc!._nativeWidth) : 1; - - outerFocus = (target: Doc) => this._textBox.props.focus(this._textBox.props.Document); // ideally, this would scroll to show the focus target - - onKeyPress = (e: any) => { - e.stopPropagation(); - } - onWheel = (e: any) => { - e.preventDefault(); - } - onKeyUp = (e: any) => { - e.stopPropagation(); - } - onKeyDown = (e: any) => { - e.stopPropagation(); - if (e.key === "Tab" || e.key === "Enter") { - e.preventDefault(); - } - } - onPointerLeave = () => { - const ele = document.getElementById("DashDocCommentView-" + this.props.node.attrs.docid); - if (ele) { - (ele as HTMLDivElement).style.backgroundColor = ""; - } - } - onPointerEnter = () => { - const ele = document.getElementById("DashDocCommentView-" + this.props.node.attrs.docid); - if (ele) { - (ele as HTMLDivElement).style.backgroundColor = "orange"; - } - } - /*endregion*/ - - componentWillMount = () => { - this._reactionDisposer?.(); - } - - componentDidUpdate = () => { - - this._renderDisposer?.(); - this._renderDisposer = reaction(() => { - - const dashDoc = this._dashDoc as Doc; - const dashLayoutDoc = Doc.Layout(dashDoc); - const finalLayout = this.props.node.attrs.docid ? dashDoc : Doc.expandTemplateLayout(dashLayoutDoc, dashDoc, this.props.node.attrs.fieldKey); - - if (finalLayout) { - if (!Doc.AreProtosEqual(finalLayout, dashDoc)) { - finalLayout.rootDocument = dashDoc.aliasOf; - } - const layoutKey = StrCast(finalLayout.layoutKey); - const finalKey = layoutKey && StrCast(finalLayout[layoutKey]).split("'")?.[1]; - if (finalLayout !== dashDoc && finalKey) { - const finalLayoutField = finalLayout[finalKey]; - if (finalLayoutField instanceof ObjectField) { - finalLayout[finalKey + "-textTemplate"] = ComputedField.MakeFunction(`copyField(this.${finalKey})`, { this: Doc.name }); - } - } - this._finalLayout = finalLayout; - this._resolvedDataDoc = Cast(finalLayout.resolvedDataDoc, Doc, null); - return { finalLayout, resolvedDataDoc: Cast(finalLayout.resolvedDataDoc, Doc, null) }; - } - }, - (res) => { - - if (res) { - this._finalLayout = res.finalLayout; - this._resolvedDataDoc = res.resolvedDataDoc; - - this.forceUpdate(); // doReactRender(res.finalLayout, res.resolvedDataDoc), - } - }, - { fireImmediately: true }); - - } - - render() { - // doRender(dashDoc: Doc, removeDoc: any, node: any, view: any, getPos: any) { - - const node = this.props.node; - const view = this.props.view; - const getPos = this.props.getPos; - - const spanStyle = { - width: this.props.node.props.width, - height: this.props.node.props.height, - position: 'absolute' as 'absolute', - display: 'inline-block' - }; - - - const outerStyle = { - position: "relative" as "relative", - textIndent: "0", - border: "1px solid " + StrCast(this._textBox.Document.color, (Cast(Doc.UserDoc().activeWorkspace, Doc, null).darkScheme ? "dimGray" : "lightGray")), - width: this.props.node.props.width, - height: this.props.node.props.height, - display: this.props.node.props.hidden ? "none" : "inline-block", - float: this.props.node.props.float, - }; - - const dashDoc = this._dashDoc as Doc; - const self = this; - const dashLayoutDoc = Doc.Layout(dashDoc); - const finalLayout = node.attrs.docid ? dashDoc : Doc.expandTemplateLayout(dashLayoutDoc, dashDoc, node.attrs.fieldKey); - const resolvedDataDoc = this._resolvedDataDoc; //Added this - - if (!finalLayout) { - return
; - // if (!finalLayout) setTimeout(() => self.doRender(dashDoc, removeDoc, node, view, getPos), 0); - } else { - - this._reactionDisposer?.(); - this._reactionDisposer = reaction(() => - ({ - dim: [finalLayout[WidthSym](), finalLayout[HeightSym]()], - color: finalLayout.color - }), - ({ dim, color }) => { - spanStyle.width = outerStyle.width = Math.max(20, dim[0]) + "px"; - spanStyle.height = outerStyle.height = Math.max(20, dim[1]) + "px"; - outerStyle.border = "1px solid " + StrCast(finalLayout.color, (Cast(Doc.UserDoc().activeWorkspace, Doc, null).darkScheme ? "dimGray" : "lightGray")); - }, { fireImmediately: true }); - - if (node.attrs.width !== dashDoc._width + "px" || node.attrs.height !== dashDoc._height + "px") { - try { // bcz: an exception will be thrown if two aliases are open at the same time when a doc view comment is made - view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, { ...node.attrs, width: dashDoc._width + "px", height: dashDoc._height + "px" })); - } catch (e) { - console.log(e); - } - } - - - //const doReactRender = (finalLayout: Doc, resolvedDataDoc: Doc) => { - // ReactDOM.unmountComponentAtNode(this._dashSpan); - - return ( - -
- - -
-
- ); - - } - } - -} \ No newline at end of file diff --git a/src/client/util/DashFieldView.scss b/src/client/util/DashFieldView.scss deleted file mode 100644 index 35ff9c1e6..000000000 --- a/src/client/util/DashFieldView.scss +++ /dev/null @@ -1,36 +0,0 @@ -.dashFieldView { - position: relative; - display: inline-block; - - .dashFieldView-enumerables { - width: 10px; - height: 10px; - position: relative; - display: inline-block; - background: dimGray; - } - .dashFieldView-fieldCheck { - min-width: 12px; - position: relative; - display: inline-block; - background-color: rgba(155, 155, 155, 0.24); - } - .dashFieldView-labelSpan { - position: relative; - display: inline-block; - font-size: small; - } - .dashFieldView-fieldSpan { - min-width: 20px; - margin-left: 2px; - margin-right: 5px; - position: relative; - display: inline-block; - background-color: rgba(155, 155, 155, 0.24); - span { - min-width: 100%; - display: inline-block; - } - } -} - \ No newline at end of file diff --git a/src/client/util/DashFieldView.tsx b/src/client/util/DashFieldView.tsx deleted file mode 100644 index cf09e0132..000000000 --- a/src/client/util/DashFieldView.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { IReactionDisposer, observable, runInAction, computed, action } from "mobx"; -import { Doc, DocListCast, Field } from "../../new_fields/Doc"; -import { List } from "../../new_fields/List"; -import { listSpec } from "../../new_fields/Schema"; -import { SchemaHeaderField } from "../../new_fields/SchemaHeaderField"; -import { ComputedField } from "../../new_fields/ScriptField"; -import { Cast, StrCast } from "../../new_fields/Types"; -import { DocServer } from "../DocServer"; -import { CollectionViewType } from "../views/collections/CollectionView"; -import { FormattedTextBox } from "../views/nodes/FormattedTextBox"; -import React = require("react"); -import * as ReactDOM from 'react-dom'; -import "./DashFieldView.scss"; -import { observer } from "mobx-react"; - - -export class DashFieldView { - _fieldWrapper: HTMLDivElement; // container for label and value - - constructor(node: any, view: any, getPos: any, tbox: FormattedTextBox) { - this._fieldWrapper = document.createElement("div"); - this._fieldWrapper.style.width = node.attrs.width; - this._fieldWrapper.style.height = node.attrs.height; - this._fieldWrapper.style.fontWeight = "bold"; - this._fieldWrapper.style.position = "relative"; - this._fieldWrapper.style.display = "inline-block"; - this._fieldWrapper.onkeypress = function (e: any) { e.stopPropagation(); }; - this._fieldWrapper.onkeydown = function (e: any) { e.stopPropagation(); }; - this._fieldWrapper.onkeyup = function (e: any) { e.stopPropagation(); }; - this._fieldWrapper.onmousedown = function (e: any) { e.stopPropagation(); }; - - ReactDOM.render(, this._fieldWrapper); - (this as any).dom = this._fieldWrapper; - } - destroy() { - ReactDOM.unmountComponentAtNode(this._fieldWrapper); - } - selectNode() { } - -} -interface IDashFieldViewInternal { - fieldKey: string; - docid: string; - view: any; - getPos: any; - tbox: FormattedTextBox; - width: number; - height: number; -} - -@observer -export class DashFieldViewInternal extends React.Component { - _reactionDisposer: IReactionDisposer | undefined; - _textBoxDoc: Doc; - _fieldKey: string; - _fieldStringRef = React.createRef(); - @observable _showEnumerables: boolean = false; - @observable _dashDoc: Doc | undefined; - - constructor(props: IDashFieldViewInternal) { - super(props); - this._fieldKey = this.props.fieldKey; - this._textBoxDoc = this.props.tbox.props.Document; - - if (this.props.docid) { - DocServer.GetRefField(this.props.docid). - then(action(async dashDoc => dashDoc instanceof Doc && (this._dashDoc = dashDoc))); - } else { - this._dashDoc = this.props.tbox.props.DataDoc || this.props.tbox.dataDoc; - } - } - componentWillUnmount() { - this._reactionDisposer?.(); - } - - // set the display of the field's value (checkbox for booleans, span of text for strings) - @computed get fieldValueContent() { - if (this._dashDoc) { - const dashVal = this._dashDoc[this._fieldKey]; - const fval = StrCast(dashVal).startsWith(":=") || dashVal === "" ? Doc.Layout(this._textBoxDoc)[this._fieldKey] : dashVal; - const boolVal = Cast(fval, "boolean", null); - const strVal = Field.toString(fval as Field) || ""; - - // field value is a boolean, so use a checkbox or similar widget to display it - if (boolVal === true || boolVal === false) { - return this._dashDoc![this._fieldKey] = e.target.checked} - />; - } - else // field value is a string, so display it as an editable span - { - // bcz: this is unfortunate, but since this React component is nested within a non-React text box (prosemirror), we can't - // use React events. Essentially, React events occur after native events have been processed, so corresponding React events - // will never fire because Prosemirror has handled the native events. So we add listeners for native events here. - return { - r?.addEventListener("keydown", e => this.fieldSpanKeyDown(e, r)); - r?.addEventListener("blur", e => r && this.updateText(r.textContent!, false)); - r?.addEventListener("pointerdown", action((e) => this._showEnumerables = true)); - }}> - {strVal} - - } - } - } - - // we need to handle all key events on the input span or else they will propagate to prosemirror. - @action - fieldSpanKeyDown = (e: KeyboardEvent, span: HTMLSpanElement) => { - if (e.key === "Enter") { // handle the enter key by "submitting" the current text to Dash's database. - e.ctrlKey && Doc.addFieldEnumerations(this._textBoxDoc, this._fieldKey, [{ title: span.textContent! }]); - this.updateText(span.textContent!, true); - e.preventDefault();// prevent default to avoid a newline from being generated and wiping out this field view - } - if (e.key === "a" && (e.ctrlKey || e.metaKey)) { // handle ctrl-A to select all the text within the span - if (window.getSelection) { - const range = document.createRange(); - range.selectNodeContents(span); - window.getSelection()!.removeAllRanges(); - window.getSelection()!.addRange(range); - } - e.preventDefault(); //prevent default so that all the text in the prosemirror text box isn't selected - } - e.stopPropagation(); // we need to handle all events or else they will propagate to prosemirror. - } - - @action - updateText = (nodeText: string, forceMatch: boolean) => { - this._showEnumerables = false; - if (nodeText) { - const newText = nodeText.startsWith(":=") || nodeText.startsWith("=:=") ? ":=-computed-" : nodeText; - - // look for a document whose id === the fieldKey being displayed. If there's a match, then that document - // holds the different enumerated values for the field in the titles of its collected documents. - // if there's a partial match from the start of the input text, complete the text --- TODO: make this an auto suggest box and select from a drop down. - DocServer.GetRefField(this._fieldKey).then(options => { - let modText = ""; - (options instanceof Doc) && DocListCast(options.data).forEach(opt => (forceMatch ? StrCast(opt.title).startsWith(newText) : StrCast(opt.title) === newText) && (modText = StrCast(opt.title))); - if (modText) { - // elementfieldSpan.innerHTML = this._dashDoc![this._fieldKey as string] = modText; - Doc.addFieldEnumerations(this._textBoxDoc, this._fieldKey, []); - this._dashDoc![this._fieldKey] = modText; - } // if the text starts with a ':=' then treat it as an expression by making a computed field from its value storing it in the key - else if (nodeText.startsWith(":=")) { - this._dashDoc![this._fieldKey] = ComputedField.MakeFunction(nodeText.substring(2)); - } else if (nodeText.startsWith("=:=")) { - Doc.Layout(this._textBoxDoc)[this._fieldKey] = ComputedField.MakeFunction(nodeText.substring(3)); - } else { - this._dashDoc![this._fieldKey] = newText; - } - }); - } - } - - // display a collection of all the enumerable values for this field - onPointerDownEnumerables = async (e: any) => { - e.stopPropagation(); - const collview = await Doc.addFieldEnumerations(this._textBoxDoc, this._fieldKey, [{ title: this._fieldKey }]); - collview instanceof Doc && this.props.tbox.props.addDocTab(collview, "onRight"); - } - - - // clicking on the label creates a pivot view collection of all documents - // in the same collection. The pivot field is the fieldKey of this label - onPointerDownLabelSpan = (e: any) => { - e.stopPropagation(); - let container = this.props.tbox.props.ContainingCollectionView; - while (container?.props.Document.isTemplateForField || container?.props.Document.isTemplateDoc) { - container = container.props.ContainingCollectionView; - } - if (container) { - const alias = Doc.MakeAlias(container.props.Document); - alias.viewType = CollectionViewType.Time; - let list = Cast(alias.schemaColumns, listSpec(SchemaHeaderField)); - if (!list) { - alias.schemaColumns = list = new List(); - } - list.map(c => c.heading).indexOf(this._fieldKey) === -1 && list.push(new SchemaHeaderField(this._fieldKey, "#f1efeb")); - list.map(c => c.heading).indexOf("text") === -1 && list.push(new SchemaHeaderField("text", "#f1efeb")); - alias._pivotField = this._fieldKey; - this.props.tbox.props.addDocTab(alias, "onRight"); - } - } - - render() { - return
- - {this._fieldKey} - - -
- {this.fieldValueContent} -
- - {!this._showEnumerables ? (null) :
} - -
; - } -} \ No newline at end of file diff --git a/src/client/util/FootnoteView.tsx b/src/client/util/FootnoteView.tsx deleted file mode 100644 index ee21fb765..000000000 --- a/src/client/util/FootnoteView.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { EditorView } from "prosemirror-view"; -import { EditorState } from "prosemirror-state"; -import { keymap } from "prosemirror-keymap"; -import { baseKeymap, toggleMark } from "prosemirror-commands"; -import { schema } from "./schema_rts"; -import { redo, undo } from "prosemirror-history"; -import { StepMap } from "prosemirror-transform"; - -import React = require("react"); - -interface IFootnoteView { - innerView: any; - outerView: any; - node: any; - dom: any; - getPos: any; -} - -export class FootnoteView extends React.Component { - _innerView: any; - _node: any; - - constructor(props: IFootnoteView) { - super(props); - const node = this.props.node; - const outerView = this.props.outerView; - const _innerView = this.props.innerView; - const getPos = this.props.getPos; - } - - selectNode() { - const attrs = { ...this.props.node.attrs }; - attrs.visibility = true; - this.dom.classList.add("ProseMirror-selectednode"); - if (!this.props.innerView) this.open(); - } - - deselectNode() { - const attrs = { ...this.props.node.attrs }; - attrs.visibility = false; - this.dom.classList.remove("ProseMirror-selectednode"); - if (this.props.innerView) this.close(); - } - open() { - // Append a tooltip to the outer node - const tooltip = this.dom.appendChild(document.createElement("div")); - tooltip.className = "footnote-tooltip"; - // And put a sub-ProseMirror into that - this.props.innerView.defineProperty(new EditorView(tooltip, { - // You can use any node as an editor document - state: EditorState.create({ - doc: this.props.node, - plugins: [keymap(baseKeymap), - keymap({ - "Mod-z": () => undo(this.props.outerView.state, this.props.outerView.dispatch), - "Mod-y": () => redo(this.props.outerView.state, this.props.outerView.dispatch), - "Mod-b": toggleMark(schema.marks.strong) - }), - // new Plugin({ - // view(newView) { - // // TODO -- make this work with RichTextMenu - // // return FormattedTextBox.getToolTip(newView); - // } - // }) - ], - - }), - // This is the magic part - dispatchTransaction: this.dispatchInner.bind(this), - handleDOMEvents: { - pointerdown: ((view: any, e: PointerEvent) => { - // Kludge to prevent issues due to the fact that the whole - // footnote is node-selected (and thus DOM-selected) when - // the parent editor is focused. - e.stopPropagation(); - document.addEventListener("pointerup", this.ignore, true); - if (this.props.outerView.hasFocus()) this.props.innerView.focus(); - }) as any - } - })); - setTimeout(() => this.props.innerView && this.props.innerView.docView.setSelection(0, 0, this.props.innerView.root, true), 0); - } - - ignore = (e: PointerEvent) => { - e.stopPropagation(); - document.removeEventListener("pointerup", this.ignore, true); - } - - dispatchInner(tr: any) { - const { state, transactions } = this.props.innerView.state.applyTransaction(tr); - this.props.innerView.updateState(state); - - if (!tr.getMeta("fromOutside")) { - const outerTr = this.props.outerView.state.tr, offsetMap = StepMap.offset(this.props.getPos() + 1); - for (const transaction of transactions) { - const steps = transaction.steps; - for (const step of steps) { - outerTr.step(step.map(offsetMap)); - } - } - if (outerTr.docChanged) this.props.outerView.dispatch(outerTr); - } - } - update(node: any) { - if (!node.sameMarkup(this.props.node)) return false; - this._node = node; //not sure - if (this.props.innerView) { - const state = this.props.innerView.state; - const start = node.content.findDiffStart(state.doc.content); - if (start !== null) { - let { a: endA, b: endB } = node.content.findDiffEnd(state.doc.content); - const overlap = start - Math.min(endA, endB); - if (overlap > 0) { endA += overlap; endB += overlap; } - this.props.innerView.dispatch( - state.tr - .replace(start, endB, node.slice(start, endA)) - .setMeta("fromOutside", true)); - } - } - return true; - } - onPointerUp = (e: any) => { - this.toggle(e); - } - - toggle = (e: any) => { - e.preventDefault(); - if (this.props.innerView) this.close(); - else { - this.open(); - } - } - - close() { - this.props.innerView && this.props.innerView.destroy(); - this._innerView = null; - this.dom.textContent = ""; - } - - destroy() { - if (this.props.innerView) this.close(); - } - - stopEvent(event: any) { - return this.props.innerView && this.props.innerView.dom.contains(event.target); - } - - ignoreMutation() { return true; } - - - render() { - return ( -
-
- -
-
- ); - } -} diff --git a/src/client/util/ImageResizeView.tsx b/src/client/util/ImageResizeView.tsx deleted file mode 100644 index 4f66475fb..000000000 --- a/src/client/util/ImageResizeView.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { NodeSelection } from "prosemirror-state"; -import { Doc } from "../../new_fields/Doc"; -import { DocServer } from "../DocServer"; -import { DocumentManager } from "./DocumentManager"; -import React = require("react"); - -import { schema } from "./schema_rts"; - -interface IImageResizeView { - node: any; - view: any; - getPos: any; - addDocTab: any; -} - -export class ImageResizeView extends React.Component { - constructor(props: IImageResizeView) { - super(props); - } - - onClickImg = (e: any) => { - e.stopPropagation(); - e.preventDefault(); - if (this.props.view.state.selection.node && this.props.view.state.selection.node.type !== this.props.view.state.schema.nodes.image) { - this.props.view.dispatch(this.props.view.state.tr.setSelection(new NodeSelection(this.props.view.state.doc.resolve(this.props.view.state.selection.from - 2)))); - } - } - - onPointerDownImg = (e: any) => { - if (e.ctrlKey) { - e.preventDefault(); - e.stopPropagation(); - DocServer.GetRefField(this.props.node.attrs.docid).then(async linkDoc => - (linkDoc instanceof Doc) && - DocumentManager.Instance.FollowLink(linkDoc, this.props.view.state.schema.Document, - document => this.props.addDocTab(document, this.props.node.attrs.location ? this.props.node.attrs.location : "inTab"), false)); - } - } - - onPointerDownHandle = (e: any) => { - e.preventDefault(); - e.stopPropagation(); - const elementImage = document.getElementById("imageId") as HTMLElement; - const wid = Number(getComputedStyle(elementImage).width.replace(/px/, "")); - const hgt = Number(getComputedStyle(elementImage).height.replace(/px/, "")); - const startX = e.pageX; - const startWidth = parseFloat(this.props.node.attrs.width); - - const onpointermove = (e: any) => { - const elementOuter = document.getElementById("outerId") as HTMLElement; - - const currentX = e.pageX; - const diffInPx = currentX - startX; - elementOuter.style.width = `${startWidth + diffInPx}`; - elementOuter.style.height = `${(startWidth + diffInPx) * hgt / wid}`; - }; - - const onpointerup = () => { - document.removeEventListener("pointermove", onpointermove); - document.removeEventListener("pointerup", onpointerup); - const pos = this.props.view.state.selection.from; - const elementOuter = document.getElementById("outerId") as HTMLElement; - this.props.view.dispatch(this.props.view.state.tr.setNodeMarkup(this.props.getPos(), null, { ...this.props.node.attrs, width: elementOuter.style.width, height: elementOuter.style.height })); - this.props.view.dispatch(this.props.view.state.tr.setSelection(new NodeSelection(this.props.view.state.doc.resolve(pos)))); - }; - - document.addEventListener("pointermove", onpointermove); - document.addEventListener("pointerup", onpointerup); - } - - selectNode() { - const elementImage = document.getElementById("imageId") as HTMLElement; - const elementHandle = document.getElementById("handleId") as HTMLElement; - - elementImage.classList.add("ProseMirror-selectednode"); - elementHandle.style.display = ""; - } - - deselectNode() { - const elementImage = document.getElementById("imageId") as HTMLElement; - const elementHandle = document.getElementById("handleId") as HTMLElement; - - elementImage.classList.remove("ProseMirror-selectednode"); - elementHandle.style.display = "none"; - } - - - render() { - - const outerStyle = { - width: this.props.node.attrs.width, - height: this.props.node.attrs.height, - display: "inline-block", - overflow: "hidden", - float: this.props.node.attrs.float - }; - - const imageStyle = { - width: "100%", - }; - - const handleStyle = { - position: "absolute", - width: "20px", - heiht: "20px", - backgroundColor: "blue", - borderRadius: "15px", - display: "none", - bottom: "-10px", - right: "-10px" - - }; - - - - return ( -
- - - - - -
- ); - } -} \ No newline at end of file diff --git a/src/client/util/ParagraphNodeSpec.ts b/src/client/util/ParagraphNodeSpec.ts deleted file mode 100644 index 0a3b68217..000000000 --- a/src/client/util/ParagraphNodeSpec.ts +++ /dev/null @@ -1,143 +0,0 @@ -import clamp from './clamp'; -import convertToCSSPTValue from './convertToCSSPTValue'; -import toCSSLineSpacing from './toCSSLineSpacing'; -import { Node, DOMOutputSpec } from 'prosemirror-model'; - -//import type { NodeSpec } from './Types'; -type NodeSpec = { - attrs?: { [key: string]: any }, - content?: string, - draggable?: boolean, - group?: string, - inline?: boolean, - name?: string, - parseDOM?: Array, - toDOM?: (node: any) => DOMOutputSpec, -}; - -// This assumes that every 36pt maps to one indent level. -export const INDENT_MARGIN_PT_SIZE = 36; -export const MIN_INDENT_LEVEL = 0; -export const MAX_INDENT_LEVEL = 7; -export const ATTRIBUTE_INDENT = 'data-indent'; - -export const EMPTY_CSS_VALUE = new Set(['', '0%', '0pt', '0px']); - -const ALIGN_PATTERN = /(left|right|center|justify)/; - -// https://github.com/ProseMirror/prosemirror-schema-basic/blob/master/src/schema-basic.js -// :: NodeSpec A plain paragraph textblock. Represented in the DOM -// as a `

` element. -const ParagraphNodeSpec: NodeSpec = { - attrs: { - align: { default: null }, - color: { default: null }, - id: { default: null }, - indent: { default: null }, - inset: { default: null }, - lineSpacing: { default: null }, - // TODO: Add UI to let user edit / clear padding. - paddingBottom: { default: null }, - // TODO: Add UI to let user edit / clear padding. - paddingTop: { default: null }, - }, - content: 'inline*', - group: 'block', - parseDOM: [{ tag: 'p', getAttrs }], - toDOM, -}; - -function getAttrs(dom: HTMLElement): Object { - const { - lineHeight, - textAlign, - marginLeft, - paddingTop, - paddingBottom, - } = dom.style; - - let align = dom.getAttribute('align') || textAlign || ''; - align = ALIGN_PATTERN.test(align) ? align : ""; - - let indent = parseInt(dom.getAttribute(ATTRIBUTE_INDENT) || "", 10); - - if (!indent && marginLeft) { - indent = convertMarginLeftToIndentValue(marginLeft); - } - - indent = indent || MIN_INDENT_LEVEL; - - const lineSpacing = lineHeight ? toCSSLineSpacing(lineHeight) : null; - - const id = dom.getAttribute('id') || ''; - return { align, indent, lineSpacing, paddingTop, paddingBottom, id }; -} - -function toDOM(node: Node): DOMOutputSpec { - const { - align, - indent, - inset, - lineSpacing, - paddingTop, - paddingBottom, - id, - } = node.attrs; - const attrs: { [key: string]: any } | null = {}; - - let style = ''; - if (align && align !== 'left') { - style += `text-align: ${align};`; - } - - if (lineSpacing) { - const cssLineSpacing = toCSSLineSpacing(lineSpacing); - style += - `line-height: ${cssLineSpacing};` + - // This creates the local css variable `--czi-content-line-height` - // that its children may apply. - `--czi-content-line-height: ${cssLineSpacing}`; - } - - if (paddingTop && !EMPTY_CSS_VALUE.has(paddingTop)) { - style += `padding-top: ${paddingTop};`; - } - - if (paddingBottom && !EMPTY_CSS_VALUE.has(paddingBottom)) { - style += `padding-bottom: ${paddingBottom};`; - } - - if (indent) { - style += `text-indent: ${indent}; padding-left: ${indent < 0 ? -indent : undefined};`; - } - - if (inset) { - style += `margin-left: ${inset}; margin-right: ${inset};`; - } - - style && (attrs.style = style); - - if (indent) { - attrs[ATTRIBUTE_INDENT] = String(indent); - } - - if (id) { - attrs.id = id; - } - - return ['p', attrs, 0]; -} - -export const toParagraphDOM = toDOM; -export const getParagraphNodeAttrs = getAttrs; - -export function convertMarginLeftToIndentValue(marginLeft: string): number { - const ptValue = convertToCSSPTValue(marginLeft); - return clamp( - MIN_INDENT_LEVEL, - Math.floor(ptValue / INDENT_MARGIN_PT_SIZE), - MAX_INDENT_LEVEL - ); -} - -export default ParagraphNodeSpec; \ No newline at end of file diff --git a/src/client/util/ProsemirrorExampleTransfer.ts b/src/client/util/ProsemirrorExampleTransfer.ts deleted file mode 100644 index 356f20ce6..000000000 --- a/src/client/util/ProsemirrorExampleTransfer.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { chainCommands, exitCode, joinDown, joinUp, lift, selectParentNode, setBlockType, splitBlockKeepMarks, toggleMark, wrapIn } from "prosemirror-commands"; -import { redo, undo } from "prosemirror-history"; -import { undoInputRule } from "prosemirror-inputrules"; -import { Schema } from "prosemirror-model"; -import { liftListItem, sinkListItem } from "./prosemirrorPatches.js"; -import { splitListItem, wrapInList, } from "prosemirror-schema-list"; -import { EditorState, Transaction, TextSelection } from "prosemirror-state"; -import { SelectionManager } from "./SelectionManager"; -import { Docs } from "../documents/Documents"; -import { NumCast, BoolCast, Cast, StrCast } from "../../new_fields/Types"; -import { Doc } from "../../new_fields/Doc"; -import { FormattedTextBox } from "../views/nodes/FormattedTextBox"; -import { Id } from "../../new_fields/FieldSymbols"; - -const mac = typeof navigator !== "undefined" ? /Mac/.test(navigator.platform) : false; - -export type KeyMap = { [key: string]: any }; - -export let updateBullets = (tx2: Transaction, schema: Schema, mapStyle?: string) => { - let fontSize: number | undefined = undefined; - tx2.doc.descendants((node: any, offset: any, index: any) => { - if (node.type === schema.nodes.ordered_list || node.type === schema.nodes.list_item) { - const path = (tx2.doc.resolve(offset) as any).path; - let depth = Array.from(path).reduce((p: number, c: any) => p + (c.hasOwnProperty("type") && c.type === schema.nodes.ordered_list ? 1 : 0), 0); - if (node.type === schema.nodes.ordered_list) depth++; - fontSize = depth === 1 && node.attrs.setFontSize ? Number(node.attrs.setFontSize) : fontSize; - const fsize = fontSize && node.type === schema.nodes.ordered_list ? Math.max(6, fontSize - (depth - 1) * 4) : undefined; - tx2.setNodeMarkup(offset, node.type, { ...node.attrs, mapStyle: mapStyle ? mapStyle : node.attrs.mapStyle, bulletStyle: depth, inheritedFontSize: fsize }, node.marks); - } - }); - return tx2; -}; -export default function buildKeymap>(schema: S, props: any, mapKeys?: KeyMap): KeyMap { - const keys: { [key: string]: any } = {}; - - function bind(key: string, cmd: any) { - if (mapKeys) { - const 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); - - !mac && bind("Mod-y", redo); - - bind("Alt-ArrowUp", joinUp); - bind("Alt-ArrowDown", joinDown); - bind("Mod-BracketLeft", lift); - bind("Escape", (state: EditorState, dispatch: (tx: Transaction) => void) => { - dispatch(state.tr.setSelection(TextSelection.create(state.doc, state.selection.from, state.selection.from))); - (document.activeElement as any).blur?.(); - SelectionManager.DeselectAll(); - }); - - bind("Mod-b", toggleMark(schema.marks.strong)); - bind("Mod-B", toggleMark(schema.marks.strong)); - - bind("Mod-e", toggleMark(schema.marks.em)); - bind("Mod-E", toggleMark(schema.marks.em)); - - bind("Mod-u", toggleMark(schema.marks.underline)); - bind("Mod-U", toggleMark(schema.marks.underline)); - - bind("Mod-`", toggleMark(schema.marks.code)); - - bind("Ctrl-.", wrapInList(schema.nodes.bullet_list)); - - bind("Ctrl-n", wrapInList(schema.nodes.ordered_list)); - - bind("Ctrl->", wrapIn(schema.nodes.blockquote)); - - // bind("^", (state: EditorState, dispatch: (tx: Transaction) => void) => { - // let newNode = schema.nodes.footnote.create({}); - // if (dispatch && state.selection.from === state.selection.to) { - // let tr = state.tr; - // tr.replaceSelectionWith(newNode); // replace insertion with a footnote. - // dispatch(tr.setSelection(new NodeSelection( // select the footnote node to open its display - // tr.doc.resolve( // get the location of the footnote node by subtracting the nodesize of the footnote from the current insertion point anchor (which will be immediately after the footnote node) - // tr.selection.anchor - tr.selection.$anchor.nodeBefore!.nodeSize)))); - // return true; - // } - // return false; - // }); - - - const cmd = chainCommands(exitCode, (state, dispatch) => { - if (dispatch) { - dispatch(state.tr.replaceSelectionWith(schema.nodes.hard_break.create()).scrollIntoView()); - return true; - } - return false; - }); - bind("Mod-Enter", cmd); - bind("Shift-Enter", cmd); - mac && bind("Ctrl-Enter", cmd); - - - bind("Shift-Ctrl-0", setBlockType(schema.nodes.paragraph)); - - bind("Shift-Ctrl-\\", setBlockType(schema.nodes.code_block)); - - for (let i = 1; i <= 6; i++) { - bind("Shift-Ctrl-" + i, setBlockType(schema.nodes.heading, { level: i })); - } - - const hr = schema.nodes.horizontal_rule; - bind("Mod-_", (state: EditorState, dispatch: (tx: Transaction) => void) => { - dispatch(state.tr.replaceSelectionWith(hr.create()).scrollIntoView()); - return true; - }); - - bind("Tab", (state: EditorState, dispatch: (tx: Transaction) => void) => { - const ref = state.selection; - const range = ref.$from.blockRange(ref.$to); - const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()); - if (!sinkListItem(schema.nodes.list_item)(state, (tx2: Transaction) => { - const tx3 = updateBullets(tx2, schema); - marks && tx3.ensureMarks([...marks]); - marks && tx3.setStoredMarks([...marks]); - dispatch(tx3); - })) { // couldn't sink into an existing list, so wrap in a new one - const newstate = state.applyTransaction(state.tr.setSelection(TextSelection.create(state.doc, range!.start, range!.end))); - if (!wrapInList(schema.nodes.ordered_list)(newstate.state, (tx2: Transaction) => { - const tx3 = updateBullets(tx2, schema); - // when promoting to a list, assume list will format things so don't copy the stored marks. - marks && tx3.ensureMarks([...marks]); - marks && tx3.setStoredMarks([...marks]); - dispatch(tx3); - })) { - console.log("bullet promote fail"); - } - } - }); - - bind("Shift-Tab", (state: EditorState, dispatch: (tx: Transaction) => void) => { - const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()); - - if (!liftListItem(schema.nodes.list_item)(state.tr, (tx2: Transaction) => { - const tx3 = updateBullets(tx2, schema); - marks && tx3.ensureMarks([...marks]); - marks && tx3.setStoredMarks([...marks]); - dispatch(tx3); - })) { - console.log("bullet demote fail"); - } - }); - bind("Ctrl-Enter", (state: EditorState, dispatch: (tx: Transaction) => void) => { - const layoutDoc = props.Document; - const originalDoc = layoutDoc.rootDocument || layoutDoc; - if (originalDoc instanceof Doc) { - const layoutKey = StrCast(originalDoc.layoutKey); - const newDoc = Docs.Create.TextDocument("", { - layout: Cast(originalDoc.layout, Doc, null) || FormattedTextBox.DefaultLayout, - layoutKey, - _singleLine: BoolCast(originalDoc._singleLine), - x: NumCast(originalDoc.x), y: NumCast(originalDoc.y) + NumCast(originalDoc._height) + 10, _width: NumCast(layoutDoc._width), _height: NumCast(layoutDoc._height) - }); - if (layoutKey !== "layout" && originalDoc[layoutKey] instanceof Doc) { - newDoc[layoutKey] = originalDoc[layoutKey]; - } - FormattedTextBox.SelectOnLoad = newDoc[Id]; - props.addDocument(newDoc); - } - }); - - const splitMetadata = (marks: any, tx: Transaction) => { - marks && tx.ensureMarks(marks.filter((val: any) => val.type !== schema.marks.metadata && val.type !== schema.marks.metadataKey && val.type !== schema.marks.metadataVal)); - marks && tx.setStoredMarks(marks.filter((val: any) => val.type !== schema.marks.metadata && val.type !== schema.marks.metadataKey && val.type !== schema.marks.metadataVal)); - return tx; - }; - const addTextOnRight = (force: boolean) => { - const layoutDoc = props.Document; - const originalDoc = layoutDoc.rootDocument || layoutDoc; - if (force || props.Document._singleLine) { - const layoutKey = StrCast(originalDoc.layoutKey); - const newDoc = Docs.Create.TextDocument("", { - layout: Cast(originalDoc.layout, Doc, null) || FormattedTextBox.DefaultLayout, - layoutKey, - _singleLine: BoolCast(originalDoc._singleLine), - x: NumCast(originalDoc.x) + NumCast(originalDoc._width) + 10, y: NumCast(originalDoc.y), _width: NumCast(layoutDoc._width), _height: NumCast(layoutDoc._height) - }); - if (layoutKey !== "layout" && originalDoc[layoutKey] instanceof Doc) { - newDoc[layoutKey] = originalDoc[layoutKey]; - } - FormattedTextBox.SelectOnLoad = newDoc[Id]; - props.addDocument(newDoc); - return true; - } - return false; - }; - bind("Alt-Enter", (state: EditorState, dispatch: (tx: Transaction>) => void) => { - return addTextOnRight(true); - }); - bind("Enter", (state: EditorState, dispatch: (tx: Transaction>) => void) => { - if (addTextOnRight(false)) return true; - const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()); - if (!splitListItem(schema.nodes.list_item)(state, dispatch)) { - if (!splitBlockKeepMarks(state, (tx3: Transaction) => { - splitMetadata(marks, tx3); - if (!liftListItem(schema.nodes.list_item)(tx3, dispatch as ((tx: Transaction>) => void))) { - dispatch(tx3); - } - })) { - return false; - } - } - return true; - }); - bind("Space", (state: EditorState, dispatch: (tx: Transaction) => void) => { - const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()); - dispatch(splitMetadata(marks, state.tr)); - return false; - }); - bind(":", (state: EditorState, dispatch: (tx: Transaction) => void) => { - const range = state.selection.$from.blockRange(state.selection.$to, (node: any) => { - return !node.marks || !node.marks.find((m: any) => m.type === schema.marks.metadata); - }); - const path = (state.doc.resolve(state.selection.from - 1) as any).path; - const spaceSeparator = path[path.length - 3].childCount > 1 ? 0 : -1; - const anchor = range!.end - path[path.length - 3].lastChild.nodeSize + spaceSeparator; - if (anchor >= 0) { - const textsel = TextSelection.create(state.doc, anchor, range!.end); - const text = range ? state.doc.textBetween(textsel.from, textsel.to) : ""; - let whitespace = text.length - 1; - for (; whitespace >= 0 && text[whitespace] !== " "; whitespace--) { } - if (text.endsWith(":")) { - dispatch(state.tr.addMark(textsel.from + whitespace + 1, textsel.to, schema.marks.metadata.create() as any). - addMark(textsel.from + whitespace + 1, textsel.to - 2, schema.marks.metadataKey.create() as any)); - } - } - return false; - }); - - - return keys; -} diff --git a/src/client/util/RichTextMenu.scss b/src/client/util/RichTextMenu.scss deleted file mode 100644 index 43cc23ecd..000000000 --- a/src/client/util/RichTextMenu.scss +++ /dev/null @@ -1,121 +0,0 @@ -@import "../views/globalCssVariables"; - -.button-dropdown-wrapper { - position: relative; - - .dropdown-button { - width: 15px; - padding-left: 5px; - padding-right: 5px; - } - - .dropdown-button-combined { - width: 50px; - display: flex; - justify-content: space-between; - - svg:nth-child(2) { - margin-top: 2px; - } - } - - .dropdown { - position: absolute; - top: 35px; - left: 0; - background-color: #323232; - color: $light-color-secondary; - border: 1px solid #4d4d4d; - border-radius: 0 6px 6px 6px; - box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25); - min-width: 150px; - padding: 5px; - font-size: 12px; - z-index: 10001; - - button { - background-color: #323232; - border: 1px solid black; - border-radius: 1px; - padding: 6px; - margin: 5px 0; - font-size: 10px; - - &:hover { - background-color: black; - } - - &:last-child { - margin-bottom: 0; - } - } - } - - input { - color: black; - } -} - -.link-menu { - .divider { - background-color: white; - height: 1px; - width: 100%; - } -} - -.color-preview-button { - .color-preview { - width: 100%; - height: 3px; - margin-top: 3px; - } -} - -.color-wrapper { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - - button.color-button { - width: 20px; - height: 20px; - border-radius: 15px !important; - margin: 3px; - border: 2px solid transparent !important; - padding: 3px; - - &.active { - border: 2px solid white !important; - } - } -} - -select { - background-color: #323232; - color: white; - border: 1px solid black; - // border-top: none; - // border-bottom: none; - font-size: 12px; - height: 100%; - margin-right: 3px; - - &:focus, - &:hover { - background-color: black; - } - - &::-ms-expand { - color: white; - } -} - -.row-2 { - display: flex; - justify-content: space-between; - - >div { - display: flex; - } -} \ No newline at end of file diff --git a/src/client/util/RichTextMenu.tsx b/src/client/util/RichTextMenu.tsx deleted file mode 100644 index 140635a1d..000000000 --- a/src/client/util/RichTextMenu.tsx +++ /dev/null @@ -1,875 +0,0 @@ -import React = require("react"); -import AntimodeMenu from "../views/AntimodeMenu"; -import { observable, action, } from "mobx"; -import { observer } from "mobx-react"; -import { Mark, MarkType, Node as ProsNode, NodeType, ResolvedPos, Schema } from "prosemirror-model"; -import { schema } from "./schema_rts"; -import { EditorView } from "prosemirror-view"; -import { EditorState, NodeSelection, TextSelection } from "prosemirror-state"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { IconProp, library } from '@fortawesome/fontawesome-svg-core'; -import { faBold, faItalic, faChevronLeft, faUnderline, faStrikethrough, faSubscript, faSuperscript, faIndent, faEyeDropper, faCaretDown, faPalette, faHighlighter, faLink, faPaintRoller, faSleigh } from "@fortawesome/free-solid-svg-icons"; -import { updateBullets } from "./ProsemirrorExampleTransfer"; -import { FieldViewProps } from "../views/nodes/FieldView"; -import { Cast, StrCast } from "../../new_fields/Types"; -import { FormattedTextBoxProps } from "../views/nodes/FormattedTextBox"; -import { unimplementedFunction, Utils } from "../../Utils"; -import { wrapInList } from "prosemirror-schema-list"; -import { PastelSchemaPalette, DarkPastelSchemaPalette } from '../../new_fields/SchemaHeaderField'; -import "./RichTextMenu.scss"; -import { DocServer } from "../DocServer"; -import { Doc } from "../../new_fields/Doc"; -import { SelectionManager } from "./SelectionManager"; -import { LinkManager } from "./LinkManager"; -const { toggleMark, setBlockType } = require("prosemirror-commands"); - -library.add(faBold, faItalic, faChevronLeft, faUnderline, faStrikethrough, faSuperscript, faSubscript, faIndent, faEyeDropper, faCaretDown, faPalette, faHighlighter, faLink, faPaintRoller); - -@observer -export default class RichTextMenu extends AntimodeMenu { - static Instance: RichTextMenu; - public overMenu: boolean = false; // kind of hacky way to prevent selects not being selectable - - private view?: EditorView; - public editorProps: FieldViewProps & FormattedTextBoxProps | undefined; - - public _brushMap: Map> = new Map(); - private fontSizeOptions: { mark: Mark | null, title: string, label: string, command: any, hidden?: boolean, style?: {} }[]; - private fontFamilyOptions: { mark: Mark | null, title: string, label: string, command: any, hidden?: boolean, style?: {} }[]; - private listTypeOptions: { node: NodeType | any | null, title: string, label: string, command: any, style?: {} }[]; - private fontColors: (string | undefined)[]; - private highlightColors: (string | undefined)[]; - - @observable private collapsed: boolean = false; - @observable private boldActive: boolean = false; - @observable private italicsActive: boolean = false; - @observable private underlineActive: boolean = false; - @observable private strikethroughActive: boolean = false; - @observable private subscriptActive: boolean = false; - @observable private superscriptActive: boolean = false; - - @observable private activeFontSize: string = ""; - @observable private activeFontFamily: string = ""; - @observable private activeListType: string = ""; - - @observable private brushIsEmpty: boolean = true; - @observable private brushMarks: Set = new Set(); - @observable private showBrushDropdown: boolean = false; - - @observable private activeFontColor: string = "black"; - @observable private showColorDropdown: boolean = false; - - @observable private activeHighlightColor: string = "transparent"; - @observable private showHighlightDropdown: boolean = false; - - @observable private currentLink: string | undefined = ""; - @observable private showLinkDropdown: boolean = false; - - constructor(props: Readonly<{}>) { - super(props); - RichTextMenu.Instance = this; - this._canFade = false; - - this.fontSizeOptions = [ - { mark: schema.marks.pFontSize.create({ fontSize: 7 }), title: "Set font size", label: "7pt", command: this.changeFontSize }, - { mark: schema.marks.pFontSize.create({ fontSize: 8 }), title: "Set font size", label: "8pt", command: this.changeFontSize }, - { mark: schema.marks.pFontSize.create({ fontSize: 9 }), title: "Set font size", label: "9pt", command: this.changeFontSize }, - { mark: schema.marks.pFontSize.create({ fontSize: 10 }), title: "Set font size", label: "10pt", command: this.changeFontSize }, - { mark: schema.marks.pFontSize.create({ fontSize: 12 }), title: "Set font size", label: "12pt", command: this.changeFontSize }, - { mark: schema.marks.pFontSize.create({ fontSize: 14 }), title: "Set font size", label: "14pt", command: this.changeFontSize }, - { mark: schema.marks.pFontSize.create({ fontSize: 16 }), title: "Set font size", label: "16pt", command: this.changeFontSize }, - { mark: schema.marks.pFontSize.create({ fontSize: 18 }), title: "Set font size", label: "18pt", command: this.changeFontSize }, - { mark: schema.marks.pFontSize.create({ fontSize: 20 }), title: "Set font size", label: "20pt", command: this.changeFontSize }, - { mark: schema.marks.pFontSize.create({ fontSize: 24 }), title: "Set font size", label: "24pt", command: this.changeFontSize }, - { mark: schema.marks.pFontSize.create({ fontSize: 32 }), title: "Set font size", label: "32pt", command: this.changeFontSize }, - { mark: schema.marks.pFontSize.create({ fontSize: 48 }), title: "Set font size", label: "48pt", command: this.changeFontSize }, - { mark: schema.marks.pFontSize.create({ fontSize: 72 }), title: "Set font size", label: "72pt", command: this.changeFontSize }, - { mark: null, title: "", label: "various", command: unimplementedFunction, hidden: true }, - { mark: null, title: "", label: "13pt", command: unimplementedFunction, hidden: true }, // this is here because the default size is 13, but there is no actual 13pt option - ]; - - this.fontFamilyOptions = [ - { mark: schema.marks.pFontFamily.create({ family: "Times New Roman" }), title: "Set font family", label: "Times New Roman", command: this.changeFontFamily, style: { fontFamily: "Times New Roman" } }, - { mark: schema.marks.pFontFamily.create({ family: "Arial" }), title: "Set font family", label: "Arial", command: this.changeFontFamily, style: { fontFamily: "Arial" } }, - { mark: schema.marks.pFontFamily.create({ family: "Georgia" }), title: "Set font family", label: "Georgia", command: this.changeFontFamily, style: { fontFamily: "Georgia" } }, - { mark: schema.marks.pFontFamily.create({ family: "Comic Sans MS" }), title: "Set font family", label: "Comic Sans MS", command: this.changeFontFamily, style: { fontFamily: "Comic Sans MS" } }, - { mark: schema.marks.pFontFamily.create({ family: "Tahoma" }), title: "Set font family", label: "Tahoma", command: this.changeFontFamily, style: { fontFamily: "Tahoma" } }, - { mark: schema.marks.pFontFamily.create({ family: "Impact" }), title: "Set font family", label: "Impact", command: this.changeFontFamily, style: { fontFamily: "Impact" } }, - { mark: schema.marks.pFontFamily.create({ family: "Crimson Text" }), title: "Set font family", label: "Crimson Text", command: this.changeFontFamily, style: { fontFamily: "Crimson Text" } }, - { mark: null, title: "", label: "various", command: unimplementedFunction, hidden: true }, - // { mark: null, title: "", label: "default", command: unimplementedFunction, hidden: true }, - ]; - - this.listTypeOptions = [ - { node: schema.nodes.ordered_list.create({ mapStyle: "bullet" }), title: "Set list type", label: ":", command: this.changeListType }, - { node: schema.nodes.ordered_list.create({ mapStyle: "decimal" }), title: "Set list type", label: "1.1", command: this.changeListType }, - { node: schema.nodes.ordered_list.create({ mapStyle: "multi" }), title: "Set list type", label: "1.A", command: this.changeListType }, - { node: undefined, title: "Set list type", label: "Remove", command: this.changeListType }, - ]; - - this.fontColors = [ - DarkPastelSchemaPalette.get("pink2"), - DarkPastelSchemaPalette.get("purple4"), - DarkPastelSchemaPalette.get("bluegreen1"), - DarkPastelSchemaPalette.get("yellow4"), - DarkPastelSchemaPalette.get("red2"), - DarkPastelSchemaPalette.get("bluegreen7"), - DarkPastelSchemaPalette.get("bluegreen5"), - DarkPastelSchemaPalette.get("orange1"), - "#757472", - "#000" - ]; - - this.highlightColors = [ - PastelSchemaPalette.get("pink2"), - PastelSchemaPalette.get("purple4"), - PastelSchemaPalette.get("bluegreen1"), - PastelSchemaPalette.get("yellow4"), - PastelSchemaPalette.get("red2"), - PastelSchemaPalette.get("bluegreen7"), - PastelSchemaPalette.get("bluegreen5"), - PastelSchemaPalette.get("orange1"), - "white", - "transparent" - ]; - } - - @action - changeView(view: EditorView) { - this.view = view; - } - - update(view: EditorView, lastState: EditorState | undefined) { - this.updateFromDash(view, lastState, this.editorProps); - } - - - public MakeLinkToSelection = (linkDocId: string, title: string, location: string, targetDocId: string): string => { - if (this.view) { - const link = this.view.state.schema.marks.link.create({ href: Utils.prepend("/doc/" + linkDocId), title: title, location: location, linkId: linkDocId, targetId: targetDocId }); - this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link). - addMark(this.view.state.selection.from, this.view.state.selection.to, link)); - return this.view.state.selection.$from.nodeAfter?.text || ""; - } - return ""; - } - - @action - public async updateFromDash(view: EditorView, lastState: EditorState | undefined, props: any) { - if (!view) { - console.log("no editor? why?"); - return; - } - this.view = view; - const state = view.state; - props && (this.editorProps = props); - - // Don't do anything if the document/selection didn't change - if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) return; - - // update active marks - const activeMarks = this.getActiveMarksOnSelection(); - this.setActiveMarkButtons(activeMarks); - - // update active font family and size - const active = this.getActiveFontStylesOnSelection(); - const activeFamilies = active && active.get("families"); - const activeSizes = active && active.get("sizes"); - - this.activeFontFamily = !activeFamilies || activeFamilies.length === 0 ? "Arial" : activeFamilies.length === 1 ? String(activeFamilies[0]) : "various"; - this.activeFontSize = !activeSizes || activeSizes.length === 0 ? "13pt" : activeSizes.length === 1 ? String(activeSizes[0]) + "pt" : "various"; - - // update link in current selection - const targetTitle = await this.getTextLinkTargetTitle(); - this.setCurrentLink(targetTitle); - } - - setMark = (mark: Mark, state: EditorState, dispatch: any) => { - if (mark) { - const node = (state.selection as NodeSelection).node; - if (node?.type === schema.nodes.ordered_list) { - let attrs = node.attrs; - if (mark.type === schema.marks.pFontFamily) attrs = { ...attrs, setFontFamily: mark.attrs.family }; - if (mark.type === schema.marks.pFontSize) attrs = { ...attrs, setFontSize: mark.attrs.fontSize }; - if (mark.type === schema.marks.pFontColor) attrs = { ...attrs, setFontColor: mark.attrs.color }; - const tr = updateBullets(state.tr.setNodeMarkup(state.selection.from, node.type, attrs), state.schema); - dispatch(tr.setSelection(new NodeSelection(tr.doc.resolve(state.selection.from)))); - } else { - toggleMark(mark.type, mark.attrs)(state, (tx: any) => { - const { from, $from, to, empty } = tx.selection; - if (!tx.doc.rangeHasMark(from, to, mark.type)) { - toggleMark(mark.type, mark.attrs)({ tr: tx, doc: tx.doc, selection: tx.selection, storedMarks: tx.storedMarks }, dispatch); - } else dispatch(tx); - }); - } - } - } - - // finds font sizes and families in selection - getActiveFontStylesOnSelection() { - if (!this.view) return; - - const activeFamilies: string[] = []; - const activeSizes: string[] = []; - const state = this.view.state; - const pos = this.view.state.selection.$from; - const ref_node = this.reference_node(pos); - if (ref_node && ref_node !== this.view.state.doc && ref_node.isText) { - ref_node.marks.forEach(m => { - m.type === state.schema.marks.pFontFamily && activeFamilies.push(m.attrs.family); - m.type === state.schema.marks.pFontSize && activeSizes.push(String(m.attrs.fontSize) + "pt"); - }); - } - - const styles = new Map(); - styles.set("families", activeFamilies); - styles.set("sizes", activeSizes); - return styles; - } - - getMarksInSelection(state: EditorState) { - const found = new Set(); - const { from, to } = state.selection as TextSelection; - state.doc.nodesBetween(from, to, (node) => node.marks.forEach(m => found.add(m))); - return found; - } - - //finds all active marks on selection in given group - getActiveMarksOnSelection() { - if (!this.view) return; - - const markGroup = [schema.marks.strong, schema.marks.em, schema.marks.underline, schema.marks.strikethrough, schema.marks.superscript, schema.marks.subscript]; - if (this.view.state.storedMarks) return this.view.state.storedMarks.map(mark => mark.type); - //current selection - const { empty, ranges, $to } = this.view.state.selection as TextSelection; - const state = this.view.state; - let activeMarks: MarkType[] = []; - if (!empty) { - activeMarks = markGroup.filter(mark => { - const has = false; - for (let i = 0; !has && i < ranges.length; i++) { - return state.doc.rangeHasMark(ranges[i].$from.pos, ranges[i].$to.pos, mark); - } - return false; - }); - } - else { - const pos = this.view.state.selection.$from; - const ref_node: ProsNode | null = this.reference_node(pos); - if (ref_node !== null && ref_node !== this.view.state.doc) { - if (ref_node.isText) { - } - else { - return []; - } - activeMarks = markGroup.filter(mark_type => { - if (mark_type === state.schema.marks.pFontSize) { - return ref_node.marks.some(m => m.type.name === state.schema.marks.pFontSize.name); - } - const mark = state.schema.mark(mark_type); - return ref_node.marks.includes(mark); - }); - } - } - return activeMarks; - } - - destroy() { - this.fadeOut(true); - } - - @action - setActiveMarkButtons(activeMarks: MarkType[] | undefined) { - if (!activeMarks) return; - - this.boldActive = false; - this.italicsActive = false; - this.underlineActive = false; - this.strikethroughActive = false; - this.subscriptActive = false; - this.superscriptActive = false; - - activeMarks.forEach(mark => { - switch (mark.name) { - case "strong": this.boldActive = true; break; - case "em": this.italicsActive = true; break; - case "underline": this.underlineActive = true; break; - case "strikethrough": this.strikethroughActive = true; break; - case "subscript": this.subscriptActive = true; break; - case "superscript": this.superscriptActive = true; break; - } - }); - } - - createButton(faIcon: string, title: string, isActive: boolean = false, command?: any, onclick?: any) { - const self = this; - function onClick(e: React.PointerEvent) { - e.preventDefault(); - e.stopPropagation(); - self.view && self.view.focus(); - self.view && command && command(self.view.state, self.view.dispatch, self.view); - self.view && onclick && onclick(self.view.state, self.view.dispatch, self.view); - self.setActiveMarkButtons(self.getActiveMarksOnSelection()); - } - - return ( - - ); - } - - createMarksDropdown(activeOption: string, options: { mark: Mark | null, title: string, label: string, command: (mark: Mark, view: EditorView) => void, hidden?: boolean, style?: {} }[], key: string): JSX.Element { - const items = options.map(({ title, label, hidden, style }) => { - if (hidden) { - return label === activeOption ? - : - ; - } - return label === activeOption ? - : - ; - }); - - const self = this; - function onChange(e: React.ChangeEvent) { - e.stopPropagation(); - e.preventDefault(); - options.forEach(({ label, mark, command }) => { - if (e.target.value === label) { - self.view && mark && command(mark, self.view); - } - }); - } - return ; - } - - createNodesDropdown(activeOption: string, options: { node: NodeType | any | null, title: string, label: string, command: (node: NodeType | any) => void, hidden?: boolean, style?: {} }[], key: string): JSX.Element { - const items = options.map(({ title, label, hidden, style }) => { - if (hidden) { - return label === activeOption ? - : - ; - } - return label === activeOption ? - : - ; - }); - - const self = this; - function onChange(val: string) { - options.forEach(({ label, node, command }) => { - if (val === label) { - self.view && node && command(node); - } - }); - } - return ; - } - - changeFontSize = (mark: Mark, view: EditorView) => { - this.setMark(view.state.schema.marks.pFontSize.create({ fontSize: mark.attrs.fontSize }), view.state, view.dispatch); - } - - changeFontFamily = (mark: Mark, view: EditorView) => { - this.setMark(view.state.schema.marks.pFontFamily.create({ family: mark.attrs.family }), view.state, view.dispatch); - } - - // TODO: remove doesn't work - //remove all node type and apply the passed-in one to the selected text - changeListType = (nodeType: NodeType | undefined) => { - if (!this.view) return; - - if (nodeType === schema.nodes.bullet_list) { - wrapInList(nodeType)(this.view.state, this.view.dispatch); - } else { - const marks = this.view.state.storedMarks || (this.view.state.selection.$to.parentOffset && this.view.state.selection.$from.marks()); - if (!wrapInList(schema.nodes.ordered_list)(this.view.state, (tx2: any) => { - const tx3 = updateBullets(tx2, schema, nodeType && (nodeType as any).attrs.mapStyle); - marks && tx3.ensureMarks([...marks]); - marks && tx3.setStoredMarks([...marks]); - - this.view!.dispatch(tx2); - })) { - const tx2 = this.view.state.tr; - const tx3 = updateBullets(tx2, schema, nodeType && (nodeType as any).attrs.mapStyle); - marks && tx3.ensureMarks([...marks]); - marks && tx3.setStoredMarks([...marks]); - - this.view.dispatch(tx3); - } - } - } - - insertSummarizer(state: EditorState, dispatch: any) { - if (state.selection.empty) return false; - const mark = state.schema.marks.summarize.create(); - const tr = state.tr; - tr.addMark(state.selection.from, state.selection.to, mark); - const content = tr.selection.content(); - const newNode = state.schema.nodes.summary.create({ visibility: false, text: content, textslice: content.toJSON() }); - dispatch && dispatch(tr.replaceSelectionWith(newNode).removeMark(tr.selection.from - 1, tr.selection.from, mark)); - return true; - } - - @action toggleBrushDropdown() { this.showBrushDropdown = !this.showBrushDropdown; } - - // todo: add brushes to brushMap to save with a style name - onBrushNameKeyPress = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - RichTextMenu.Instance.brushMarks && RichTextMenu.Instance._brushMap.set(this._brushNameRef.current!.value, RichTextMenu.Instance.brushMarks); - this._brushNameRef.current!.style.background = "lightGray"; - } - } - _brushNameRef = React.createRef(); - - createBrushButton() { - const self = this; - function onBrushClick(e: React.PointerEvent) { - e.preventDefault(); - e.stopPropagation(); - self.view && self.view.focus(); - self.view && self.fillBrush(self.view.state, self.view.dispatch); - } - - let label = "Stored marks: "; - if (this.brushMarks && this.brushMarks.size > 0) { - this.brushMarks.forEach((mark: Mark) => { - const markType = mark.type; - label += markType.name; - label += ", "; - }); - label = label.substring(0, label.length - 2); - } else { - label = "No marks are currently stored"; - } - - const button = - ; - - const dropdownContent = -

-

{label}

- - -
; - - return ( - - ); - } - - @action - clearBrush() { - RichTextMenu.Instance.brushIsEmpty = true; - RichTextMenu.Instance.brushMarks = new Set(); - } - - @action - fillBrush(state: EditorState, dispatch: any) { - if (!this.view) return; - - if (this.brushIsEmpty) { - const selected_marks = this.getMarksInSelection(this.view.state); - if (selected_marks.size >= 0) { - this.brushMarks = selected_marks; - this.brushIsEmpty = !this.brushIsEmpty; - } - } - else { - const { from, to, $from } = this.view.state.selection; - if (!this.view.state.selection.empty && $from && $from.nodeAfter) { - if (this.brushMarks && to - from > 0) { - this.view.dispatch(this.view.state.tr.removeMark(from, to)); - Array.from(this.brushMarks).filter(m => m.type !== schema.marks.user_mark).forEach((mark: Mark) => { - this.setMark(mark, this.view!.state, this.view!.dispatch); - }); - } - } - else { - this.brushIsEmpty = !this.brushIsEmpty; - } - } - } - - @action toggleColorDropdown() { this.showColorDropdown = !this.showColorDropdown; } - @action setActiveColor(color: string) { this.activeFontColor = color; } - - createColorButton() { - const self = this; - function onColorClick(e: React.PointerEvent) { - e.preventDefault(); - e.stopPropagation(); - self.view && self.view.focus(); - self.view && self.insertColor(self.activeFontColor, self.view.state, self.view.dispatch); - } - function changeColor(e: React.PointerEvent, color: string) { - e.preventDefault(); - e.stopPropagation(); - self.view && self.view.focus(); - self.setActiveColor(color); - self.view && self.insertColor(self.activeFontColor, self.view.state, self.view.dispatch); - } - - const button = - ; - - const dropdownContent = -
-

Change font color:

-
- {this.fontColors.map(color => { - if (color) { - return this.activeFontColor === color ? - : - ; - } - })} -
-
; - - return ( - - ); - } - - public insertColor(color: String, state: EditorState, dispatch: any) { - const colorMark = state.schema.mark(state.schema.marks.pFontColor, { color: color }); - if (state.selection.empty) { - dispatch(state.tr.addStoredMark(colorMark)); - return false; - } - this.setMark(colorMark, state, dispatch); - } - - @action toggleHighlightDropdown() { this.showHighlightDropdown = !this.showHighlightDropdown; } - @action setActiveHighlight(color: string) { this.activeHighlightColor = color; } - - createHighlighterButton() { - const self = this; - function onHighlightClick(e: React.PointerEvent) { - e.preventDefault(); - e.stopPropagation(); - self.view && self.view.focus(); - self.view && self.insertHighlight(self.activeHighlightColor, self.view.state, self.view.dispatch); - } - function changeHighlight(e: React.PointerEvent, color: string) { - e.preventDefault(); - e.stopPropagation(); - self.view && self.view.focus(); - self.setActiveHighlight(color); - self.view && self.insertHighlight(self.activeHighlightColor, self.view.state, self.view.dispatch); - } - - const button = - ; - - const dropdownContent = -
-

Change highlight color:

-
- {this.highlightColors.map(color => { - if (color) { - return this.activeHighlightColor === color ? - : - ; - } - })} -
-
; - - return ( - - ); - } - - insertHighlight(color: String, state: EditorState, dispatch: any) { - if (state.selection.empty) return false; - toggleMark(state.schema.marks.marker, { highlight: color })(state, dispatch); - } - - @action toggleLinkDropdown() { this.showLinkDropdown = !this.showLinkDropdown; } - @action setCurrentLink(link: string) { this.currentLink = link; } - - createLinkButton() { - const self = this; - - function onLinkChange(e: React.ChangeEvent) { - self.setCurrentLink(e.target.value); - } - - const link = this.currentLink ? this.currentLink : ""; - - const button = ; - - const dropdownContent = -
-

Linked to:

- - -
- -
; - - return ( - - ); - } - - async getTextLinkTargetTitle() { - if (!this.view) return; - - const node = this.view.state.selection.$from.nodeAfter; - const link = node && node.marks.find(m => m.type.name === "link"); - if (link) { - const href = link.attrs.href; - if (href) { - if (href.indexOf(Utils.prepend("/doc/")) === 0) { - const linkclicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0]; - if (linkclicked) { - const linkDoc = await DocServer.GetRefField(linkclicked); - if (linkDoc instanceof Doc) { - const anchor1 = await Cast(linkDoc.anchor1, Doc); - const anchor2 = await Cast(linkDoc.anchor2, Doc); - const currentDoc = SelectionManager.SelectedDocuments().length && SelectionManager.SelectedDocuments()[0].props.Document; - if (currentDoc && anchor1 && anchor2) { - if (Doc.AreProtosEqual(currentDoc, anchor1)) { - return StrCast(anchor2.title); - } - if (Doc.AreProtosEqual(currentDoc, anchor2)) { - return StrCast(anchor1.title); - } - } - } - } - } else { - return href; - } - } else { - return link.attrs.title; - } - } - } - - // TODO: should check for valid URL - makeLinkToURL = (target: String, lcoation: string) => { - if (!this.view) return; - - let node = this.view.state.selection.$from.nodeAfter; - let link = this.view.state.schema.mark(this.view.state.schema.marks.link, { href: target, location: location }); - this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link)); - this.view.dispatch(this.view.state.tr.addMark(this.view.state.selection.from, this.view.state.selection.to, link)); - node = this.view.state.selection.$from.nodeAfter; - link = node && node.marks.find(m => m.type.name === "link"); - } - - deleteLink = () => { - if (!this.view) return; - - const node = this.view.state.selection.$from.nodeAfter; - const link = node && node.marks.find(m => m.type === this.view!.state.schema.marks.link); - const href = link!.attrs.href; - if (href) { - if (href.indexOf(Utils.prepend("/doc/")) === 0) { - const linkclicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0]; - if (linkclicked) { - DocServer.GetRefField(linkclicked).then(async linkDoc => { - if (linkDoc instanceof Doc) { - LinkManager.Instance.deleteLink(linkDoc); - this.view!.dispatch(this.view!.state.tr.removeMark(this.view!.state.selection.from, this.view!.state.selection.to, this.view!.state.schema.marks.link)); - } - }); - } - } else { - if (node) { - const { tr, schema, selection } = this.view.state; - const extension = this.linkExtend(selection.$anchor, href); - this.view.dispatch(tr.removeMark(extension.from, extension.to, schema.marks.link)); - } - } - } - } - - linkExtend($start: ResolvedPos, href: string) { - const mark = this.view!.state.schema.marks.link; - - let startIndex = $start.index(); - let endIndex = $start.indexAfter(); - - while (startIndex > 0 && $start.parent.child(startIndex - 1).marks.filter(m => m.type === mark && m.attrs.href === href).length) startIndex--; - while (endIndex < $start.parent.childCount && $start.parent.child(endIndex).marks.filter(m => m.type === mark && m.attrs.href === href).length) endIndex++; - - let startPos = $start.start(); - let endPos = startPos; - for (let i = 0; i < endIndex; i++) { - const size = $start.parent.child(i).nodeSize; - if (i < startIndex) startPos += size; - endPos += size; - } - return { from: startPos, to: endPos }; - } - - reference_node(pos: ResolvedPos): ProsNode | null { - if (!this.view) return null; - - let ref_node: ProsNode = this.view.state.doc; - if (pos.nodeBefore !== null && pos.nodeBefore !== undefined) { - ref_node = pos.nodeBefore; - } - else if (pos.nodeAfter !== null && pos.nodeAfter !== undefined) { - ref_node = pos.nodeAfter; - } - else if (pos.pos > 0) { - let skip = false; - for (let i: number = pos.pos - 1; i > 0; i--) { - this.view.state.doc.nodesBetween(i, pos.pos, (node: ProsNode) => { - if (node.isLeaf && !skip) { - ref_node = node; - skip = true; - } - - }); - } - } - if (!ref_node.isLeaf && ref_node.childCount > 0) { - ref_node = ref_node.child(0); - } - return ref_node; - } - - @action onPointerEnter(e: React.PointerEvent) { RichTextMenu.Instance.overMenu = true; } - @action onPointerLeave(e: React.PointerEvent) { RichTextMenu.Instance.overMenu = false; } - - @action - toggleMenuPin = (e: React.MouseEvent) => { - this.Pinned = !this.Pinned; - if (!this.Pinned) { - this.fadeOut(true); - } - } - - @action - protected toggleCollapse = (e: React.MouseEvent) => { - this.collapsed = !this.collapsed; - setTimeout(() => { - const x = Math.min(this._left, window.innerWidth - RichTextMenu.Instance.width); - RichTextMenu.Instance.jumpTo(x, this._top); - }, 0); - } - - render() { - - const row1 =
{[ - this.createButton("bold", "Bold", this.boldActive, toggleMark(schema.marks.strong)), - this.createButton("italic", "Italic", this.italicsActive, toggleMark(schema.marks.em)), - this.createButton("underline", "Underline", this.underlineActive, toggleMark(schema.marks.underline)), - this.createButton("strikethrough", "Strikethrough", this.strikethroughActive, toggleMark(schema.marks.strikethrough)), - this.createButton("superscript", "Superscript", this.superscriptActive, toggleMark(schema.marks.superscript)), - this.createButton("subscript", "Subscript", this.subscriptActive, toggleMark(schema.marks.subscript)), - this.createColorButton(), - this.createHighlighterButton(), - this.createLinkButton(), - this.createBrushButton(), - this.createButton("indent", "Summarize", undefined, this.insertSummarizer), - ]}
; - - const row2 =
-
- {[this.createMarksDropdown(this.activeFontSize, this.fontSizeOptions, "font size"), - this.createMarksDropdown(this.activeFontFamily, this.fontFamilyOptions, "font family"), - this.createNodesDropdown(this.activeListType, this.listTypeOptions, "nodes")]} -
-
-
- -
- - {this.getDragger()} -
-
; - - return ( -
- {this.getElementWithRows([row1, row2], 2, false)} -
- ); - } -} - -interface ButtonDropdownProps { - view?: EditorView; - button: JSX.Element; - dropdownContent: JSX.Element; - openDropdownOnButton?: boolean; -} - -@observer -class ButtonDropdown extends React.Component { - - @observable private showDropdown: boolean = false; - private ref: HTMLDivElement | null = null; - - componentDidMount() { - document.addEventListener("pointerdown", this.onBlur); - } - - componentWillUnmount() { - document.removeEventListener("pointerdown", this.onBlur); - } - - @action - setShowDropdown(show: boolean) { - this.showDropdown = show; - } - @action - toggleDropdown() { - this.showDropdown = !this.showDropdown; - } - - onDropdownClick = (e: React.PointerEvent) => { - e.preventDefault(); - e.stopPropagation(); - this.props.view && this.props.view.focus(); - this.toggleDropdown(); - } - - onBlur = (e: PointerEvent) => { - setTimeout(() => { - if (this.ref !== null && !this.ref.contains(e.target as Node)) { - this.setShowDropdown(false); - } - }, 0); - } - - render() { - return ( -
this.ref = node}> - {this.props.openDropdownOnButton ? - : - <> - {this.props.button} - - } - - {this.showDropdown ? this.props.dropdownContent : (null)} -
- ); - } -} \ No newline at end of file diff --git a/src/client/util/RichTextRules.ts b/src/client/util/RichTextRules.ts index 63a3815ea..7ce6bbf85 100644 --- a/src/client/util/RichTextRules.ts +++ b/src/client/util/RichTextRules.ts @@ -7,9 +7,9 @@ import { Cast, NumCast } from "../../new_fields/Types"; import { returnFalse, Utils } from "../../Utils"; import { DocServer } from "../DocServer"; import { Docs, DocUtils } from "../documents/Documents"; -import { FormattedTextBox } from "../views/nodes/FormattedTextBox"; +import { FormattedTextBox } from "../views/nodes/formattedText/FormattedTextBox"; import { wrappingInputRule } from "./prosemirrorPatches"; -import RichTextMenu from "./RichTextMenu"; +import RichTextMenu from "../views/nodes/formattedText/RichTextMenu"; import { schema } from "./schema_rts"; export class RichTextRules { diff --git a/src/client/util/RichTextSchema.tsx b/src/client/util/RichTextSchema.tsx deleted file mode 100644 index 3e45d5de5..000000000 --- a/src/client/util/RichTextSchema.tsx +++ /dev/null @@ -1,718 +0,0 @@ -import { IReactionDisposer, observable, reaction, runInAction } from "mobx"; -import { baseKeymap, toggleMark } from "prosemirror-commands"; -import { redo, undo } from "prosemirror-history"; -import { keymap } from "prosemirror-keymap"; -import { DOMOutputSpecArray, Fragment, MarkSpec, Node, NodeSpec, Schema, Slice } from "prosemirror-model"; -import { bulletList, listItem, orderedList } from 'prosemirror-schema-list'; -import { EditorState, NodeSelection, Plugin, TextSelection } from "prosemirror-state"; -import { StepMap } from "prosemirror-transform"; -import { EditorView } from "prosemirror-view"; -import * as ReactDOM from 'react-dom'; -import { Doc, DocListCast, Field, HeightSym, WidthSym } from "../../new_fields/Doc"; -import { Id } from "../../new_fields/FieldSymbols"; -import { List } from "../../new_fields/List"; -import { ObjectField } from "../../new_fields/ObjectField"; -import { listSpec } from "../../new_fields/Schema"; -import { SchemaHeaderField } from "../../new_fields/SchemaHeaderField"; -import { ComputedField } from "../../new_fields/ScriptField"; -import { BoolCast, Cast, NumCast, StrCast, FieldValue } from "../../new_fields/Types"; -import { emptyFunction, returnEmptyString, returnFalse, returnOne, Utils, returnZero } from "../../Utils"; -import { DocServer } from "../DocServer"; -import { Docs } from "../documents/Documents"; -import { CollectionViewType } from "../views/collections/CollectionView"; -import { DocumentView } from "../views/nodes/DocumentView"; -import { FormattedTextBox } from "../views/nodes/FormattedTextBox"; -import { DocumentManager } from "./DocumentManager"; -import { Transform } from "./Transform"; -import React = require("react"); - -import { schema } from "./schema_rts"; - -export class OrderedListView { - update(node: any) { - return false; // if attr's of an ordered_list (e.g., bulletStyle) change, return false forces the dom node to be recreated which is necessary for the bullet labels to update - } -} - -export class ImageResizeView { - _handle: HTMLElement; - _img: HTMLElement; - _outer: HTMLElement; - constructor(node: any, view: any, getPos: any, addDocTab: any) { - //moved - 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.height = node.attrs.height; - this._outer.style.display = "inline-block"; - this._outer.style.overflow = "hidden"; - (this._outer.style as any).float = node.attrs.float; - //moved - 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"; - const self = this; - //moved - this._img.onclick = function (e: any) { - e.stopPropagation(); - e.preventDefault(); - if (view.state.selection.node && view.state.selection.node.type !== view.state.schema.nodes.image) { - view.dispatch(view.state.tr.setSelection(new NodeSelection(view.state.doc.resolve(view.state.selection.from - 2)))); - } - }; - //moved - this._img.onpointerdown = function (e: any) { - if (e.ctrlKey) { - e.preventDefault(); - e.stopPropagation(); - DocServer.GetRefField(node.attrs.docid).then(async linkDoc => - (linkDoc instanceof Doc) && - DocumentManager.Instance.FollowLink(linkDoc, view.state.schema.Document, - document => addDocTab(document, node.attrs.location ? node.attrs.location : "inTab"), false)); - } - }; - //moved - this._handle.onpointerdown = function (e: any) { - e.preventDefault(); - e.stopPropagation(); - const wid = Number(getComputedStyle(self._img).width.replace(/px/, "")); - const hgt = Number(getComputedStyle(self._img).height.replace(/px/, "")); - 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}`; - self._outer.style.height = `${(startWidth + diffInPx) * hgt / wid}`; - }; - - const onpointerup = () => { - document.removeEventListener("pointermove", onpointermove); - document.removeEventListener("pointerup", onpointerup); - const pos = view.state.selection.from; - view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, { ...node.attrs, width: self._outer.style.width, height: self._outer.style.height })); - view.dispatch(view.state.tr.setSelection(new NodeSelection(view.state.doc.resolve(pos)))); - }; - - document.addEventListener("pointermove", onpointermove); - document.addEventListener("pointerup", onpointerup); - }; - //Moved - this._outer.appendChild(this._img); - this._outer.appendChild(this._handle); - (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"; - } -} - -export class DashDocCommentView { - _collapsed: HTMLElement; - _view: any; - constructor(node: any, view: any, getPos: any) { - //moved - this._collapsed = document.createElement("span"); - this._collapsed.className = "formattedTextBox-inlineComment"; - this._collapsed.id = "DashDocCommentView-" + node.attrs.docid; - this._view = view; - //moved - const targetNode = () => { // search forward in the prosemirror doc for the attached dashDocNode that is the target of the comment anchor - for (let i = getPos() + 1; i < view.state.doc.content.size; i++) { - const m = view.state.doc.nodeAt(i); - if (m && m.type === view.state.schema.nodes.dashDoc && m.attrs.docid === node.attrs.docid) { - return { node: m, pos: i, hidden: m.attrs.hidden } as { node: any, pos: number, hidden: boolean }; - } - } - const dashDoc = view.state.schema.nodes.dashDoc.create({ width: 75, height: 35, title: "dashDoc", docid: node.attrs.docid, float: "right" }); - view.dispatch(view.state.tr.insert(getPos() + 1, dashDoc)); - setTimeout(() => { try { view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.tr.doc, getPos() + 2))); } catch (e) { } }, 0); - return undefined; - }; - //moved - this._collapsed.onpointerdown = (e: any) => { - e.stopPropagation(); - }; - //moved - this._collapsed.onpointerup = (e: any) => { - const target = targetNode(); - if (target) { - const expand = target.hidden; - const tr = view.state.tr.setNodeMarkup(target.pos, undefined, { ...target.node.attrs, hidden: target.node.attrs.hidden ? false : true }); - view.dispatch(tr.setSelection(TextSelection.create(tr.doc, getPos() + (expand ? 2 : 1)))); // update the attrs - setTimeout(() => { - expand && DocServer.GetRefField(node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc)); - try { view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.tr.doc, getPos() + (expand ? 2 : 1)))); } catch (e) { } - }, 0); - } - e.stopPropagation(); - }; - //moved - this._collapsed.onpointerenter = (e: any) => { - DocServer.GetRefField(node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc, false)); - e.preventDefault(); - e.stopPropagation(); - }; - //moved - this._collapsed.onpointerleave = (e: any) => { - DocServer.GetRefField(node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowUnhighlight()); - e.preventDefault(); - e.stopPropagation(); - }; - - (this as any).dom = this._collapsed; - } - //moved - selectNode() { } -} - -export class DashDocView { - _dashSpan: HTMLDivElement; - _outer: HTMLElement; - _dashDoc: Doc | undefined; - _reactionDisposer: IReactionDisposer | undefined; - _renderDisposer: IReactionDisposer | undefined; - _textBox: FormattedTextBox; - - getDocTransform = () => { - const { scale, translateX, translateY } = Utils.GetScreenTransform(this._outer); - return new Transform(-translateX, -translateY, 1).scale(1 / this.contentScaling() / scale); - } - contentScaling = () => NumCast(this._dashDoc!._nativeWidth) > 0 ? this._dashDoc![WidthSym]() / NumCast(this._dashDoc!._nativeWidth) : 1; - - outerFocus = (target: Doc) => this._textBox.props.focus(this._textBox.props.Document); // ideally, this would scroll to show the focus target - - constructor(node: any, view: any, getPos: any, tbox: FormattedTextBox) { - this._textBox = tbox; - this._dashSpan = document.createElement("div"); - this._outer = document.createElement("span"); - this._outer.style.position = "relative"; - this._outer.style.textIndent = "0"; - this._outer.style.border = "1px solid " + StrCast(tbox.layoutDoc.color, (Cast(Doc.UserDoc().activeWorkspace, Doc, null).darkScheme ? "dimGray" : "lightGray")); - this._outer.style.width = node.attrs.width; - this._outer.style.height = node.attrs.height; - this._outer.style.display = node.attrs.hidden ? "none" : "inline-block"; - // this._outer.style.overflow = "hidden"; // bcz: not sure if this is needed. if it's used, then the doc doesn't highlight when you hover over a docComment - (this._outer.style as any).float = node.attrs.float; - - this._dashSpan.style.width = node.attrs.width; - this._dashSpan.style.height = node.attrs.height; - this._dashSpan.style.position = "absolute"; - this._dashSpan.style.display = "inline-block"; - this._dashSpan.onpointerleave = () => { - const ele = document.getElementById("DashDocCommentView-" + node.attrs.docid); - if (ele) { - (ele as HTMLDivElement).style.backgroundColor = ""; - } - }; - this._dashSpan.onpointerenter = () => { - const ele = document.getElementById("DashDocCommentView-" + node.attrs.docid); - if (ele) { - (ele as HTMLDivElement).style.backgroundColor = "orange"; - } - }; - const removeDoc = () => { - const pos = getPos(); - const ns = new NodeSelection(view.state.doc.resolve(pos)); - view.dispatch(view.state.tr.setSelection(ns).deleteSelection()); - return true; - }; - const alias = node.attrs.alias; - - const docid = node.attrs.docid || tbox.props.Document[Id];// tbox.props.DataDoc?.[Id] || tbox.dataDoc?.[Id]; - DocServer.GetRefField(docid + alias).then(async dashDoc => { - if (!(dashDoc instanceof Doc)) { - alias && DocServer.GetRefField(docid).then(async dashDocBase => { - if (dashDocBase instanceof Doc) { - const aliasedDoc = Doc.MakeAlias(dashDocBase, docid + alias); - aliasedDoc.layoutKey = "layout"; - node.attrs.fieldKey && Doc.makeCustomViewClicked(aliasedDoc, Docs.Create.StackingDocument, node.attrs.fieldKey, undefined); - self.doRender(aliasedDoc, removeDoc, node, view, getPos); - } - }); - } else { - self.doRender(dashDoc, removeDoc, node, view, getPos); - } - }); - const self = this; - this._dashSpan.onkeydown = function (e: any) { - e.stopPropagation(); - if (e.key === "Tab" || e.key === "Enter") { - e.preventDefault(); - } - }; - this._dashSpan.onkeypress = function (e: any) { e.stopPropagation(); }; - this._dashSpan.onwheel = function (e: any) { e.preventDefault(); }; - this._dashSpan.onkeyup = function (e: any) { e.stopPropagation(); }; - this._outer.appendChild(this._dashSpan); - (this as any).dom = this._outer; - } - - doRender(dashDoc: Doc, removeDoc: any, node: any, view: any, getPos: any) { - this._dashDoc = dashDoc; - const self = this; - const dashLayoutDoc = Doc.Layout(dashDoc); - const finalLayout = node.attrs.docid ? dashDoc : Doc.expandTemplateLayout(dashLayoutDoc, dashDoc, node.attrs.fieldKey); - - if (!finalLayout) setTimeout(() => self.doRender(dashDoc, removeDoc, node, view, getPos), 0); - else { - this._reactionDisposer?.(); - this._reactionDisposer = reaction(() => ({ dim: [finalLayout[WidthSym](), finalLayout[HeightSym]()], color: finalLayout.color }), ({ dim, color }) => { - this._dashSpan.style.width = this._outer.style.width = Math.max(20, dim[0]) + "px"; - this._dashSpan.style.height = this._outer.style.height = Math.max(20, dim[1]) + "px"; - this._outer.style.border = "1px solid " + StrCast(finalLayout.color, (Cast(Doc.UserDoc().activeWorkspace, Doc, null).darkScheme ? "dimGray" : "lightGray")); - }, { fireImmediately: true }); - const doReactRender = (finalLayout: Doc, resolvedDataDoc: Doc) => { - ReactDOM.unmountComponentAtNode(this._dashSpan); - - ReactDOM.render(, this._dashSpan); - if (node.attrs.width !== dashDoc._width + "px" || node.attrs.height !== dashDoc._height + "px") { - try { // bcz: an exception will be thrown if two aliases are open at the same time when a doc view comment is made - view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, { ...node.attrs, width: dashDoc._width + "px", height: dashDoc._height + "px" })); - } catch (e) { - console.log(e); - } - } - }; - this._renderDisposer?.(); - this._renderDisposer = reaction(() => { - // if (!Doc.AreProtosEqual(finalLayout, dashDoc)) { - // finalLayout.rootDocument = dashDoc.aliasOf; // bcz: check on this ... why is it here? - // } - const layoutKey = StrCast(finalLayout.layoutKey); - const finalKey = layoutKey && StrCast(finalLayout[layoutKey]).split("'")?.[1]; - if (finalLayout !== dashDoc && finalKey) { - const finalLayoutField = finalLayout[finalKey]; - if (finalLayoutField instanceof ObjectField) { - finalLayout[finalKey + "-textTemplate"] = ComputedField.MakeFunction(`copyField(this.${finalKey})`, { this: Doc.name }); - } - } - return { finalLayout, resolvedDataDoc: Cast(finalLayout.resolvedDataDoc, Doc, null) }; - }, - (res) => doReactRender(res.finalLayout, res.resolvedDataDoc), - { fireImmediately: true }); - } - } - destroy() { - ReactDOM.unmountComponentAtNode(this._dashSpan); - this._reactionDisposer?.(); - } -} - -export class DashFieldView { - _fieldWrapper: HTMLDivElement; // container for label and value - _labelSpan: HTMLSpanElement; // field label - _fieldSpan: HTMLSpanElement; // field value - _fieldCheck: HTMLInputElement; - _enumerables: HTMLDivElement; // field value - _reactionDisposer: IReactionDisposer | undefined; - _textBoxDoc: Doc; - @observable _dashDoc: Doc | undefined; - _fieldKey: string; - _options: Doc[] = []; - - constructor(node: any, view: any, getPos: any, tbox: FormattedTextBox) { - this._fieldKey = node.attrs.fieldKey; - this._textBoxDoc = tbox.props.Document; - this._fieldWrapper = document.createElement("p"); - this._fieldWrapper.style.width = node.attrs.width; - this._fieldWrapper.style.height = node.attrs.height; - this._fieldWrapper.style.fontWeight = "bold"; - this._fieldWrapper.style.position = "relative"; - this._fieldWrapper.style.display = "inline-block"; - - const self = this; - - this._enumerables = document.createElement("div"); - this._enumerables.style.width = "10px"; - this._enumerables.style.height = "10px"; - this._enumerables.style.position = "relative"; - this._enumerables.style.display = "none"; - - //Moved - this._enumerables.onpointerdown = async (e) => { - e.stopPropagation(); - const collview = await Doc.addFieldEnumerations(self._textBoxDoc, self._fieldKey, [{ title: self._fieldSpan.innerText }]); - collview instanceof Doc && tbox.props.addDocTab(collview, "onRight"); - }; - //Moved - const updateText = (forceMatch: boolean) => { - self._enumerables.style.display = "none"; - const newText = self._fieldSpan.innerText.startsWith(":=") || self._fieldSpan.innerText.startsWith("=:=") ? ":=-computed-" : self._fieldSpan.innerText; - - // look for a document whose id === the fieldKey being displayed. If there's a match, then that document - // holds the different enumerated values for the field in the titles of its collected documents. - // if there's a partial match from the start of the input text, complete the text --- TODO: make this an auto suggest box and select from a drop down. - DocServer.GetRefField(self._fieldKey).then(options => { - let modText = ""; - (options instanceof Doc) && DocListCast(options.data).forEach(opt => (forceMatch ? StrCast(opt.title).startsWith(newText) : StrCast(opt.title) === newText) && (modText = StrCast(opt.title))); - if (modText) { - self._fieldSpan.innerHTML = self._dashDoc![self._fieldKey] = modText; - Doc.addFieldEnumerations(self._textBoxDoc, self._fieldKey, []); - } // if the text starts with a ':=' then treat it as an expression by making a computed field from its value storing it in the key - else if (self._fieldSpan.innerText.startsWith(":=")) { - self._dashDoc![self._fieldKey] = ComputedField.MakeFunction(self._fieldSpan.innerText.substring(2)); - } else if (self._fieldSpan.innerText.startsWith("=:=")) { - Doc.Layout(tbox.props.Document)[self._fieldKey] = ComputedField.MakeFunction(self._fieldSpan.innerText.substring(3)); - } else { - self._dashDoc![self._fieldKey] = newText; - } - }); - }; - - //Moved - this._fieldCheck = document.createElement("input"); - this._fieldCheck.id = Utils.GenerateGuid(); - this._fieldCheck.type = "checkbox"; - this._fieldCheck.style.position = "relative"; - this._fieldCheck.style.display = "none"; - this._fieldCheck.style.minWidth = "12px"; - this._fieldCheck.style.backgroundColor = "rgba(155, 155, 155, 0.24)"; - this._fieldCheck.onchange = function (e: any) { - self._dashDoc![self._fieldKey] = e.target.checked; - }; - - this._fieldSpan = document.createElement("span"); - this._fieldSpan.id = Utils.GenerateGuid(); - this._fieldSpan.contentEditable = "true"; - this._fieldSpan.style.position = "relative"; - this._fieldSpan.style.display = "none"; - this._fieldSpan.style.minWidth = "12px"; - this._fieldSpan.style.fontSize = "large"; - this._fieldSpan.onkeypress = function (e: any) { e.stopPropagation(); }; - this._fieldSpan.onkeyup = function (e: any) { e.stopPropagation(); }; - this._fieldSpan.onmousedown = function (e: any) { e.stopPropagation(); self._enumerables.style.display = "inline-block"; }; - this._fieldSpan.onblur = function (e: any) { updateText(false); }; - - // MOVED - const setDashDoc = (doc: Doc) => { - self._dashDoc = doc; - if (self._options?.length && !self._dashDoc[self._fieldKey]) { - self._dashDoc[self._fieldKey] = StrCast(self._options[0].title); - } - this._labelSpan.innerHTML = `${self._fieldKey}: `; - const fieldVal = Cast(this._dashDoc?.[self._fieldKey], "boolean", null); - this._fieldCheck.style.display = (fieldVal === true || fieldVal === false) ? "inline-block" : "none"; - this._fieldSpan.style.display = !(fieldVal === true || fieldVal === false) ? StrCast(this._dashDoc?.[self._fieldKey]) ? "" : "inline-block" : "none"; - }; - - //Moved - this._fieldSpan.onkeydown = function (e: any) { - e.stopPropagation(); - if ((e.key === "a" && e.ctrlKey) || (e.key === "a" && e.metaKey)) { - if (window.getSelection) { - const range = document.createRange(); - range.selectNodeContents(self._fieldSpan); - window.getSelection()!.removeAllRanges(); - window.getSelection()!.addRange(range); - } - e.preventDefault(); - } - if (e.key === "Enter") { - e.preventDefault(); - e.ctrlKey && Doc.addFieldEnumerations(self._textBoxDoc, self._fieldKey, [{ title: self._fieldSpan.innerText }]); - updateText(true); - } - }; - - this._labelSpan = document.createElement("span"); - this._labelSpan.style.position = "relative"; - this._labelSpan.style.fontSize = "small"; - this._labelSpan.title = "click to see related tags"; - this._labelSpan.style.fontSize = "x-small"; - this._labelSpan.onpointerdown = function (e: any) { - e.stopPropagation(); - let container = tbox.props.ContainingCollectionView; - while (container?.props.Document.isTemplateForField || container?.props.Document.isTemplateDoc) { - container = container.props.ContainingCollectionView; - } - if (container) { - const alias = Doc.MakeAlias(container.props.Document); - alias.viewType = CollectionViewType.Time; - let list = Cast(alias.schemaColumns, listSpec(SchemaHeaderField)); - if (!list) { - alias.schemaColumns = list = new List(); - } - list.map(c => c.heading).indexOf(self._fieldKey) === -1 && list.push(new SchemaHeaderField(self._fieldKey, "#f1efeb")); - list.map(c => c.heading).indexOf("text") === -1 && list.push(new SchemaHeaderField("text", "#f1efeb")); - alias._pivotField = self._fieldKey; - tbox.props.addDocTab(alias, "onRight"); - } - }; - this._labelSpan.innerHTML = `${self._fieldKey}: `; - //MOVED - if (node.attrs.docid) { - DocServer.GetRefField(node.attrs.docid). - then(async dashDoc => dashDoc instanceof Doc && runInAction(() => setDashDoc(dashDoc))); - } else { - setDashDoc(tbox.props.DataDoc || tbox.dataDoc); - } - - //Moved - this._reactionDisposer?.(); - this._reactionDisposer = reaction(() => { // this reaction will update the displayed text whenever the document's fieldKey's value changes - const dashVal = this._dashDoc?.[self._fieldKey]; - return StrCast(dashVal).startsWith(":=") || dashVal === "" ? Doc.Layout(tbox.props.Document)[self._fieldKey] : dashVal; - }, fval => { - const boolVal = Cast(fval, "boolean", null); - if (boolVal === true || boolVal === false) { - this._fieldCheck.checked = boolVal; - } else { - this._fieldSpan.innerHTML = Field.toString(fval as Field) || ""; - } - this._fieldCheck.style.display = (boolVal === true || boolVal === false) ? "inline-block" : "none"; - this._fieldSpan.style.display = !(fval === true || fval === false) ? (StrCast(fval) ? "" : "inline-block") : "none"; - }, { fireImmediately: true }); - - //MOVED IN ORDER - this._fieldWrapper.appendChild(this._labelSpan); - this._fieldWrapper.appendChild(this._fieldCheck); - this._fieldWrapper.appendChild(this._fieldSpan); - this._fieldWrapper.appendChild(this._enumerables); - (this as any).dom = this._fieldWrapper; - //updateText(false); - } - //MOVED - destroy() { - this._reactionDisposer?.(); - } - //moved - selectNode() { } -} - -export class FootnoteView { - innerView: any; - outerView: any; - node: any; - dom: any; - getPos: any; - - constructor(node: any, view: any, getPos: any) { - // We'll need these later - this.node = node; - this.outerView = view; - this.getPos = getPos; - - // The node's representation in the editor (empty, for now) - this.dom = document.createElement("footnote"); - this.dom.addEventListener("pointerup", this.toggle, true); - // These are used when the footnote is selected - this.innerView = null; - } - selectNode() { - const attrs = { ...this.node.attrs }; - attrs.visibility = true; - this.dom.classList.add("ProseMirror-selectednode"); - if (!this.innerView) this.open(); - } - - deselectNode() { - const attrs = { ...this.node.attrs }; - attrs.visibility = false; - this.dom.classList.remove("ProseMirror-selectednode"); - if (this.innerView) this.close(); - } - open() { - // Append a tooltip to the outer node - const tooltip = this.dom.appendChild(document.createElement("div")); - tooltip.className = "footnote-tooltip"; - // And put a sub-ProseMirror into that - this.innerView = new EditorView(tooltip, { - // You can use any node as an editor document - state: EditorState.create({ - doc: this.node, - plugins: [keymap(baseKeymap), - keymap({ - "Mod-z": () => undo(this.outerView.state, this.outerView.dispatch), - "Mod-y": () => redo(this.outerView.state, this.outerView.dispatch), - "Mod-b": toggleMark(schema.marks.strong) - }), - // new Plugin({ - // view(newView) { - // // TODO -- make this work with RichTextMenu - // // return FormattedTextBox.getToolTip(newView); - // } - // }) - ], - - }), - // This is the magic part - dispatchTransaction: this.dispatchInner.bind(this), - handleDOMEvents: { - pointerdown: ((view: any, e: PointerEvent) => { - // Kludge to prevent issues due to the fact that the whole - // footnote is node-selected (and thus DOM-selected) when - // the parent editor is focused. - e.stopPropagation(); - document.addEventListener("pointerup", this.ignore, true); - if (this.outerView.hasFocus()) this.innerView.focus(); - }) as any - } - - }); - setTimeout(() => this.innerView && this.innerView.docView.setSelection(0, 0, this.innerView.root, true), 0); - } - - ignore = (e: PointerEvent) => { - e.stopPropagation(); - document.removeEventListener("pointerup", this.ignore, true); - } - - toggle = () => { - if (this.innerView) this.close(); - else { - this.open(); - } - } - close() { - this.innerView && this.innerView.destroy(); - this.innerView = null; - this.dom.textContent = ""; - } - - dispatchInner(tr: any) { - const { state, transactions } = this.innerView.state.applyTransaction(tr); - this.innerView.updateState(state); - - if (!tr.getMeta("fromOutside")) { - const outerTr = this.outerView.state.tr, offsetMap = StepMap.offset(this.getPos() + 1); - for (const transaction of transactions) { - const steps = transaction.steps; - for (const step of steps) { - outerTr.step(step.map(offsetMap)); - } - } - if (outerTr.docChanged) this.outerView.dispatch(outerTr); - } - } - update(node: any) { - if (!node.sameMarkup(this.node)) return false; - this.node = node; - if (this.innerView) { - const state = this.innerView.state; - const start = node.content.findDiffStart(state.doc.content); - if (start !== null) { - let { a: endA, b: endB } = node.content.findDiffEnd(state.doc.content); - const overlap = start - Math.min(endA, endB); - if (overlap > 0) { endA += overlap; endB += overlap; } - this.innerView.dispatch( - state.tr - .replace(start, endB, node.slice(start, endA)) - .setMeta("fromOutside", true)); - } - } - return true; - } - - destroy() { - if (this.innerView) this.close(); - } - - stopEvent(event: any) { - return this.innerView && this.innerView.dom.contains(event.target); - } - - ignoreMutation() { return true; } -} - -export class SummaryView { - _collapsed: HTMLElement; - _view: any; - constructor(node: any, view: any, getPos: any) { - this._collapsed = document.createElement("span"); - this._collapsed.className = this.className(node.attrs.visibility); - this._view = view; - const js = node.toJSON; - node.toJSON = function () { - return js.apply(this, arguments); - }; - - this._collapsed.onpointerdown = (e: any) => { - const visible = !node.attrs.visibility; - const attrs = { ...node.attrs, visibility: visible }; - let textSelection = TextSelection.create(view.state.doc, getPos() + 1); - if (!visible) { // update summarized text and save in attrs - textSelection = this.updateSummarizedText(getPos() + 1); - attrs.text = textSelection.content(); - attrs.textslice = attrs.text.toJSON(); - } - view.dispatch(view.state.tr. - setSelection(textSelection). // select the current summarized text (or where it will be if its collapsed) - replaceSelection(!visible ? new Slice(Fragment.fromArray([]), 0, 0) : node.attrs.text). // collapse/expand it - setNodeMarkup(getPos(), undefined, attrs)); // update the attrs - e.preventDefault(); - e.stopPropagation(); - this._collapsed.className = this.className(visible); - }; - (this as any).dom = this._collapsed; - } - selectNode() { } - - deselectNode() { } - - className = (visible: boolean) => "formattedTextBox-summarizer" + (visible ? "" : "-collapsed"); - - updateSummarizedText(start?: any) { - const mtype = this._view.state.schema.marks.summarize; - const mtypeInc = this._view.state.schema.marks.summarizeInclusive; - let endPos = start; - - const visited = new Set(); - for (let i: number = start + 1; i < this._view.state.doc.nodeSize - 1; i++) { - let skip = false; - this._view.state.doc.nodesBetween(start, i, (node: Node, pos: number, parent: Node, index: number) => { - if (node.isLeaf && !visited.has(node) && !skip) { - if (node.marks.find((m: any) => m.type === mtype || m.type === mtypeInc)) { - visited.add(node); - endPos = i + node.nodeSize - 1; - } - else skip = true; - } - }); - } - return TextSelection.create(this._view.state.doc, start, endPos); - } -} \ No newline at end of file diff --git a/src/client/util/SummaryView.tsx b/src/client/util/SummaryView.tsx deleted file mode 100644 index 89908d8ee..000000000 --- a/src/client/util/SummaryView.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { TextSelection } from "prosemirror-state"; -import { Fragment, Node, Slice } from "prosemirror-model"; - -import React = require("react"); - -interface ISummaryView { - node: any; - view: any; - getPos: any; - self: any; -} -export class SummaryView extends React.Component { - - onPointerDown = (e: any) => { - const visible = !this.props.node.attrs.visibility; - const attrs = { ...this.props.node.attrs, visibility: visible }; - let textSelection = TextSelection.create(this.props.view.state.doc, this.props.getPos() + 1); - if (!visible) { // update summarized text and save in attrs - textSelection = this.updateSummarizedText(this.props.getPos() + 1); - attrs.text = textSelection.content(); - attrs.textslice = attrs.text.toJSON(); - } - this.props.view.dispatch(this.props.view.state.tr. - setSelection(textSelection). // select the current summarized text (or where it will be if its collapsed) - replaceSelection(!visible ? new Slice(Fragment.fromArray([]), 0, 0) : this.props.node.attrs.text). // collapse/expand it - setNodeMarkup(this.props.getPos(), undefined, attrs)); // update the attrs - e.preventDefault(); - e.stopPropagation(); - const _collapsed = document.getElementById('collapse') as HTMLElement; - _collapsed.className = this.className(visible); - } - - updateSummarizedText(start?: any) { - const mtype = this.props.view.state.schema.marks.summarize; - const mtypeInc = this.props.view.state.schema.marks.summarizeInclusive; - let endPos = start; - - const visited = new Set(); - for (let i: number = start + 1; i < this.props.view.state.doc.nodeSize - 1; i++) { - let skip = false; - this.props.view.state.doc.nodesBetween(start, i, (node: Node, pos: number, parent: Node, index: number) => { - if (this.props.node.isLeaf && !visited.has(node) && !skip) { - if (this.props.node.marks.find((m: any) => m.type === mtype || m.type === mtypeInc)) { - visited.add(node); - endPos = i + this.props.node.nodeSize - 1; - } - else skip = true; - } - }); - } - return TextSelection.create(this.props.view.state.doc, start, endPos); - } - - className = (visible: boolean) => "formattedTextBox-summarizer" + (visible ? "" : "-collapsed"); - - selectNode() { } - - deselectNode() { } - - render() { - const _view = this.props.node.view; - const js = this.props.node.toJSon; - - this.props.node.toJSON = function () { - return js.apply(this, arguments); - }; - - const spanCollapsedClassName = this.className(this.props.node.attrs.visibility); - - return ( - - - - ); - - } -} \ No newline at end of file diff --git a/src/client/util/TooltipTextMenu.scss b/src/client/util/TooltipTextMenu.scss deleted file mode 100644 index e2149e9c1..000000000 --- a/src/client/util/TooltipTextMenu.scss +++ /dev/null @@ -1,372 +0,0 @@ -@import "../views/globalCssVariables"; -.ProseMirror-menu-dropdown-wrap { - display: inline-block; - position: relative; -} - -.ProseMirror-menu-dropdown { - vertical-align: 1px; - cursor: pointer; - position: relative; - padding: 0 15px 0 4px; - background: white; - border-radius: 2px; - text-align: left; - font-size: 12px; - white-space: nowrap; - margin-right: 4px; - - &: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-submenu-wrap { - position: relative; - margin-right: -4px; -} - -.ProseMirror-menu-dropdown-menu, -.ProseMirror-menu-submenu { - font-size: 12px; - background: white; - border: 1px solid rgb(223, 223, 223); - min-width: 40px; - z-index: 50000; - position: absolute; - box-sizing: content-box; - - .ProseMirror-menu-dropdown-item { - cursor: pointer; - z-index: 100000; - text-align: left; - padding: 3px; - - &:hover { - background-color: $light-color-secondary; - } - } -} - - -.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-icon { - display: inline-block; - // line-height: .8; - // vertical-align: -2px; /* Compensate for padding */ - // padding: 2px 8px; - cursor: pointer; - - &.ProseMirror-menu-disabled { - cursor: default; - } - - svg { - fill:white; - height: 1em; - } - - span { - vertical-align: text-top; - } - } - -.wrapper { - position: absolute; - pointer-events: all; - display: flex; - align-items: center; - transform: translateY(-85px); - - height: 35px; - background: #323232; - border-radius: 6px; - box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25); - -} - -.tooltipMenu, .basic-tools { - z-index: 20000; - pointer-events: all; - padding: 3px; - padding-bottom: 5px; - display: flex; - align-items: center; - - .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; - } -} - -.menuicon { - width: 25px; - height: 25px; - cursor: pointer; - text-align: center; - line-height: 25px; - margin: 0 2px; - border-radius: 3px; - - &:hover { - background-color: black; - - #link-drag { - background-color: black; - } - } - - &> * { - margin-top: 50%; - margin-left: 50%; - transform: translate(-50%, -50%); - } - - svg { - fill: white; - width: 18px; - height: 18px; - } -} - -.menuicon-active { - width: 25px; - height: 25px; - cursor: pointer; - text-align: center; - line-height: 25px; - margin: 0 2px; - border-radius: 3px; - - &:hover { - background-color: black; - } - - &> * { - margin-top: 50%; - margin-left: 50%; - transform: translate(-50%, -50%); - } - - svg { - fill: greenyellow; - width: 18px; - height: 18px; - } -} - -#colorPicker { - position: relative; - - svg { - width: 18px; - height: 18px; - // margin-top: 11px; - } - - .buttonColor { - position: absolute; - top: 24px; - left: 1px; - width: 24px; - height: 4px; - margin-top: 0; - } -} - -#link-drag { - background-color: #323232; -} - -.underline svg { - margin-top: 13px; -} - - .font-size-indicator { - font-size: 12px; - padding-right: 0px; - } - .summarize{ - color: white; - height: 20px; - text-align: center; - } - - -.brush{ - display: inline-block; - width: 1em; - height: 1em; - stroke-width: 0; - stroke: currentColor; - fill: currentColor; - margin-right: 15px; -} - -.brush-active{ - display: inline-block; - width: 1em; - height: 1em; - stroke-width: 3; - fill: greenyellow; - margin-right: 15px; -} - -.dragger-wrapper { - color: #eee; - height: 22px; - padding: 0 5px; - box-sizing: content-box; - cursor: grab; - - .dragger { - width: 18px; - height: 100%; - display: flex; - justify-content: space-evenly; - } - - .dragger-line { - width: 2px; - height: 100%; - background-color: black; - } -} - -.button-dropdown-wrapper { - display: flex; - align-content: center; - - &:hover { - background-color: black; - } -} - -.buttonSettings-dropdown { - - &.ProseMirror-menu-dropdown { - width: 10px; - height: 25px; - margin: 0; - padding: 0 2px; - background-color: #323232; - text-align: center; - - &:after { - border-top: 4px solid white; - right: 2px; - } - - &:hover { - background-color: black; - } - } - - &.ProseMirror-menu-dropdown-menu { - min-width: 150px; - left: -27px; - top: 31px; - background-color: #323232; - border: 1px solid #4d4d4d; - color: $light-color-secondary; - // border: none; - // border: 1px solid $light-color-secondary; - border-radius: 0 6px 6px 6px; - padding: 3px; - box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25); - - .ProseMirror-menu-dropdown-item{ - cursor: default; - - &:last-child { - border-bottom: none; - } - - &:hover { - background-color: #323232; - } - - .button-setting, .button-setting-disabled { - padding: 2px; - border-radius: 2px; - } - - .button-setting:hover { - cursor: pointer; - background-color: black; - } - - .separated-button { - border-top: 1px solid $light-color-secondary; - padding-top: 6px; - } - - input { - color: black; - border: none; - border-radius: 1px; - padding: 3px; - } - - button { - padding: 6px; - background-color: #323232; - border: 1px solid black; - border-radius: 1px; - - &:hover { - background-color: black; - } - } - } - - - } -} - -.colorPicker-wrapper { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - margin-top: 3px; - margin-left: -3px; - width: calc(100% + 6px); -} - -button.colorPicker { - width: 20px; - height: 20px; - border-radius: 15px !important; - margin: 3px; - border: none !important; - - &.active { - border: 2px solid white !important; - } -} diff --git a/src/client/util/marks_rts.ts b/src/client/util/marks_rts.ts deleted file mode 100644 index 75d7109e4..000000000 --- a/src/client/util/marks_rts.ts +++ /dev/null @@ -1,296 +0,0 @@ -import React = require("react"); -import { DOMOutputSpecArray, Fragment, MarkSpec, Node, NodeSpec, Schema, Slice } from "prosemirror-model"; -import { Doc } from "../../new_fields/Doc"; - - -const emDOM: DOMOutputSpecArray = ["em", 0]; -const strongDOM: DOMOutputSpecArray = ["strong", 0]; -const codeDOM: DOMOutputSpecArray = ["code", 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 `` - // element. - link: { - attrs: { - href: {}, - targetId: { default: "" }, - linkId: { default: "" }, - showPreview: { default: true }, - location: { default: null }, - title: { default: null }, - docref: { default: false } // flags whether the linked text comes from a document within Dash. If so, an attribution label is appended after the text - }, - inclusive: false, - parseDOM: [{ - tag: "a[href]", getAttrs(dom: any) { - return { href: dom.getAttribute("href"), location: dom.getAttribute("location"), title: dom.getAttribute("title"), targetId: dom.getAttribute("id") }; - } - }], - toDOM(node: any) { - return node.attrs.docref && node.attrs.title ? - ["div", ["span", `"`], ["span", 0], ["span", `"`], ["br"], ["a", { ...node.attrs, class: "prosemirror-attribution", title: `${node.attrs.title}` }, node.attrs.title], ["br"]] : - ["a", { ...node.attrs, id: node.attrs.linkId + node.attrs.targetId, title: `${node.attrs.title}` }, 0]; - } - }, - - - // :: MarkSpec Coloring on text. Has `color` attribute that defined the color of the marked text. - pFontColor: { - attrs: { - color: { default: "#000" } - }, - inclusive: true, - parseDOM: [{ - tag: "span", getAttrs(dom: any) { - return { color: dom.getAttribute("color") }; - } - }], - toDOM(node: any) { - return node.attrs.color ? ['span', { style: 'color:' + node.attrs.color }] : ['span', 0]; - } - }, - - marker: { - attrs: { - highlight: { default: "transparent" } - }, - inclusive: true, - parseDOM: [{ - tag: "span", getAttrs(dom: any) { - return { highlight: dom.getAttribute("backgroundColor") }; - } - }], - toDOM(node: any) { - return node.attrs.highlight ? ['span', { style: 'background-color:' + node.attrs.highlight }] : ['span', { style: 'background-color: transparent' }]; - } - }, - - // :: MarkSpec An emphasis mark. Rendered as an `` element. - // Has parse rules that also match `` and `font-style: italic`. - em: { - parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style: italic" }], - toDOM() { return emDOM; } - }, - - // :: MarkSpec A strong mark. Rendered as ``, parse rules - // also match `` and `font-weight: bold`. - strong: { - parseDOM: [{ tag: "strong" }, - { tag: "b" }, - { style: "font-weight" }], - toDOM() { return strongDOM; } - }, - - 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'] - }, - - mbulletType: { - attrs: { - bulletType: { default: "decimal" } - }, - toDOM(node: any) { - return ['span', { - style: `background: ${node.attrs.bulletType === "decimal" ? "yellow" : node.attrs.bulletType === "upper-alpha" ? "blue" : "green"}` - }]; - } - }, - - metadata: { - toDOM() { - return ['span', { style: 'font-size:75%; background:rgba(100, 100, 100, 0.2); ' }]; - } - }, - metadataKey: { - toDOM() { - return ['span', { style: 'font-style:italic; ' }]; - } - }, - metadataVal: { - toDOM() { - return ['span']; - } - }, - - summarizeInclusive: { - parseDOM: [ - { - tag: "span", - getAttrs: (p: any) => { - if (typeof (p) !== "string") { - const style = getComputedStyle(p); - if (style.textDecoration === "underline") return null; - if (p.parentElement.outerHTML.indexOf("text-decoration: underline") !== -1 && - p.parentElement.outerHTML.indexOf("text-decoration-style: solid") !== -1) { - return null; - } - } - return false; - } - }, - ], - inclusive: true, - toDOM() { - return ['span', { - style: 'text-decoration: underline; text-decoration-style: solid; text-decoration-color: rgba(204, 206, 210, 0.92)' - }]; - } - }, - - summarize: { - inclusive: false, - parseDOM: [ - { - tag: "span", - getAttrs: (p: any) => { - if (typeof (p) !== "string") { - const style = getComputedStyle(p); - if (style.textDecoration === "underline") return null; - if (p.parentElement.outerHTML.indexOf("text-decoration: underline") !== -1 && - p.parentElement.outerHTML.indexOf("text-decoration-style: dotted") !== -1) { - return null; - } - } - return false; - } - }, - ], - toDOM() { - return ['span', { - style: 'text-decoration: underline; text-decoration-style: dotted; text-decoration-color: rgba(204, 206, 210, 0.92)' - }]; - } - }, - - underline: { - parseDOM: [ - { - tag: "span", - getAttrs: (p: any) => { - if (typeof (p) !== "string") { - const style = getComputedStyle(p); - if (style.textDecoration === "underline" || p.parentElement.outerHTML.indexOf("text-decoration-style:line") !== -1) { - return null; - } - } - return false; - } - } - // { style: "text-decoration=underline" } - ], - toDOM: () => ['span', { - style: 'text-decoration:underline;text-decoration-style:line' - }] - }, - - search_highlight: { - attrs: { - selected: { default: false } - }, - parseDOM: [{ style: 'background: yellow' }], - toDOM(node: any) { - return ['span', { - style: `background: ${node.attrs.selected ? "orange" : "yellow"}` - }]; - } - }, - - // the id of the user who entered the text - user_mark: { - attrs: { - userid: { default: "" }, - modified: { default: "when?" }, // 1 second intervals since 1970 - }, - group: "inline", - toDOM(node: any) { - const uid = node.attrs.userid.replace(".", "").replace("@", ""); - const min = Math.round(node.attrs.modified / 12); - const hr = Math.round(min / 60); - const day = Math.round(hr / 60 / 24); - const remote = node.attrs.userid !== Doc.CurrentUserEmail ? " userMark-remote" : ""; - return ['span', { class: "userMark-" + uid + remote + " userMark-min-" + min + " userMark-hr-" + hr + " userMark-day-" + day }, 0]; - } - }, - // the id of the user who entered the text - user_tag: { - attrs: { - userid: { default: "" }, - modified: { default: "when?" }, // 1 second intervals since 1970 - tag: { default: "" } - }, - group: "inline", - inclusive: false, - toDOM(node: any) { - const uid = node.attrs.userid.replace(".", "").replace("@", ""); - return ['span', { class: "userTag-" + uid + " userTag-" + node.attrs.tag }, 0]; - } - }, - - - // :: MarkSpec Code font mark. Represented as a `` element. - code: { - parseDOM: [{ tag: "code" }], - toDOM() { return codeDOM; } - }, - - /* FONTS */ - pFontFamily: { - attrs: { - family: { default: "Crimson Text" }, - }, - parseDOM: [{ - tag: "span", getAttrs(dom: any) { - const cstyle = getComputedStyle(dom); - if (cstyle.font) { - if (cstyle.font.indexOf("Times New Roman") !== -1) return { family: "Times New Roman" }; - if (cstyle.font.indexOf("Arial") !== -1) return { family: "Arial" }; - if (cstyle.font.indexOf("Georgia") !== -1) return { family: "Georgia" }; - if (cstyle.font.indexOf("Comic Sans") !== -1) return { family: "Comic Sans MS" }; - if (cstyle.font.indexOf("Tahoma") !== -1) return { family: "Tahoma" }; - if (cstyle.font.indexOf("Crimson") !== -1) return { family: "Crimson Text" }; - } - } - }], - toDOM: (node) => ['span', { - style: `font-family: "${node.attrs.family}";` - }] - }, - - /** FONT SIZES */ - pFontSize: { - attrs: { - fontSize: { default: 10 } - }, - parseDOM: [{ style: 'font-size: 10px;' }], - toDOM: (node) => ['span', { - style: `font-size: ${node.attrs.fontSize}px;` - }] - }, -}; diff --git a/src/client/util/nodes_rts.ts b/src/client/util/nodes_rts.ts deleted file mode 100644 index e7bcf444a..000000000 --- a/src/client/util/nodes_rts.ts +++ /dev/null @@ -1,264 +0,0 @@ -import React = require("react"); -import { DOMOutputSpecArray, Fragment, MarkSpec, Node, NodeSpec, Schema, Slice } from "prosemirror-model"; -import { bulletList, listItem, orderedList } from 'prosemirror-schema-list'; -import ParagraphNodeSpec from "./ParagraphNodeSpec"; - -const blockquoteDOM: DOMOutputSpecArray = ["blockquote", 0], hrDOM: DOMOutputSpecArray = ["hr"], - 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+" - }, - - footnote: { - group: "inline", - content: "inline*", - inline: true, - attrs: { - visibility: { default: false } - }, - // This makes the view treat the node as a leaf, even though it - // technically has content - atom: true, - toDOM: () => ["footnote", 0], - parseDOM: [{ tag: "footnote" }] - }, - - paragraph: ParagraphNodeSpec, - - // :: NodeSpec A blockquote (`
`) wrapping one or more blocks. - blockquote: { - content: "block+", - group: "block", - defining: true, - parseDOM: [{ tag: "blockquote" }], - toDOM() { return blockquoteDOM; } - }, - - // :: NodeSpec A horizontal rule (`
`). - 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 `

` to - // `

` 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 `
` element with a
-    // `` 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"
-    },
-
-    dashComment: {
-        attrs: {
-            docid: { default: "" },
-        },
-        inline: true,
-        group: "inline",
-        toDOM(node) {
-            const attrs = { style: `width: 40px` };
-            return ["span", { ...node.attrs, ...attrs }, "←"];
-        },
-    },
-
-    summary: {
-        inline: true,
-        attrs: {
-            visibility: { default: false },
-            text: { default: undefined },
-            textslice: { default: undefined },
-        },
-        group: "inline",
-        toDOM(node) {
-            const attrs = { style: `width: 40px` };
-            return ["span", { ...node.attrs, ...attrs }];
-        },
-    },
-
-    // :: NodeSpec An inline image (``) node. Supports `src`,
-    // `alt`, and `href` attributes. The latter two default to the empty
-    // string.
-    image: {
-        inline: true,
-        attrs: {
-            src: {},
-            agnostic: { default: null },
-            width: { default: 100 },
-            alt: { default: null },
-            title: { default: null },
-            float: { default: "left" },
-            location: { default: "onRight" },
-            docid: { default: "" }
-        },
-        group: "inline",
-        draggable: true,
-        parseDOM: [{
-            tag: "img[src]", getAttrs(dom: any) {
-                return {
-                    src: dom.getAttribute("src"),
-                    title: dom.getAttribute("title"),
-                    alt: dom.getAttribute("alt"),
-                    width: Math.min(100, Number(dom.getAttribute("width"))),
-                };
-            }
-        }],
-        // TODO if we don't define toDom, dragging the image crashes. Why?
-        toDOM(node) {
-            const attrs = { style: `width: ${node.attrs.width}` };
-            return ["img", { ...node.attrs, ...attrs }];
-        }
-    },
-
-    dashDoc: {
-        inline: true,
-        attrs: {
-            width: { default: 200 },
-            height: { default: 100 },
-            title: { default: null },
-            float: { default: "right" },
-            location: { default: "onRight" },
-            hidden: { default: false },
-            fieldKey: { default: "" },
-            docid: { default: "" },
-            alias: { default: "" }
-        },
-        group: "inline",
-        draggable: false,
-        toDOM(node) {
-            const attrs = { style: `width: ${node.attrs.width}, height: ${node.attrs.height}` };
-            return ["div", { ...node.attrs, ...attrs }];
-        }
-    },
-
-    dashField: {
-        inline: true,
-        attrs: {
-            fieldKey: { default: "" },
-            docid: { default: "" }
-        },
-        group: "inline",
-        draggable: false,
-        toDOM(node) {
-            const attrs = { style: `width: ${node.attrs.width}, height: ${node.attrs.height}` };
-            return ["div", { ...node.attrs, ...attrs }];
-        }
-    },
-
-    video: {
-        inline: true,
-        attrs: {
-            src: {},
-            width: { default: "100px" },
-            alt: { default: null },
-            title: { default: null }
-        },
-        group: "inline",
-        draggable: true,
-        parseDOM: [{
-            tag: "video[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"))),
-                };
-            }
-        }],
-        toDOM(node) {
-            const attrs = { style: `width: ${node.attrs.width}` };
-            return ["video", { ...node.attrs, ...attrs }];
-        }
-    },
-
-    // :: NodeSpec A hard line break, represented in the DOM as `
`. - hard_break: { - inline: true, - group: "inline", - selectable: false, - parseDOM: [{ tag: "br" }], - toDOM() { return brDOM; } - }, - - ordered_list: { - ...orderedList, - content: 'list_item+', - group: 'block', - attrs: { - bulletStyle: { default: 0 }, - mapStyle: { default: "decimal" }, - setFontSize: { default: undefined }, - setFontFamily: { default: "inherit" }, - setFontColor: { default: "inherit" }, - inheritedFontSize: { default: undefined }, - visibility: { default: true }, - indent: { default: undefined } - }, - toDOM(node: Node) { - if (node.attrs.mapStyle === "bullet") return ['ul', 0]; - const map = node.attrs.bulletStyle ? node.attrs.mapStyle + node.attrs.bulletStyle : ""; - const fsize = node.attrs.setFontSize ? node.attrs.setFontSize : node.attrs.inheritedFontSize; - const ffam = node.attrs.setFontFamily; - const color = node.attrs.setFontColor; - return node.attrs.visibility ? - ['ol', { class: `${map}-ol`, style: `list-style: none; font-size: ${fsize}; font-family: ${ffam}; color:${color}; margin-left: ${node.attrs.indent}` }, 0] : - ['ol', { class: `${map}-ol`, style: `list-style: none;` }]; - } - }, - - bullet_list: { - ...bulletList, - content: 'list_item+', - group: 'block', - // parseDOM: [{ tag: "ul" }, { style: 'list-style-type=disc' }], - toDOM(node: Node) { - return ['ul', 0]; - } - }, - - list_item: { - attrs: { - bulletStyle: { default: 0 }, - mapStyle: { default: "decimal" }, - visibility: { default: true } - }, - ...listItem, - content: 'paragraph block*', - toDOM(node: any) { - const map = node.attrs.bulletStyle ? node.attrs.mapStyle + node.attrs.bulletStyle : ""; - return node.attrs.visibility ? ["li", { class: `${map}` }, 0] : ["li", { class: `${map}` }, "..."]; - //return ["li", { class: `${map}` }, 0]; - } - }, -}; \ No newline at end of file diff --git a/src/client/util/prosemirrorPatches.js b/src/client/util/prosemirrorPatches.js deleted file mode 100644 index 269423482..000000000 --- a/src/client/util/prosemirrorPatches.js +++ /dev/null @@ -1,139 +0,0 @@ -'use strict'; - -Object.defineProperty(exports, '__esModule', { value: true }); - -var prosemirrorInputRules = require('prosemirror-inputrules'); -var prosemirrorTransform = require('prosemirror-transform'); -var prosemirrorModel = require('prosemirror-model'); - -exports.liftListItem = liftListItem; -exports.sinkListItem = sinkListItem; -exports.wrappingInputRule = wrappingInputRule; -// :: (NodeType) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool -// Create a command to lift the list item around the selection up into -// a wrapping list. -function liftListItem(itemType) { - return function (tx, dispatch) { - var ref = tx.selection; - var $from = ref.$from; - var $to = ref.$to; - var range = $from.blockRange($to, function (node) { return node.childCount && node.firstChild.type == itemType; }); - if (!range) { return false } - if (!dispatch) { return true } - if ($from.node(range.depth - 1).type == itemType) // Inside a parent list - { return liftToOuterList(tx, dispatch, itemType, range) } - else // Outer list node - { return liftOutOfList(tx, dispatch, range) } - } -} - -function liftToOuterList(tr, dispatch, itemType, range) { - var end = range.end, endOfList = range.$to.end(range.depth); - if (end < endOfList) { - // There are siblings after the lifted items, which must become - // children of the last item - tr.step(new prosemirrorTransform.ReplaceAroundStep(end - 1, endOfList, end, endOfList, - new prosemirrorModel.Slice(prosemirrorModel.Fragment.from(itemType.create(null, range.parent.copy())), 1, 0), 1, true)); - range = new prosemirrorModel.NodeRange(tr.doc.resolve(range.$from.pos), tr.doc.resolve(endOfList), range.depth); - } - dispatch(tr.lift(range, prosemirrorTransform.liftTarget(range)).scrollIntoView()); - return true -} - -function liftOutOfList(tr, dispatch, range) { - var list = range.parent; - // Merge the list items into a single big item - for (var pos = range.end, i = range.endIndex - 1, e = range.startIndex; i > e; i--) { - pos -= list.child(i).nodeSize; - tr.delete(pos - 1, pos + 1); - } - var $start = tr.doc.resolve(range.start), item = $start.nodeAfter; - var atStart = range.startIndex == 0, atEnd = range.endIndex == list.childCount; - var parent = $start.node(-1), indexBefore = $start.index(-1); - if (!parent.canReplace(indexBefore + (atStart ? 0 : 1), indexBefore + 1, - item.content.append(atEnd ? prosemirrorModel.Fragment.empty : prosemirrorModel.Fragment.from(list)))) { return false } - var start = $start.pos, end = start + item.nodeSize; - // Strip off the surrounding list. At the sides where we're not at - // the end of the list, the existing list is closed. At sides where - // this is the end, it is overwritten to its end. - tr.step(new prosemirrorTransform.ReplaceAroundStep(start - (atStart ? 1 : 0), end + (atEnd ? 1 : 0), start + 1, end - 1, - new prosemirrorModel.Slice((atStart ? prosemirrorModel.Fragment.empty : prosemirrorModel.Fragment.from(list.copy(prosemirrorModel.Fragment.empty))) - .append(atEnd ? prosemirrorModel.Fragment.empty : prosemirrorModel.Fragment.from(list.copy(prosemirrorModel.Fragment.empty))), - atStart ? 0 : 1, atEnd ? 0 : 1), atStart ? 0 : 1)); - dispatch(tr.scrollIntoView()); - return true -} - -// :: (NodeType) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool -// Create a command to sink the list item around the selection down -// into an inner list. -function sinkListItem(itemType) { - return function (state, dispatch) { - var ref = state.selection; - var $from = ref.$from; - var $to = ref.$to; - var range = $from.blockRange($to, function (node) { return node.childCount && node.firstChild.type == itemType; }); - if (!range) { return false } - var startIndex = range.startIndex; - if (startIndex == 0) { return false } - var parent = range.parent, nodeBefore = parent.child(startIndex - 1); - if (nodeBefore.type != itemType) { return false; } - - if (dispatch) { - var nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type == parent.type; - var inner = prosemirrorModel.Fragment.from(nestedBefore ? itemType.create() : null); - let slice = new prosemirrorModel.Slice(prosemirrorModel.Fragment.from(itemType.create(null, prosemirrorModel.Fragment.from(parent.type.create({ ...parent.attrs, fontSize: parent.attrs.fontSize ? parent.attrs.fontSize - 4 : undefined }, inner)))), - nestedBefore ? 3 : 1, 0); - var before = range.start, after = range.end; - dispatch(state.tr.step(new prosemirrorTransform.ReplaceAroundStep(before - (nestedBefore ? 3 : 1), after, - before, after, slice, 1, true)) - .scrollIntoView()); - } - return true - } -} - -function findWrappingOutside(range, type) { - var parent = range.parent; - var startIndex = range.startIndex; - var endIndex = range.endIndex; - var around = parent.contentMatchAt(startIndex).findWrapping(type); - if (!around) { return null } - var outer = around.length ? around[0] : type; - return parent.canReplaceWith(startIndex, endIndex, outer) ? around : null -} - -function findWrappingInside(range, type) { - var parent = range.parent; - var startIndex = range.startIndex; - var endIndex = range.endIndex; - var inner = parent.child(startIndex); - var inside = type.contentMatch.findWrapping(inner.type); - if (!inside) { return null } - var lastType = inside.length ? inside[inside.length - 1] : type; - var innerMatch = lastType.contentMatch; - for (var i = startIndex; innerMatch && i < endIndex; i++) { innerMatch = innerMatch.matchType(parent.child(i).type); } - if (!innerMatch || !innerMatch.validEnd) { return null } - return inside -} -function findWrapping(range, nodeType, attrs, innerRange, customWithAttrs = null) { - if (innerRange === void 0) innerRange = range; - let withAttrs = (type) => ({ type: type, attrs: null }); - var around = findWrappingOutside(range, nodeType); - var inner = around && findWrappingInside(innerRange, nodeType); - if (!inner) { return null } - return around.map(withAttrs).concat({ type: nodeType, attrs: attrs }).concat(inner.map(customWithAttrs ? customWithAttrs : withAttrs)) -} -function wrappingInputRule(regexp, nodeType, getAttrs, joinPredicate, customWithAttrs = null) { - return new prosemirrorInputRules.InputRule(regexp, function (state, match, start, end) { - var attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs; - var tr = state.tr.delete(start, end); - var $start = tr.doc.resolve(start), range = $start.blockRange(), wrapping = range && findWrapping(range, nodeType, attrs, undefined, customWithAttrs); - if (!wrapping) { return null } - tr.wrap(range, wrapping); - var before = tr.doc.resolve(start - 1).nodeBefore; - if (before && before.type == nodeType && prosemirrorTransform.canJoin(tr.doc, start - 1) && - (!joinPredicate || joinPredicate(match, before))) { tr.join(start - 1); } - return tr - }) -} \ No newline at end of file diff --git a/src/client/util/schema_rts.ts b/src/client/util/schema_rts.ts deleted file mode 100644 index 83561073c..000000000 --- a/src/client/util/schema_rts.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Schema, Slice } from "prosemirror-model"; - -import { nodes } from "./nodes_rts"; -import { marks } from "./marks_rts"; - - -// :: Schema -// This schema rougly corresponds to the document schema used by -// [CommonMark](http://commonmark.org/), minus the list elements, -// which are defined in the [`prosemirror-schema-list`](#schema-list) -// module. -// -// 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 }); - -const fromJson = schema.nodeFromJSON; - -schema.nodeFromJSON = (json: any) => { - const node = fromJson(json); - if (json.type === schema.nodes.summary.name) { - node.attrs.text = Slice.fromJSON(schema, node.attrs.textslice); - } - return node; -}; \ No newline at end of file diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx index c02f79187..3624cdb6d 100644 --- a/src/client/views/DocumentButtonBar.tsx +++ b/src/client/views/DocumentButtonBar.tsx @@ -15,7 +15,7 @@ import './collections/ParentDocumentSelector.scss'; import './DocumentButtonBar.scss'; import { LinkMenu } from "./linking/LinkMenu"; import { DocumentView } from './nodes/DocumentView'; -import { GoogleRef } from "./nodes/FormattedTextBox"; +import { GoogleRef } from "./nodes/formattedText/FormattedTextBox"; import { TemplateMenu } from "./TemplateMenu"; import { Template, Templates } from "./Templates"; import React = require("react"); diff --git a/src/client/views/InkingControl.tsx b/src/client/views/InkingControl.tsx index 172c1864a..70ea955e1 100644 --- a/src/client/views/InkingControl.tsx +++ b/src/client/views/InkingControl.tsx @@ -8,7 +8,7 @@ import { Scripting } from "../util/Scripting"; import { SelectionManager } from "../util/SelectionManager"; import { undoBatch } from "../util/UndoManager"; import GestureOverlay from "./GestureOverlay"; -import { FormattedTextBox } from "./nodes/FormattedTextBox"; +import { FormattedTextBox } from "./nodes/formattedText/FormattedTextBox"; export class InkingControl { @observable static Instance: InkingControl; diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index 8fb67c435..20238985d 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -19,7 +19,7 @@ import { DocServer } from '../DocServer'; import { Docs, DocumentOptions } from '../documents/Documents'; import { DocumentType } from '../documents/DocumentTypes'; import { HistoryUtil } from '../util/History'; -import RichTextMenu from '../util/RichTextMenu'; +import RichTextMenu from './nodes/formattedText/RichTextMenu'; import { Scripting } from '../util/Scripting'; import SettingsManager from '../util/SettingsManager'; import SharingManager from '../util/SharingManager'; diff --git a/src/client/views/collections/CollectionCarouselView.tsx b/src/client/views/collections/CollectionCarouselView.tsx index f4250e96d..eda8e5684 100644 --- a/src/client/views/collections/CollectionCarouselView.tsx +++ b/src/client/views/collections/CollectionCarouselView.tsx @@ -11,7 +11,7 @@ import "./CollectionCarouselView.scss"; import { CollectionSubView } from './CollectionSubView'; import { faCaretLeft, faCaretRight } from '@fortawesome/free-solid-svg-icons'; import { Doc } from '../../../new_fields/Doc'; -import { FormattedTextBox } from '../nodes/FormattedTextBox'; +import { FormattedTextBox } from '../nodes/formattedText/FormattedTextBox'; import { ContextMenu } from '../ContextMenu'; import { ObjectField } from '../../../new_fields/ObjectField'; diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index 49abc6ee6..fb7535d9f 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -15,7 +15,7 @@ import { DragManager, dropActionType } from "../../util/DragManager"; import { undoBatch, UndoManager } from "../../util/UndoManager"; import { DocComponent } from "../DocComponent"; import { FieldViewProps } from "../nodes/FieldView"; -import { FormattedTextBox, GoogleRef } from "../nodes/FormattedTextBox"; +import { FormattedTextBox, GoogleRef } from "../nodes/formattedText/FormattedTextBox"; import { CollectionView } from "./CollectionView"; import React = require("react"); import { basename } from 'path'; diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 28b461313..92b27a0c6 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -32,7 +32,7 @@ import { ContextMenuProps } from "../../ContextMenuItem"; import { InkingControl } from "../../InkingControl"; import { CollectionFreeFormDocumentView } from "../../nodes/CollectionFreeFormDocumentView"; import { DocumentViewProps, DocumentView } from "../../nodes/DocumentView"; -import { FormattedTextBox } from "../../nodes/FormattedTextBox"; +import { FormattedTextBox } from "../../nodes/formattedText/FormattedTextBox"; import { pageSchema } from "../../nodes/ImageBox"; import PDFMenu from "../../pdf/PDFMenu"; import { CollectionDockingView } from "../CollectionDockingView"; diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index cd8166309..2d3bb6f3c 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -19,7 +19,7 @@ import React = require("react"); import { CognitiveServices } from "../../../cognitive_services/CognitiveServices"; import { RichTextField } from "../../../../new_fields/RichTextField"; import { CollectionView } from "../CollectionView"; -import { FormattedTextBox } from "../../nodes/FormattedTextBox"; +import { FormattedTextBox } from "../../nodes/formattedText/FormattedTextBox"; import { ScriptField } from "../../../../new_fields/ScriptField"; interface MarqueeViewProps { diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx index cd78ac7b3..4d20d3e2c 100644 --- a/src/client/views/nodes/DocumentContentsView.tsx +++ b/src/client/views/nodes/DocumentContentsView.tsx @@ -19,7 +19,7 @@ import { DocumentViewProps } from "./DocumentView"; import "./DocumentView.scss"; import { FontIconBox } from "./FontIconBox"; import { FieldView, FieldViewProps } from "./FieldView"; -import { FormattedTextBox } from "./FormattedTextBox"; +import { FormattedTextBox } from "./formattedText/FormattedTextBox"; import { ImageBox } from "./ImageBox"; import { KeyValueBox } from "./KeyValueBox"; import { PDFBox } from "./PDFBox"; diff --git a/src/client/views/nodes/FormattedTextBox.scss b/src/client/views/nodes/FormattedTextBox.scss deleted file mode 100644 index 3bedb7127..000000000 --- a/src/client/views/nodes/FormattedTextBox.scss +++ /dev/null @@ -1,265 +0,0 @@ -@import "../globalCssVariables"; - -.ProseMirror { - width: 100%; - height: 100%; - min-height: 100%; -} - -.ProseMirror:focus { - outline: none !important; -} - -.formattedTextBox-cont { - touch-action: none; - cursor: text; - background: inherit; - padding: 0; - border-width: 0px; - border-radius: inherit; - border-color: $intermediate-color; - box-sizing: border-box; - background-color: inherit; - border-style: solid; - overflow-y: auto; - overflow-x: hidden; - color: initial; - max-height: 100%; - display: flex; - flex-direction: row; - transition: opacity 1s; - - .formattedTextBox-dictation { - height: 12px; - width: 10px; - top: 0px; - left: 0px; - position: absolute; - } -} -.formattedTextBox-outer { - position: relative; - overflow: auto; - display: inline-block; - width: 100%; - height: 100%; -} - -.formattedTextBox-sidebar-handle { - position: absolute; - top: calc(50% - 17.5px); - width: 10px; - height: 35px; - background: lightgray; - border-radius: 20px; - cursor:grabbing; -} - -.formattedTextBox-cont>.formattedTextBox-sidebar-handle { - right: 0; - left: unset; -} - -.formattedTextBox-sidebar, -.formattedTextBox-sidebar-inking { - border-left: dashed 1px black; - height: 100%; - display: inline-block; - position: absolute; - right: 0; - - .collectionfreeformview-container { - position: relative; - } - - >.formattedTextBox-sidebar-handle { - right: unset; - left: -5; - } -} - -.formattedTextBox-sidebar-inking { - pointer-events: all; -} - -.formattedTextBox-inner-rounded { - height: 70%; - width: 85%; - position: absolute; - overflow: auto; - top: 15%; - left: 10%; -} - -.formattedTextBox-inner-rounded, -.formattedTextBox-inner { - height: 100%; - white-space: pre-wrap; -} - -// .menuicon { -// display: inline-block; -// border-right: 1px solid rgba(0, 0, 0, 0.2); -// color: #888; -// line-height: 1; -// padding: 0 7px; -// margin: 1px; -// cursor: pointer; -// text-align: center; -// min-width: 1.4em; -// } - -.strong, -.heading { - font-weight: bold; -} - -.em { - font-style: italic; -} - -.userMarkOpen { - background: rgba(255, 255, 0, 0.267); - display: inline; -} - -.userMark { - background: rgba(255, 255, 0, 0.267); - font-size: 2px; - display: inline-grid; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - width: 10px; - min-height: 10px; - text-align: center; - align-content: center; -} - -footnote { - display: inline-block; - position: relative; - cursor: pointer; - - div { - padding: 0 !important; - } -} - -footnote::after { - content: counter(prosemirror-footnote); - vertical-align: super; - font-size: 75%; - counter-increment: prosemirror-footnote; -} - -.ProseMirror { - counter-reset: prosemirror-footnote; -} - -.footnote-tooltip { - cursor: auto; - font-size: 75%; - position: absolute; - left: -30px; - top: calc(100% + 10px); - background: silver; - padding: 3px; - border-radius: 2px; - max-width: 100px; - min-width: 50px; - width: max-content; -} - -.prosemirror-attribution { - font-size: 8px; -} - -.footnote-tooltip::before { - border: 5px solid silver; - border-top-width: 0px; - border-left-color: transparent; - border-right-color: transparent; - position: absolute; - top: -5px; - left: 27px; - content: " "; - height: 0; - width: 0; -} - - -.formattedTextBox-inlineComment { - position: relative; - width: 40px; - height: 20px; - &::before { - content: "→"; - } - &:hover { - background: orange; - } -} - -.formattedTextBox-summarizer { - opacity: 0.5; - position: relative; - width: 40px; - height: 20px; - &::after { - content: "←"; - } -} - -.formattedTextBox-summarizer-collapsed { - opacity: 0.5; - position: relative; - width: 40px; - height: 20px; - &::after { - content: "..."; - } -} - -.ProseMirror { - touch-action: none; - span { - font-family: inherit; - } - - ol, ul { - counter-reset: deci1 0 multi1 0; - padding-left: 1em; - font-family: inherit; - } - ol { - margin-left: 1em; - font-family: inherit; - } - - .decimal1-ol { counter-reset: deci1; p {display: inline; font-family: inherit} margin-left: 0; } - .decimal2-ol { counter-reset: deci2; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 1em;} - .decimal3-ol { counter-reset: deci3; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 2em;} - .decimal4-ol { counter-reset: deci4; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 3em;} - .decimal5-ol { counter-reset: deci5; p {display: inline; font-family: inherit} font-size: smaller; } - .decimal6-ol { counter-reset: deci6; p {display: inline; font-family: inherit} font-size: smaller; } - .decimal7-ol { counter-reset: deci7; p {display: inline; font-family: inherit} font-size: smaller; } - - .multi1-ol { counter-reset: multi1; p {display: inline; font-family: inherit} margin-left: 0; padding-left: 1.2em } - .multi2-ol { counter-reset: multi2; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 1.4em;} - .multi3-ol { counter-reset: multi3; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 2em;} - .multi4-ol { counter-reset: multi4; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 3.4em;} - - .decimal1:before { transition: 0.5s;counter-increment: deci1; display: inline-block; margin-left: -1em; width: 1em; content: counter(deci1) ". "; } - .decimal2:before { transition: 0.5s;counter-increment: deci2; display: inline-block; margin-left: -2.1em; width: 2.1em; content: counter(deci1) "."counter(deci2) ". "; } - .decimal3:before { transition: 0.5s;counter-increment: deci3; display: inline-block; margin-left: -2.85em;width: 2.85em; content: counter(deci1) "."counter(deci2) "."counter(deci3) ". "; } - .decimal4:before { transition: 0.5s;counter-increment: deci4; display: inline-block; margin-left: -3.85em;width: 3.85em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) ". "; } - .decimal5:before { transition: 0.5s;counter-increment: deci5; display: inline-block; margin-left: -2em; width: 5em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) ". "; } - .decimal6:before { transition: 0.5s;counter-increment: deci6; display: inline-block; margin-left: -2em; width: 6em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) "."counter(deci6) ". "; } - .decimal7:before { transition: 0.5s;counter-increment: deci7; display: inline-block; margin-left: -2em; width: 7em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) "."counter(deci6) "."counter(deci7) ". "; } - - .multi1:before { transition: 0.5s;counter-increment: multi1; display: inline-block; margin-left: -1em; width: 1.2em; content: counter(multi1, upper-alpha) ". "; } - .multi2:before { transition: 0.5s;counter-increment: multi2; display: inline-block; margin-left: -2em; width: 2em; content: counter(multi1, upper-alpha) "."counter(multi2, decimal) ". "; } - .multi3:before { transition: 0.5s;counter-increment: multi3; display: inline-block; margin-left: -2.85em; width:2.85em; content: counter(multi1, upper-alpha) "."counter(multi2, decimal) "."counter(multi3, lower-alpha) ". "; } - .multi4:before { transition: 0.5s;counter-increment: multi4; display: inline-block; margin-left: -4.2em; width: 4.2em; content: counter(multi1, upper-alpha) "."counter(multi2, decimal) "."counter(multi3, lower-alpha) "."counter(multi4, lower-roman) ". "; } -} \ No newline at end of file diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/FormattedTextBox.tsx deleted file mode 100644 index e65453aa0..000000000 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ /dev/null @@ -1,1306 +0,0 @@ -import { library } from '@fortawesome/fontawesome-svg-core'; -import { faEdit, faSmile, faTextHeight, faUpload } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { isEqual } from "lodash"; -import { action, computed, IReactionDisposer, Lambda, observable, reaction, runInAction } from "mobx"; -import { observer } from "mobx-react"; -import { baseKeymap } from "prosemirror-commands"; -import { history } from "prosemirror-history"; -import { inputRules } from 'prosemirror-inputrules'; -import { keymap } from "prosemirror-keymap"; -import { Fragment, Mark, Node, Slice } from "prosemirror-model"; -import { EditorState, NodeSelection, Plugin, TextSelection, Transaction } from "prosemirror-state"; -import { ReplaceStep } from 'prosemirror-transform'; -import { EditorView } from "prosemirror-view"; -import { DateField } from '../../../new_fields/DateField'; -import { DataSym, Doc, DocListCast, DocListCastAsync, Field, HeightSym, Opt, WidthSym } from "../../../new_fields/Doc"; -import { documentSchema } from '../../../new_fields/documentSchemas'; -import { Id } from '../../../new_fields/FieldSymbols'; -import { InkTool } from '../../../new_fields/InkField'; -import { PrefetchProxy } from '../../../new_fields/Proxy'; -import { RichTextField } from "../../../new_fields/RichTextField"; -import { RichTextUtils } from '../../../new_fields/RichTextUtils'; -import { createSchema, makeInterface } from "../../../new_fields/Schema"; -import { Cast, DateCast, NumCast, StrCast } from "../../../new_fields/Types"; -import { TraceMobx } from '../../../new_fields/util'; -import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, numberRange, returnOne, returnZero, Utils } from '../../../Utils'; -import { GoogleApiClientUtils, Pulls, Pushes } from '../../apis/google_docs/GoogleApiClientUtils'; -import { DocServer } from "../../DocServer"; -import { Docs, DocUtils } from '../../documents/Documents'; -import { DocumentType } from '../../documents/DocumentTypes'; -import { DictationManager } from '../../util/DictationManager'; -import { DragManager } from "../../util/DragManager"; -import { makeTemplate } from '../../util/DropConverter'; -import buildKeymap from "../../util/ProsemirrorExampleTransfer"; -import RichTextMenu from '../../util/RichTextMenu'; -import { RichTextRules } from "../../util/RichTextRules"; -// import { DashDocCommentView, DashDocView, DashFieldView, FootnoteView, ImageResizeView, OrderedListView, SummaryView } from "../../util/RichTextSchema"; -// import { DashDocCommentView, DashDocView, DashFieldView, FootnoteView, SummaryView } from "../../util/RichTextSchema"; -import { OrderedListView } from "../../util/RichTextSchema"; -import { ImageResizeView } from "../../util/ImageResizeView"; - -import { DashDocCommentView } from "../../util/DashDocCommentView"; -import { DashFieldView } from "../../util/DashFieldView"; -import { FootnoteView } from "../../util/FootnoteView"; -import { SummaryView } from "../../util/SummaryView"; -import { DashDocView } from "../../util/DashDocView"; - -import { schema } from "../../util/schema_rts"; -import { SelectionManager } from "../../util/SelectionManager"; -import { undoBatch, UndoManager } from "../../util/UndoManager"; -import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView'; -import { ContextMenu } from '../ContextMenu'; -import { ContextMenuProps } from '../ContextMenuItem'; -import { ViewBoxAnnotatableComponent } from "../DocComponent"; -import { DocumentButtonBar } from '../DocumentButtonBar'; -import { InkingControl } from "../InkingControl"; -import { AudioBox } from './AudioBox'; -import { FieldView, FieldViewProps } from "./FieldView"; -import "./FormattedTextBox.scss"; -import { FormattedTextBoxComment, formattedTextBoxCommentPlugin } from './FormattedTextBoxComment'; -import React = require("react"); - -library.add(faEdit); -library.add(faSmile, faTextHeight, faUpload); - -export interface FormattedTextBoxProps { - hideOnLeave?: boolean; - makeLink?: () => Opt; - xMargin?: number; - yMargin?: number; -} - -const richTextSchema = createSchema({ - documentText: "string" -}); - -export const GoogleRef = "googleDocId"; - -type RichTextDocument = makeInterface<[typeof richTextSchema, typeof documentSchema]>; -const RichTextDocument = makeInterface(richTextSchema, documentSchema); - -type PullHandler = (exportState: Opt, dataDoc: Doc) => void; - -@observer -export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProps & FormattedTextBoxProps), RichTextDocument>(RichTextDocument) { - public static LayoutString(fieldStr: string) { return FieldView.LayoutString(FormattedTextBox, fieldStr); } - public static blankState = () => EditorState.create(FormattedTextBox.Instance.config); - public static Instance: FormattedTextBox; - public ProseRef?: HTMLDivElement; - private _ref: React.RefObject = React.createRef(); - private _scrollRef: React.RefObject = React.createRef(); - private _editorView: Opt; - private _applyingChange: boolean = false; - private _searchIndex = 0; - private _sidebarMovement = 0; - private _lastX = 0; - private _lastY = 0; - private _undoTyping?: UndoManager.Batch; - private _disposers: { [name: string]: IReactionDisposer } = {}; - private dropDisposer?: DragManager.DragDropDisposer; - - @computed get _recording() { return this.dataDoc.audioState === "recording"; } - set _recording(value) { this.dataDoc.audioState = value ? "recording" : undefined; } - - @observable private _entered = false; - - public static FocusedBox: FormattedTextBox | undefined; - public static SelectOnLoad = ""; - public static SelectOnLoadChar = ""; - public static IsFragment(html: string) { - return html.indexOf("data-pm-slice") !== -1; - } - public static GetHref(html: string): string { - const parser = new DOMParser(); - const parsedHtml = parser.parseFromString(html, 'text/html'); - if (parsedHtml.body.childNodes.length === 1 && parsedHtml.body.childNodes[0].childNodes.length === 1 && - (parsedHtml.body.childNodes[0].childNodes[0] as any).href) { - return (parsedHtml.body.childNodes[0].childNodes[0] as any).href; - } - return ""; - } - public static GetDocFromUrl(url: string) { - if (url.startsWith(document.location.origin)) { - const split = new URL(url).pathname.split("doc/"); - const docid = split[split.length - 1]; - return docid; - } - return ""; - } - - @undoBatch - public setFontColor(color: string) { - const view = this._editorView!; - if (view.state.selection.from === view.state.selection.to) return false; - if (view.state.selection.to - view.state.selection.from > view.state.doc.nodeSize - 3) { - this.layoutDoc.color = color; - } - const colorMark = view.state.schema.mark(view.state.schema.marks.pFontColor, { color: color }); - view.dispatch(view.state.tr.addMark(view.state.selection.from, view.state.selection.to, colorMark)); - return true; - } - - constructor(props: any) { - super(props); - FormattedTextBox.Instance = this; - this.updateHighlights(); - } - - public get CurrentDiv(): HTMLDivElement { return this._ref.current!; } - - linkOnDeselect: Map = new Map(); - - doLinkOnDeselect() { - Array.from(this.linkOnDeselect.entries()).map(entry => { - const key = entry[0]; - const value = entry[1]; - const id = Utils.GenerateDeterministicGuid(this.dataDoc[Id] + key); - DocServer.GetRefField(value).then(doc => { - DocServer.GetRefField(id).then(linkDoc => { - this.dataDoc[key] = doc || Docs.Create.FreeformDocument([], { title: value, _width: 500, _height: 500 }, value); - DocUtils.Publish(this.dataDoc[key] as Doc, value, this.props.addDocument, this.props.removeDocument); - if (linkDoc) { (linkDoc as Doc).anchor2 = this.dataDoc[key] as Doc; } - else DocUtils.MakeLink({ doc: this.props.Document }, { doc: this.dataDoc[key] as Doc }, "link to named target", id); - }); - }); - }); - this.linkOnDeselect.clear(); - } - - dispatchTransaction = (tx: Transaction) => { - if (this._editorView) { - const metadata = tx.selection.$from.marks().find((m: Mark) => m.type === schema.marks.metadata); - if (metadata) { - const range = tx.selection.$from.blockRange(tx.selection.$to); - let text = range ? tx.doc.textBetween(range.start, range.end) : ""; - let textEndSelection = tx.selection.to; - for (; textEndSelection < range!.end && text[textEndSelection - range!.start] !== " "; textEndSelection++) { } - text = text.substr(0, textEndSelection - range!.start); - text = text.split(" ")[text.split(" ").length - 1]; - const split = text.split("::"); - if (split.length > 1 && split[1]) { - const key = split[0]; - const value = split[split.length - 1]; - this.linkOnDeselect.set(key, value); - - const id = Utils.GenerateDeterministicGuid(this.dataDoc[Id] + key); - const link = this._editorView.state.schema.marks.link.create({ href: Utils.prepend("/doc/" + id), location: "onRight", title: value }); - const mval = this._editorView.state.schema.marks.metadataVal.create(); - const offset = (tx.selection.to === range!.end - 1 ? -1 : 0); - tx = tx.addMark(textEndSelection - value.length + offset, textEndSelection, link).addMark(textEndSelection - value.length + offset, textEndSelection, mval); - this.dataDoc[key] = value; - } - } - const state = this._editorView.state.apply(tx); - this._editorView.updateState(state); - (tx.storedMarks && !this._editorView.state.storedMarks) && (this._editorView.state.storedMarks = tx.storedMarks); - - const tsel = this._editorView.state.selection.$from; - tsel.marks().filter(m => m.type === this._editorView!.state.schema.marks.user_mark).map(m => AudioBox.SetScrubTime(Math.max(0, m.attrs.modified * 1000))); - const curText = state.doc.textBetween(0, state.doc.content.size, " \n"); - const curTemp = Cast(this.props.Document[this.props.fieldKey + "-textTemplate"], RichTextField); - if (!this._applyingChange) { - this._applyingChange = true; - this.dataDoc[this.props.fieldKey + "-lastModified"] = new DateField(new Date(Date.now())); - if (!curTemp || curText) { // if no template, or there's text, write it to the document. (if this is driven by a template, then this overwrites the template text which is intended) - this.dataDoc[this.props.fieldKey] = new RichTextField(JSON.stringify(state.toJSON()), curText); - this.dataDoc[this.props.fieldKey + "-noTemplate"] = (curTemp?.Text || "") !== curText; // mark the data field as being split from the template if it has been edited - } else { // if we've deleted all the text in a note driven by a template, then restore the template data - this._editorView.updateState(EditorState.fromJSON(this.config, JSON.parse(curTemp.Data))); - this.dataDoc[this.props.fieldKey + "-noTemplate"] = undefined; // mark the data field as not being split from any template it might have - } - this._applyingChange = false; - } - this.updateTitle(); - this.tryUpdateHeight(); - } - } - - updateTitle = () => { - if ((this.props.Document.isTemplateForField === "text" || !this.props.Document.isTemplateForField) && // only update the title if the data document's data field is changing - StrCast(this.dataDoc.title).startsWith("-") && this._editorView && !this.rootDoc.customTitle) { - const str = this._editorView.state.doc.textContent; - const titlestr = str.substr(0, Math.min(40, str.length)); - this.dataDoc.title = "-" + titlestr + (str.length > 40 ? "..." : ""); - } - } - - // needs a better API for taking in a set of words with target documents instead of just one target - public hyperlinkTerms = (terms: string[], target: Doc) => { - if (this._editorView && (this._editorView as any).docView && terms.some(t => t)) { - const res = terms.filter(t => t).map(term => this.findInNode(this._editorView!, this._editorView!.state.doc, term)); - const tr = this._editorView.state.tr; - const flattened: TextSelection[] = []; - res.map(r => r.map(h => flattened.push(h))); - const lastSel = Math.min(flattened.length - 1, this._searchIndex); - this._searchIndex = ++this._searchIndex > flattened.length - 1 ? 0 : this._searchIndex; - const alink = DocUtils.MakeLink({ doc: this.props.Document }, { doc: target }, "automatic")!; - const link = this._editorView.state.schema.marks.link.create({ - href: Utils.prepend("/doc/" + alink[Id]), - title: "a link", location: location, linkId: alink[Id], targetId: target[Id] - }); - this._editorView.dispatch(tr.addMark(flattened[lastSel].from, flattened[lastSel].to, link)); - } - } - public highlightSearchTerms = (terms: string[]) => { - if (this._editorView && (this._editorView as any).docView && terms.some(t => t)) { - const mark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight); - const activeMark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight, { selected: true }); - const res = terms.filter(t => t).map(term => this.findInNode(this._editorView!, this._editorView!.state.doc, term)); - let tr = this._editorView.state.tr; - const flattened: TextSelection[] = []; - res.map(r => r.map(h => flattened.push(h))); - const lastSel = Math.min(flattened.length - 1, this._searchIndex); - flattened.forEach((h: TextSelection, ind: number) => tr = tr.addMark(h.from, h.to, ind === lastSel ? activeMark : mark)); - this._searchIndex = ++this._searchIndex > flattened.length - 1 ? 0 : this._searchIndex; - this._editorView.dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(flattened[lastSel].from), tr.doc.resolve(flattened[lastSel].to))).scrollIntoView()); - } - } - - public unhighlightSearchTerms = () => { - if (this._editorView && (this._editorView as any).docView) { - const mark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight); - const activeMark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight, { selected: true }); - const end = this._editorView.state.doc.nodeSize - 2; - this._editorView.dispatch(this._editorView.state.tr.removeMark(0, end, mark).removeMark(0, end, activeMark)); - } - } - adoptAnnotation = (start: number, end: number, mark: Mark) => { - const view = this._editorView!; - const nmark = view.state.schema.marks.user_mark.create({ ...mark.attrs, userid: Doc.CurrentUserEmail }); - view.dispatch(view.state.tr.removeMark(start, end, nmark).addMark(start, end, nmark)); - } - protected createDropTarget = (ele: HTMLDivElement) => { - this.ProseRef = ele; - this.dropDisposer?.(); - ele && (this.dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.props.Document)); - } - - @undoBatch - @action - drop = async (e: Event, de: DragManager.DropEvent) => { - if (de.complete.docDragData) { - const draggedDoc = de.complete.docDragData.draggedDocuments.length && de.complete.docDragData.draggedDocuments[0]; - // replace text contents whend dragging with Alt - if (draggedDoc && draggedDoc.type === DocumentType.RTF && !Doc.AreProtosEqual(draggedDoc, this.props.Document) && de.altKey) { - if (draggedDoc.data instanceof RichTextField) { - Doc.GetProto(this.dataDoc)[this.props.fieldKey] = new RichTextField(draggedDoc.data.Data, draggedDoc.data.Text); - e.stopPropagation(); - } - // embed document when dragging with a userDropAction or an embedDoc flag set - } else if (de.complete.docDragData.userDropAction || de.complete.docDragData.embedDoc) { - const target = de.complete.docDragData.droppedDocuments[0]; - // const link = DocUtils.MakeLink({ doc: this.dataDoc, ctx: this.props.ContainingCollectionDoc }, { doc: target }, "Embedded Doc:" + target.title); - // if (link) { - target._fitToBox = true; - const node = schema.nodes.dashDoc.create({ - width: target[WidthSym](), height: target[HeightSym](), - title: "dashDoc", docid: target[Id], - float: "right" - }); - const view = this._editorView!; - view.dispatch(view.state.tr.insert(view.posAtCoords({ left: de.x, top: de.y })!.pos, node)); - this.tryUpdateHeight(); - e.stopPropagation(); - // } - } // otherwise, fall through to outer collection to handle drop - } else if (de.complete.linkDragData) { - de.complete.linkDragData.linkDropCallback = this.linkDrop; - } - } - linkDrop = (data: DragManager.LinkDragData) => { - const linkDoc = data.linkDocument!; - const anchor1Title = linkDoc.anchor1 instanceof Doc ? StrCast(linkDoc.anchor1.title) : "-untitled-"; - const anchor1Id = linkDoc.anchor1 instanceof Doc ? linkDoc.anchor1[Id] : ""; - this.makeLinkToSelection(linkDoc[Id], anchor1Title, "onRight", anchor1Id); - } - - getNodeEndpoints(context: Node, node: Node): { from: number, to: number } | null { - let offset = 0; - - if (context === node) return { from: offset, to: offset + node.nodeSize }; - - if (node.isBlock) { - // tslint:disable-next-line: prefer-for-of - for (let i = 0; i < (context.content as any).content.length; i++) { - const result = this.getNodeEndpoints((context.content as any).content[i], node); - if (result) { - return { - from: result.from + offset + (context.type.name === "doc" ? 0 : 1), - to: result.to + offset + (context.type.name === "doc" ? 0 : 1) - }; - } - offset += (context.content as any).content[i].nodeSize; - } - return null; - } else { - return null; - } - } - - - //Recursively finds matches within a given node - findInNode(pm: EditorView, node: Node, find: string) { - let ret: TextSelection[] = []; - - if (node.isTextblock) { - let index = 0, foundAt; - const ep = this.getNodeEndpoints(pm.state.doc, node); - while (ep && (foundAt = node.textContent.slice(index).search(RegExp(find, "i"))) > -1) { - const sel = new TextSelection(pm.state.doc.resolve(ep.from + index + foundAt + 1), pm.state.doc.resolve(ep.from + index + foundAt + find.length + 1)); - ret.push(sel); - index = index + foundAt + find.length; - } - } else { - node.content.forEach((child, i) => ret = ret.concat(this.findInNode(pm, child, find))); - } - return ret; - } - static _highlights: string[] = ["Text from Others", "Todo Items", "Important Items", "Disagree Items", "Ignore Items"]; - - updateHighlights = () => { - clearStyleSheetRules(FormattedTextBox._userStyleSheet); - if (FormattedTextBox._highlights.indexOf("Text from Others") !== -1) { - addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-remote", { background: "yellow" }); - } - if (FormattedTextBox._highlights.indexOf("My Text") !== -1) { - addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-" + Doc.CurrentUserEmail.replace(".", "").replace("@", ""), { background: "moccasin" }); - } - if (FormattedTextBox._highlights.indexOf("Todo Items") !== -1) { - addStyleSheetRule(FormattedTextBox._userStyleSheet, "userTag-" + "todo", { outline: "black solid 1px" }); - } - if (FormattedTextBox._highlights.indexOf("Important Items") !== -1) { - addStyleSheetRule(FormattedTextBox._userStyleSheet, "userTag-" + "important", { "font-size": "larger" }); - } - if (FormattedTextBox._highlights.indexOf("Disagree Items") !== -1) { - addStyleSheetRule(FormattedTextBox._userStyleSheet, "userTag-" + "disagree", { "text-decoration": "line-through" }); - } - if (FormattedTextBox._highlights.indexOf("Ignore Items") !== -1) { - addStyleSheetRule(FormattedTextBox._userStyleSheet, "userTag-" + "ignore", { "font-size": "1" }); - } - if (FormattedTextBox._highlights.indexOf("By Recent Minute") !== -1) { - addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-" + Doc.CurrentUserEmail.replace(".", "").replace("@", ""), { opacity: "0.1" }); - const min = Math.round(Date.now() / 1000 / 60); - numberRange(10).map(i => addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-min-" + (min - i), { opacity: ((10 - i - 1) / 10).toString() })); - setTimeout(() => this.updateHighlights()); - } - if (FormattedTextBox._highlights.indexOf("By Recent Hour") !== -1) { - addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-" + Doc.CurrentUserEmail.replace(".", "").replace("@", ""), { opacity: "0.1" }); - const hr = Math.round(Date.now() / 1000 / 60 / 60); - numberRange(10).map(i => addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-hr-" + (hr - i), { opacity: ((10 - i - 1) / 10).toString() })); - } - } - - sidebarDown = (e: React.PointerEvent) => { - this._lastX = e.clientX; - this._lastY = e.clientY; - this._sidebarMovement = 0; - document.addEventListener("pointermove", this.sidebarMove); - document.addEventListener("pointerup", this.sidebarUp); - e.stopPropagation(); - e.preventDefault(); // prevents text from being selected during drag - } - sidebarMove = (e: PointerEvent) => { - const bounds = this.CurrentDiv.getBoundingClientRect(); - this._sidebarMovement += Math.sqrt((e.clientX - this._lastX) * (e.clientX - this._lastX) + (e.clientY - this._lastY) * (e.clientY - this._lastY)); - this.props.Document.sidebarWidthPercent = "" + 100 * (1 - (e.clientX - bounds.left) / bounds.width) + "%"; - } - sidebarUp = (e: PointerEvent) => { - document.removeEventListener("pointermove", this.sidebarMove); - document.removeEventListener("pointerup", this.sidebarUp); - } - - toggleSidebar = () => this._sidebarMovement < 5 && (this.props.Document.sidebarWidthPercent = StrCast(this.props.Document.sidebarWidthPercent, "0%") === "0%" ? "25%" : "0%"); - - public static get DefaultLayout(): Doc | string | undefined { - return Cast(Doc.UserDoc().defaultTextLayout, Doc, null) || StrCast(Doc.UserDoc().defaultTextLayout, null); - } - specificContextMenu = (e: React.MouseEvent): void => { - const cm = ContextMenu.Instance; - - const funcs: ContextMenuProps[] = []; - this.props.Document.isTemplateDoc && funcs.push({ description: "Make Default Layout", event: async () => Doc.UserDoc().defaultTextLayout = new PrefetchProxy(this.props.Document), icon: "eye" }); - funcs.push({ description: "Reset Default Layout", event: () => Doc.UserDoc().defaultTextLayout = undefined, icon: "eye" }); - !this.props.Document.rootDocument && funcs.push({ - description: "Make Template", event: () => { - this.props.Document.isTemplateDoc = makeTemplate(this.props.Document); - Doc.AddDocToList(Cast(Doc.UserDoc()["template-notes"], Doc, null), "data", this.props.Document); - }, icon: "eye" - }); - funcs.push({ description: "Toggle Single Line", event: () => this.props.Document._singleLine = !this.props.Document._singleLine, icon: "expand-arrows-alt" }); - funcs.push({ description: "Toggle Sidebar", event: () => this.props.Document._showSidebar = !this.props.Document._showSidebar, icon: "expand-arrows-alt" }); - funcs.push({ description: "Toggle Dictation Icon", event: () => this.props.Document._showAudio = !this.props.Document._showAudio, icon: "expand-arrows-alt" }); - funcs.push({ description: "Toggle Menubar", event: () => this.toggleMenubar(), icon: "expand-arrows-alt" }); - - const highlighting: ContextMenuProps[] = []; - ["My Text", "Text from Others", "Todo Items", "Important Items", "Ignore Items", "Disagree Items", "By Recent Minute", "By Recent Hour"].forEach(option => - highlighting.push({ - description: (FormattedTextBox._highlights.indexOf(option) === -1 ? "Highlight " : "Unhighlight ") + option, event: () => { - e.stopPropagation(); - if (FormattedTextBox._highlights.indexOf(option) === -1) { - FormattedTextBox._highlights.push(option); - } else { - FormattedTextBox._highlights.splice(FormattedTextBox._highlights.indexOf(option), 1); - } - this.updateHighlights(); - }, icon: "expand-arrows-alt" - })); - funcs.push({ description: "highlighting...", subitems: highlighting, icon: "hand-point-right" }); - - ContextMenu.Instance.addItem({ description: "Options...", subitems: funcs, icon: "asterisk" }); - - const change = cm.findByDescription("Change Perspective..."); - const changeItems: ContextMenuProps[] = change && "subitems" in change ? change.subitems : []; - - const noteTypesDoc = Cast(Doc.UserDoc()["template-notes"], Doc, null); - DocListCast(noteTypesDoc?.data).forEach(note => { - changeItems.push({ - description: StrCast(note.title), event: undoBatch(() => { - Doc.setNativeView(this.props.Document); - Doc.makeCustomViewClicked(this.rootDoc, Docs.Create.TreeDocument, StrCast(note.title), note); - }), icon: "eye" - }); - }); - changeItems.push({ description: "FreeForm", event: undoBatch(() => Doc.makeCustomViewClicked(this.rootDoc, Docs.Create.FreeformDocument, "freeform"), "change view"), icon: "eye" }); - !change && cm.addItem({ description: "Change Perspective...", subitems: changeItems, icon: "external-link-alt" }); - - const open = cm.findByDescription("Add a Perspective..."); - const openItems: ContextMenuProps[] = open && "subitems" in open ? open.subitems : []; - - openItems.push({ - description: "FreeForm", event: undoBatch(() => { - const alias = Doc.MakeAlias(this.rootDoc); - Doc.makeCustomViewClicked(alias, Docs.Create.FreeformDocument, "freeform"); - this.props.addDocTab(alias, "onRight"); - }), icon: "eye" - }); - !open && cm.addItem({ description: "Add a Perspective...", subitems: openItems, icon: "external-link-alt" }); - - } - - recordDictation = () => { - DictationManager.Controls.listen({ - interimHandler: this.setCurrentBulletContent, - continuous: { indefinite: false }, - }).then(results => { - if (results && [DictationManager.Controls.Infringed].includes(results)) { - DictationManager.Controls.stop(); - } - //this._editorView!.focus(); - }); - } - stopDictation = (abort: boolean) => { DictationManager.Controls.stop(!abort); }; - - @action - toggleMenubar = () => { - this.props.Document._chromeStatus = this.props.Document._chromeStatus === "disabled" ? "enabled" : "disabled"; - } - - recordBullet = async () => { - const completedCue = "end session"; - const results = await DictationManager.Controls.listen({ - interimHandler: this.setCurrentBulletContent, - continuous: { indefinite: false }, - terminators: [completedCue, "bullet", "next"] - }); - if (results && [DictationManager.Controls.Infringed, completedCue].includes(results)) { - DictationManager.Controls.stop(); - return; - } - this.nextBullet(this._editorView!.state.selection.to); - setTimeout(this.recordBullet, 2000); - } - - setCurrentBulletContent = (value: string) => { - if (this._editorView) { - const state = this._editorView.state; - const now = Date.now(); - let mark = schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.floor(now / 1000) }); - if (!this._break && state.selection.to !== state.selection.from) { - for (let i = state.selection.from; i <= state.selection.to; i++) { - const pos = state.doc.resolve(i); - const um = Array.from(pos.marks()).find(m => m.type === schema.marks.user_mark); - if (um) { - mark = um; - break; - } - } - } - const recordingStart = DateCast(this.props.Document.recordingStart).date.getTime(); - this._break = false; - value = "" + (mark.attrs.modified * 1000 - recordingStart) / 1000 + value; - const from = state.selection.from; - const inserted = state.tr.insertText(value).addMark(from, from + value.length + 1, mark); - this._editorView.dispatch(inserted.setSelection(TextSelection.create(inserted.doc, from, from + value.length + 1))); - } - } - - nextBullet = (pos: number) => { - if (this._editorView) { - const frag = Fragment.fromArray(this.newListItems(2)); - if (this._editorView.state.doc.resolve(pos).depth >= 2) { - const slice = new Slice(frag, 2, 2); - let state = this._editorView.state; - this._editorView.dispatch(state.tr.step(new ReplaceStep(pos, pos, slice))); - pos += 4; - state = this._editorView.state; - this._editorView.dispatch(state.tr.setSelection(TextSelection.create(this._editorView.state.doc, pos, pos))); - } - } - } - - private newListItems = (count: number) => { - return numberRange(count).map(x => schema.nodes.list_item.create(undefined, schema.nodes.paragraph.create())); - } - - _keymap: any = undefined; - _rules: RichTextRules | undefined; - @computed get config() { - this._keymap = buildKeymap(schema, this.props); - this._rules = new RichTextRules(this.props.Document, this); - return { - schema, - plugins: [ - inputRules(this._rules.inpRules), - this.richTextMenuPlugin(), - history(), - keymap(this._keymap), - keymap(baseKeymap), - new Plugin({ - props: { - attributes: { class: "ProseMirror-example-setup-style" } - } - }), - formattedTextBoxCommentPlugin - ] - }; - } - - makeLinkToSelection(linkDocId: string, title: string, location: string, targetDocId: string) { - if (this._editorView) { - const link = this._editorView.state.schema.marks.link.create({ href: Utils.prepend("/doc/" + linkDocId), title: title, location: location, linkId: linkDocId, targetId: targetDocId }); - this._editorView.dispatch(this._editorView.state.tr.removeMark(this._editorView.state.selection.from, this._editorView.state.selection.to, this._editorView.state.schema.marks.link). - addMark(this._editorView.state.selection.from, this._editorView.state.selection.to, link)); - } - } - componentDidMount() { - this._disposers.buttonBar = reaction( - () => DocumentButtonBar.Instance, - instance => { - if (instance) { - this.pullFromGoogleDoc(this.checkState); - this.dataDoc[GoogleRef] && this.dataDoc.unchanged && runInAction(() => instance.isAnimatingFetch = true); - } - } - ); - this._disposers.linkMaker = reaction( - () => this.props.makeLink?.(), - (linkDoc: Opt) => { - if (linkDoc) { - const anchor2Title = linkDoc.anchor2 instanceof Doc ? StrCast(linkDoc.anchor2.title) : "-untitled-"; - const anchor2Id = linkDoc.anchor2 instanceof Doc ? linkDoc.anchor2[Id] : ""; - this.makeLinkToSelection(linkDoc[Id], anchor2Title, "onRight", anchor2Id); - } - }, - { fireImmediately: true } - ); - this._disposers.editorState = reaction( - () => { - if (this.dataDoc[this.props.fieldKey + "-noTemplate"] || !this.props.Document[this.props.fieldKey + "-textTemplate"]) { - return Cast(this.dataDoc[this.props.fieldKey], RichTextField, null)?.Data; - } - return Cast(this.props.Document[this.props.fieldKey + "-textTemplate"], RichTextField, null)?.Data; - }, - incomingValue => { - if (incomingValue !== undefined && this._editorView && !this._applyingChange) { - const updatedState = JSON.parse(incomingValue); - this._editorView.updateState(EditorState.fromJSON(this.config, updatedState)); - this.tryUpdateHeight(); - } - } - ); - this._disposers.pullDoc = reaction( - () => this.props.Document[Pulls], - () => { - if (!DocumentButtonBar.hasPulledHack) { - DocumentButtonBar.hasPulledHack = true; - const unchanged = this.dataDoc.unchanged; - this.pullFromGoogleDoc(unchanged ? this.checkState : this.updateState); - } - } - ); - this._disposers.pushDoc = reaction( - () => this.props.Document[Pushes], - () => { - if (!DocumentButtonBar.hasPushedHack) { - DocumentButtonBar.hasPushedHack = true; - this.pushToGoogleDoc(); - } - } - ); - this._disposers.height = reaction( - () => [this.layoutDoc[WidthSym](), this.layoutDoc._autoHeight], - () => this.tryUpdateHeight() - ); - - this.setupEditor(this.config, this.props.fieldKey); - - this._disposers.search = reaction(() => this.rootDoc.searchMatch, - search => search ? this.highlightSearchTerms([Doc.SearchQuery()]) : this.unhighlightSearchTerms(), - { fireImmediately: true }); - - this._disposers.record = reaction(() => this._recording, - () => { - if (this._recording) { - setTimeout(action(() => { - this.stopDictation(true); - setTimeout(() => this.recordDictation(), 500); - }), 500); - } else setTimeout(() => this.stopDictation(true), 0); - } - ); - this._disposers.scrollToRegion = reaction( - () => StrCast(this.layoutDoc.scrollToLinkID), - async (scrollToLinkID) => { - const findLinkFrag = (frag: Fragment, editor: EditorView) => { - const nodes: Node[] = []; - frag.forEach((node, index) => { - const examinedNode = findLinkNode(node, editor); - if (examinedNode && examinedNode.textContent) { - nodes.push(examinedNode); - start += index; - } - }); - return { frag: Fragment.fromArray(nodes), start: start }; - }; - const findLinkNode = (node: Node, editor: EditorView) => { - if (!node.isText) { - const content = findLinkFrag(node.content, editor); - return node.copy(content.frag); - } - const marks = [...node.marks]; - const linkIndex = marks.findIndex(mark => mark.type === editor.state.schema.marks.link); - return linkIndex !== -1 && scrollToLinkID === marks[linkIndex].attrs.href.replace(/.*\/doc\//, "") ? node : undefined; - }; - - let start = -1; - if (this._editorView && scrollToLinkID) { - const editor = this._editorView; - const ret = findLinkFrag(editor.state.doc.content, editor); - - if (ret.frag.size > 2 && ret.start >= 0) { - let selection = TextSelection.near(editor.state.doc.resolve(ret.start)); // default to near the start - if (ret.frag.firstChild) { - selection = TextSelection.between(editor.state.doc.resolve(ret.start), editor.state.doc.resolve(ret.start + ret.frag.firstChild.nodeSize)); // bcz: looks better to not have the target selected - } - editor.dispatch(editor.state.tr.setSelection(new TextSelection(selection.$from, selection.$from)).scrollIntoView()); - const mark = editor.state.schema.mark(this._editorView.state.schema.marks.search_highlight); - setTimeout(() => editor.dispatch(editor.state.tr.addMark(selection.from, selection.to, mark)), 0); - setTimeout(() => this.unhighlightSearchTerms(), 2000); - } - Doc.SetInPlace(this.layoutDoc, "scrollToLinkID", undefined, false); - } - - }, - { fireImmediately: true } - ); - this._disposers.scroll = reaction(() => NumCast(this.props.Document.scrollPos), - pos => this._scrollRef.current && this._scrollRef.current.scrollTo({ top: pos }), { fireImmediately: true } - ); - - setTimeout(() => this.tryUpdateHeight(NumCast(this.layoutDoc.limitHeight, 0))); - } - - pushToGoogleDoc = async () => { - this.pullFromGoogleDoc(async (exportState: Opt, dataDoc: Doc) => { - const modes = GoogleApiClientUtils.Docs.WriteMode; - let mode = modes.Replace; - let reference: Opt = Cast(this.dataDoc[GoogleRef], "string"); - if (!reference) { - mode = modes.Insert; - reference = { title: StrCast(this.dataDoc.title) }; - } - const redo = async () => { - if (this._editorView && reference) { - const content = await RichTextUtils.GoogleDocs.Export(this._editorView.state); - const response = await GoogleApiClientUtils.Docs.write({ reference, content, mode }); - response && (this.dataDoc[GoogleRef] = response.documentId); - const pushSuccess = response !== undefined && !("errors" in response); - dataDoc.unchanged = pushSuccess; - DocumentButtonBar.Instance.startPushOutcome(pushSuccess); - } - }; - const undo = () => { - if (!exportState) { - return; - } - const content: GoogleApiClientUtils.Docs.Content = { - text: exportState.text, - requests: [] - }; - if (reference && content) { - GoogleApiClientUtils.Docs.write({ reference, content, mode }); - } - }; - UndoManager.AddEvent({ undo, redo }); - redo(); - }); - } - - pullFromGoogleDoc = async (handler: PullHandler) => { - const dataDoc = this.dataDoc; - const documentId = StrCast(dataDoc[GoogleRef]); - let exportState: Opt; - if (documentId) { - exportState = await RichTextUtils.GoogleDocs.Import(documentId, dataDoc); - } - UndoManager.RunInBatch(() => handler(exportState, dataDoc), Pulls); - } - - updateState = (exportState: Opt, dataDoc: Doc) => { - let pullSuccess = false; - if (exportState !== undefined) { - pullSuccess = true; - dataDoc.data = new RichTextField(JSON.stringify(exportState.state.toJSON())); - setTimeout(() => { - if (this._editorView) { - const state = this._editorView.state; - const end = state.doc.content.size - 1; - this._editorView.dispatch(state.tr.setSelection(TextSelection.create(state.doc, end, end))); - } - }, 0); - dataDoc.title = exportState.title; - this.rootDoc.customTitle = true; - dataDoc.unchanged = true; - } else { - delete dataDoc[GoogleRef]; - } - DocumentButtonBar.Instance.startPullOutcome(pullSuccess); - } - - checkState = (exportState: Opt, dataDoc: Doc) => { - if (exportState && this._editorView) { - const equalContent = isEqual(this._editorView.state.doc, exportState.state.doc); - const equalTitles = dataDoc.title === exportState.title; - const unchanged = equalContent && equalTitles; - dataDoc.unchanged = unchanged; - DocumentButtonBar.Instance.setPullState(unchanged); - } - } - - clipboardTextSerializer = (slice: Slice): string => { - let text = "", separated = true; - const from = 0, to = slice.content.size; - slice.content.nodesBetween(from, to, (node, pos) => { - if (node.isText) { - text += node.text!.slice(Math.max(from, pos) - pos, to - pos); - separated = false; - } else if (!separated && node.isBlock) { - text += "\n"; - separated = true; - } else if (node.type.name === "hard_break") { - text += "\n"; - } - }, 0); - return text; - } - - sliceSingleNode(slice: Slice) { - return slice.openStart === 0 && slice.openEnd === 0 && slice.content.childCount === 1 ? slice.content.firstChild : null; - } - - handlePaste = (view: EditorView, event: Event, slice: Slice): boolean => { - const cbe = event as ClipboardEvent; - const pdfDocId = cbe.clipboardData && cbe.clipboardData.getData("dash/pdfOrigin"); - const pdfRegionId = cbe.clipboardData && cbe.clipboardData.getData("dash/pdfRegion"); - if (pdfDocId && pdfRegionId) { - DocServer.GetRefField(pdfDocId).then(pdfDoc => { - DocServer.GetRefField(pdfRegionId).then(pdfRegion => { - if ((pdfDoc instanceof Doc) && (pdfRegion instanceof Doc)) { - setTimeout(async () => { - const targetField = Doc.LayoutFieldKey(pdfDoc); - const targetAnnotations = await DocListCastAsync(pdfDoc[DataSym][targetField + "-annotations"]);// bcz: better to have the PDF's view handle updating its own annotations - targetAnnotations?.push(pdfRegion); - }); - - const link = DocUtils.MakeLink({ doc: this.props.Document }, { doc: pdfRegion }, "PDF pasted"); - if (link) { - cbe.clipboardData!.setData("dash/linkDoc", link[Id]); - const linkId = link[Id]; - const frag = addMarkToFrag(slice.content, (node: Node) => addLinkMark(node, StrCast(pdfDoc.title), linkId)); - slice = new Slice(frag, slice.openStart, slice.openEnd); - const tr = view.state.tr.replaceSelection(slice); - view.dispatch(tr.scrollIntoView().setMeta("paste", true).setMeta("uiEvent", "paste")); - } - } - }); - }); - return true; - } - return false; - - - function addMarkToFrag(frag: Fragment, marker: (node: Node) => Node) { - const nodes: Node[] = []; - frag.forEach(node => nodes.push(marker(node))); - return Fragment.fromArray(nodes); - } - function addLinkMark(node: Node, title: string, linkId: string) { - if (!node.isText) { - const content = addMarkToFrag(node.content, (node: Node) => addLinkMark(node, title, linkId)); - return node.copy(content); - } - const marks = [...node.marks]; - const linkIndex = marks.findIndex(mark => mark.type.name === "link"); - const link = view.state.schema.mark(view.state.schema.marks.link, { href: `http://localhost:1050/doc/${linkId}`, location: "onRight", title: title, docref: true }); - marks.splice(linkIndex === -1 ? 0 : linkIndex, 1, link); - return node.mark(marks); - } - } - - private setupEditor(config: any, fieldKey: string) { - const curText = Cast(this.dataDoc[this.props.fieldKey], RichTextField, null); - const useTemplate = !curText?.Text && this.props.Document[this.props.fieldKey + "-textTemplate"]; - const rtfField = Cast((useTemplate && this.props.Document[this.props.fieldKey + "-textTemplate"]) || this.dataDoc[fieldKey], RichTextField); - if (this.ProseRef) { - const self = this; - this._editorView?.destroy(); - this._editorView = new EditorView(this.ProseRef, { - state: rtfField?.Data ? EditorState.fromJSON(config, JSON.parse(rtfField.Data)) : EditorState.create(config), - handleScrollToSelection: (editorView) => { - const docPos = editorView.coordsAtPos(editorView.state.selection.from); - const viewRect = self._ref.current!.getBoundingClientRect(); - if (docPos.top < viewRect.top || docPos.top > viewRect.bottom) { - docPos && (self._scrollRef.current!.scrollTop += (docPos.top - viewRect.top) * self.props.ScreenToLocalTransform().Scale); - } - return true; - }, - dispatchTransaction: this.dispatchTransaction, - nodeViews: { - //dashComment(node, view, getPos) { return new DashDocCommentView({ node, view, getPos }); }, - dashField(node, view, getPos) { return new DashFieldView(node, view, getPos, self); }, - //dashDoc(node, view, getPos) { return new DashDocView(node, view, getPos, self); }, - // dashDoc(node, view, getPos) { return new DashDocView({ node, view, getPos, self }); }, - - // image(node, view, getPos) { - // //const addDocTab = this.props.addDocTab; - // return new ImageResizeView({ node, view, getPos, addDocTab: this.props.addDocTab }); - // }, - - - // // WAS : - // //image(node, view, getPos) { return new ImageResizeView(node, view, getPos, this.props.addDocTab); }, - - // summary(node, view, getPos) { return new SummaryView({ node, view, getPos }); }, - // ordered_list(node, view, getPos) { return new OrderedListView(); }, - // footnote(node, view, getPos) { return new FootnoteView({ node, outerView, getPos }); } - }, - clipboardTextSerializer: this.clipboardTextSerializer, - handlePaste: this.handlePaste, - }); - const startupText = !rtfField && this._editorView && Field.toString(this.dataDoc[fieldKey] as Field); - if (startupText) { - const { state: { tr }, dispatch } = this._editorView; - dispatch(tr.insertText(startupText)); - } - } - - const selectOnLoad = this.rootDoc[Id] === FormattedTextBox.SelectOnLoad; - if (selectOnLoad && !this.props.dontRegisterView) { - FormattedTextBox.SelectOnLoad = ""; - this.props.select(false); - FormattedTextBox.SelectOnLoadChar && this._editorView!.dispatch(this._editorView!.state.tr.insertText(FormattedTextBox.SelectOnLoadChar)); - FormattedTextBox.SelectOnLoadChar = ""; - - } - (selectOnLoad /* || !rtfField?.Text*/) && this._editorView!.focus(); - // add user mark for any first character that was typed since the user mark that gets set in KeyPress won't have been called yet. - this._editorView!.state.storedMarks = [...(this._editorView!.state.storedMarks ? this._editorView!.state.storedMarks : []), schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.floor(Date.now() / 1000) })]; - } - getFont(font: string) { - switch (font) { - case "Arial": return schema.marks.arial.create(); - case "Times New Roman": return schema.marks.timesNewRoman.create(); - case "Georgia": return schema.marks.georgia.create(); - case "Comic Sans MS": return schema.marks.comicSans.create(); - case "Tahoma": return schema.marks.tahoma.create(); - case "Impact": return schema.marks.impact.create(); - case "ACrimson Textrial": return schema.marks.crimson.create(); - } - return schema.marks.arial.create(); - } - - componentWillUnmount() { - Object.values(this._disposers).forEach(disposer => disposer?.()); - this._editorView?.destroy(); - } - - static _downEvent: any; - _downX = 0; - _downY = 0; - _break = false; - onPointerDown = (e: React.PointerEvent): void => { - if (this._recording && !e.ctrlKey && e.button === 0) { - this.stopDictation(true); - this._break = true; - const state = this._editorView!.state; - const to = state.selection.to; - const updated = TextSelection.create(state.doc, to, to); - this._editorView!.dispatch(this._editorView!.state.tr.setSelection(updated).insertText("\n", to)); - e.preventDefault(); - e.stopPropagation(); - if (this._recording) setTimeout(() => this.recordDictation(), 500); - } - this._downX = e.clientX; - this._downY = e.clientY; - this.doLinkOnDeselect(); - FormattedTextBox._downEvent = true; - FormattedTextBoxComment.textBox = this; - if (this.props.onClick && e.button === 0 && !this.props.isSelected(false)) { - e.preventDefault(); - } - if (e.button === 0 && this.active(true) && !e.altKey && !e.ctrlKey && !e.metaKey) { - if (e.clientX < this.ProseRef!.getBoundingClientRect().right) { // don't stop propagation if clicking in the sidebar - e.stopPropagation(); - } - } - if (e.button === 2 || (e.button === 0 && e.ctrlKey)) { - e.preventDefault(); - } - } - - onPointerUp = (e: React.PointerEvent): void => { - if (!FormattedTextBox._downEvent) return; - FormattedTextBox._downEvent = false; - if (!(e.nativeEvent as any).formattedHandled) { - FormattedTextBoxComment.textBox = this; - FormattedTextBoxComment.update(this._editorView!); - } - (e.nativeEvent as any).formattedHandled = true; - - if (e.buttons === 1 && this.props.isSelected(true) && !e.altKey) { - e.stopPropagation(); - } - this._downX = this._downY = Number.NaN; - } - - @action - onFocused = (e: React.FocusEvent): void => { - FormattedTextBox.FocusedBox = this; - this.tryUpdateHeight(); - - // see if we need to preserve the insertion point - const prosediv = this.ProseRef?.children?.[0] as any; - const keeplocation = prosediv?.keeplocation; - prosediv && (prosediv.keeplocation = undefined); - const pos = this._editorView?.state.selection.$from.pos || 1; - keeplocation && setTimeout(() => this._editorView?.dispatch(this._editorView?.state.tr.setSelection(TextSelection.create(this._editorView.state.doc, pos)))); - const coords = !Number.isNaN(this._downX) ? { left: this._downX, top: this._downY, bottom: this._downY, right: this._downX } : this._editorView?.coordsAtPos(pos); - - // jump rich text menu to this textbox - const bounds = this._ref.current?.getBoundingClientRect(); - if (bounds && this.props.Document._chromeStatus !== "disabled") { - const x = Math.min(Math.max(bounds.left, 0), window.innerWidth - RichTextMenu.Instance.width); - let y = Math.min(Math.max(0, bounds.top - RichTextMenu.Instance.height - 50), window.innerHeight - RichTextMenu.Instance.height); - if (coords && coords.left > x && coords.left < x + RichTextMenu.Instance.width && coords.top > y && coords.top < y + RichTextMenu.Instance.height + 50) { - y = Math.min(bounds.bottom, window.innerHeight - RichTextMenu.Instance.height); - } - RichTextMenu.Instance.jumpTo(x, y); - } - } - onPointerWheel = (e: React.WheelEvent): void => { - // if a text note is not selected and scrollable, this prevents us from being able to scroll and zoom out at the same time - if (this.props.isSelected(true) || e.currentTarget.scrollHeight > e.currentTarget.clientHeight) { - e.stopPropagation(); - } - } - - static _bulletStyleSheet: any = addStyleSheet(); - static _userStyleSheet: any = addStyleSheet(); - - onClick = (e: React.MouseEvent): void => { - if ((this._editorView!.root as any).getSelection().isCollapsed) { // this is a hack to allow the cursor to be placed at the end of a document when the document ends in an inline dash comment. Apparently Chrome on Windows has a bug/feature which breaks this when clicking after the end of the text. - const pcords = this._editorView!.posAtCoords({ left: e.clientX, top: e.clientY }); - const node = pcords && this._editorView!.state.doc.nodeAt(pcords.pos); // get what prosemirror thinks the clicked node is (if it's null, then we didn't click on any text) - if (pcords && node?.type === this._editorView!.state.schema.nodes.dashComment) { - this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, pcords.pos + 2))); - e.preventDefault(); - } - if (!node && this.ProseRef) { - const lastNode = this.ProseRef.children[this.ProseRef.children.length - 1].children[this.ProseRef.children[this.ProseRef.children.length - 1].children.length - 1]; // get the last prosemirror div - if (e.clientY > lastNode?.getBoundingClientRect().bottom) { // if we clicked below the last prosemirror div, then set the selection to be the end of the document - this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, this._editorView!.state.doc.content.size))); - } - } - } - if ((e.nativeEvent as any).formattedHandled) { e.stopPropagation(); return; } - (e.nativeEvent as any).formattedHandled = true; - // if (e.button === 0 && ((!this.props.isSelected(true) && !e.ctrlKey) || (this.props.isSelected(true) && e.ctrlKey)) && !e.metaKey && e.target) { - // let href = (e.target as any).href; - // let location: string; - // if ((e.target as any).attributes.location) { - // location = (e.target as any).attributes.location.value; - // } - // let pcords = this._editorView!.posAtCoords({ left: e.clientX, top: e.clientY }); - // let node = pcords && this._editorView!.state.doc.nodeAt(pcords.pos); - // if (node) { - // let link = node.marks.find(m => m.type === this._editorView!.state.schema.marks.link); - // if (link && !(link.attrs.docref && link.attrs.title)) { // bcz: getting hacky. this indicates that we clicked on a PDF excerpt quotation. In this case, we don't want to follow the link (we follow only the actual hyperlink for the quotation which is handled above). - // href = link && link.attrs.href; - // location = link && link.attrs.location; - // } - // } - // if (href) { - // if (href.indexOf(Utils.prepend("/doc/")) === 0) { - // let linkClicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0]; - // if (linkClicked) { - // DocServer.GetRefField(linkClicked).then(async linkDoc => { - // (linkDoc instanceof Doc) && - // DocumentManager.Instance.FollowLink(linkDoc, this.props.Document, document => this.props.addDocTab(document, location ? location : "inTab"), false); - // }); - // } - // } else { - // let webDoc = Docs.Create.WebDocument(href, { x: NumCast(this.layoutDoc.x, 0) + NumCast(this.layoutDoc.width, 0), y: NumCast(this.layoutDoc.y) }); - // this.props.addDocument && this.props.addDocument(webDoc); - // } - // e.stopPropagation(); - // e.preventDefault(); - // } - // } - - if (Math.abs(e.clientX - this._downX) < 4 && Math.abs(e.clientX - this._downX) < 4) { - this.props.select(e.ctrlKey); - this.hitBulletTargets(e.clientX, e.clientY, e.shiftKey, false); - } - } - - // this hackiness handles clicking on the list item bullets to do expand/collapse. the bullets are ::before pseudo elements so there's no real way to hit test against them. - hitBulletTargets(x: number, y: number, select: boolean, highlightOnly: boolean) { - clearStyleSheetRules(FormattedTextBox._bulletStyleSheet); - const pos = this._editorView!.posAtCoords({ left: x, top: y }); - if (pos && this.props.isSelected(true)) { - // let beforeEle = document.querySelector("." + hit.className) as Element; // const before = hit ? window.getComputedStyle(hit, ':before') : undefined; - //const node = this._editorView!.state.doc.nodeAt(pos.pos); - const $pos = this._editorView!.state.doc.resolve(pos.pos); - let list_node = $pos.node().type === schema.nodes.list_item ? $pos.node() : undefined; - if ($pos.node().type === schema.nodes.ordered_list) { - for (let off = 1; off < 100; off++) { - const pos = this._editorView!.posAtCoords({ left: x + off, top: y }); - const node = pos && this._editorView!.state.doc.nodeAt(pos.pos); - if (node?.type === schema.nodes.list_item) { - list_node = node; - break; - } - } - } - if (list_node && pos.inside >= 0 && this._editorView!.state.doc.nodeAt(pos.inside)!.attrs.bulletStyle === list_node.attrs.bulletStyle) { - if (select) { - const $olist_pos = this._editorView!.state.doc.resolve($pos.pos - $pos.parentOffset - 1); - if (!highlightOnly) { - this._editorView!.dispatch(this._editorView!.state.tr.setSelection(new NodeSelection($olist_pos))); - } - addStyleSheetRule(FormattedTextBox._bulletStyleSheet, list_node.attrs.mapStyle + list_node.attrs.bulletStyle + ":hover:before", { background: "lightgray" }); - } else if (Math.abs(pos.pos - pos.inside) < 2) { - if (!highlightOnly) { - const offset = this._editorView!.state.doc.nodeAt(pos.inside)?.type === schema.nodes.ordered_list ? 1 : 0; - this._editorView!.dispatch(this._editorView!.state.tr.setNodeMarkup(pos.inside + offset, list_node.type, { ...list_node.attrs, visibility: !list_node.attrs.visibility })); - this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, pos.inside + offset))); - } - addStyleSheetRule(FormattedTextBox._bulletStyleSheet, list_node.attrs.mapStyle + list_node.attrs.bulletStyle + ":hover:before", { background: "lightgray" }); - } - } - } - } - onMouseUp = (e: React.MouseEvent): void => { - e.stopPropagation(); - - const view = this._editorView as any; - // this interposes on prosemirror's upHandler to prevent prosemirror's up from invoked multiple times when there - // are nested prosemirrors. We only want the lowest level prosemirror to be invoked. - if (view.mouseDown) { - const originalUpHandler = view.mouseDown.up; - view.root.removeEventListener("mouseup", originalUpHandler); - view.mouseDown.up = (e: MouseEvent) => { - !(e as any).formattedHandled && originalUpHandler(e); - // e.stopPropagation(); - (e as any).formattedHandled = true; - }; - view.root.addEventListener("mouseup", view.mouseDown.up); - } - } - - richTextMenuPlugin() { - return new Plugin({ - view(newView) { - RichTextMenu.Instance && RichTextMenu.Instance.changeView(newView); - return RichTextMenu.Instance; - } - }); - } - - public static HadSelection: boolean = false; - onBlur = (e: any) => { - FormattedTextBox.HadSelection = window.getSelection()?.toString() !== ""; - //DictationManager.Controls.stop(false); - if (this._undoTyping) { - this._undoTyping.end(); - this._undoTyping = undefined; - } - this.doLinkOnDeselect(); - - // move the richtextmenu offscreen - if (!RichTextMenu.Instance.Pinned && !RichTextMenu.Instance.overMenu) RichTextMenu.Instance.jumpTo(-300, -300); - } - - _lastTimedMark: Mark | undefined = undefined; - onKeyPress = (e: React.KeyboardEvent) => { - if (e.altKey) { - e.preventDefault(); - return; - } - const state = this._editorView!.state; - if (!state.selection.empty && e.key === "%") { - this._rules!.EnteringStyle = true; - e.preventDefault(); - e.stopPropagation(); - return; - } - - if (state.selection.empty || !this._rules!.EnteringStyle) { - this._rules!.EnteringStyle = false; - } - if (e.key === "Escape") { - this._editorView!.dispatch(state.tr.setSelection(TextSelection.create(state.doc, state.selection.from, state.selection.from))); - (document.activeElement as any).blur?.(); - SelectionManager.DeselectAll(); - } - e.stopPropagation(); - if (e.key === "Tab" || e.key === "Enter") { - e.preventDefault(); - } - const mark = e.key !== " " && this._lastTimedMark ? this._lastTimedMark : schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.floor(Date.now() / 1000) }); - this._lastTimedMark = mark; - this._editorView!.dispatch(this._editorView!.state.tr.removeStoredMark(schema.marks.user_mark.create({})).addStoredMark(mark)); - - if (!this._undoTyping) { - this._undoTyping = UndoManager.StartBatch("undoTyping"); - } - } - - onscrolled = (ev: React.UIEvent) => { - this.props.Document.scrollPos = this._scrollRef.current!.scrollTop; - } - @action - tryUpdateHeight(limitHeight?: number) { - let scrollHeight = this._ref.current?.scrollHeight; - if (this.layoutDoc._autoHeight && scrollHeight && - getComputedStyle(this._ref.current!.parentElement!).top === "0px") { // if top === 0, then the text box is growing upward (as the overlay caption) which doesn't contribute to the height computation - if (limitHeight && scrollHeight > limitHeight) { - scrollHeight = limitHeight; - this.layoutDoc.limitHeight = undefined; - this.layoutDoc._autoHeight = false; - } - const nh = this.layoutDoc.isTemplateForField ? 0 : NumCast(this.dataDoc._nativeHeight, 0); - const dh = NumCast(this.layoutDoc._height, 0); - const newHeight = Math.max(10, (nh ? dh / nh * scrollHeight : scrollHeight) + (this.props.ChromeHeight ? this.props.ChromeHeight() : 0)); - if (Math.abs(newHeight - dh) > 1) { // bcz: Argh! without this, we get into a React crash if the same document is opened in a freeform view and in the treeview. no idea why, but after dragging the freeform document, selecting it, and selecting text, it will compute to 1 pixel higher than the treeview which causes a cycle - this.layoutDoc._height = newHeight; - this.dataDoc._nativeHeight = nh ? scrollHeight : undefined; - } - } - } - - @computed get sidebarWidthPercent() { return StrCast(this.props.Document.sidebarWidthPercent, "0%"); } - sidebarWidth = () => Number(this.sidebarWidthPercent.substring(0, this.sidebarWidthPercent.length - 1)) / 100 * this.props.PanelWidth(); - sidebarScreenToLocal = () => this.props.ScreenToLocalTransform().translate(-(this.props.PanelWidth() - this.sidebarWidth()), 0); - @computed get sidebarColor() { return StrCast(this.layoutDoc[this.props.fieldKey + "-backgroundColor"], StrCast(this.layoutDoc[this.props.fieldKey + "-backgroundColor"], "transparent")); } - render() { - TraceMobx(); - const rounded = StrCast(this.layoutDoc.borderRounding) === "100%" ? "-rounded" : ""; - const interactive = InkingControl.Instance.selectedTool || this.layoutDoc.isBackground; - if (this.props.isSelected()) { - this._editorView && RichTextMenu.Instance.updateFromDash(this._editorView, undefined, this.props); - } else if (FormattedTextBoxComment.textBox === this) { - FormattedTextBoxComment.Hide(); - } - return ( - -
this.hitBulletTargets(e.clientX, e.clientY, e.shiftKey, true)} - onBlur={this.onBlur} - onPointerUp={this.onPointerUp} - onPointerDown={this.onPointerDown} - onMouseUp={this.onMouseUp} - onWheel={this.onPointerWheel} - onPointerEnter={action(() => this._entered = true)} - onPointerLeave={action((e: React.PointerEvent) => { - this._entered = false; - const target = document.elementFromPoint(e.nativeEvent.x, e.nativeEvent.y); - for (let child: any = target; child; child = child?.parentElement) { - if (child === this._ref.current!) { - this._entered = true; - } - } - })} - > -
-
-
- {!this.props.Document._showSidebar ? (null) : this.sidebarWidthPercent === "0%" ? -
this.toggleSidebar()} /> : -
- - -
this.toggleSidebar()} /> -
} - {!this.props.Document._showAudio ? (null) : -
{ - runInAction(() => this._recording = !this._recording); - setTimeout(() => this._editorView!.focus(), 500); - e.stopPropagation(); - }} > - -
} -
- ); - } -} diff --git a/src/client/views/nodes/FormattedTextBoxComment.scss b/src/client/views/nodes/FormattedTextBoxComment.scss deleted file mode 100644 index 2dd63ec21..000000000 --- a/src/client/views/nodes/FormattedTextBoxComment.scss +++ /dev/null @@ -1,33 +0,0 @@ -.FormattedTextBox-tooltip { - position: absolute; - pointer-events: none; - z-index: 20; - background: white; - border: 1px solid silver; - border-radius: 2px; - margin-bottom: 7px; - -webkit-transform: translateX(-50%); - transform: translateX(-50%); - } - .FormattedTextBox-tooltip:before { - content: ""; - height: 0; width: 0; - position: absolute; - left: 50%; - margin-left: -5px; - bottom: -6px; - border: 5px solid transparent; - border-bottom-width: 0; - border-top-color: silver; - } - .FormattedTextBox-tooltip:after { - content: ""; - height: 0; width: 0; - position: absolute; - left: 50%; - margin-left: -5px; - bottom: -4.5px; - border: 5px solid transparent; - border-bottom-width: 0; - border-top-color: white; - } \ No newline at end of file diff --git a/src/client/views/nodes/FormattedTextBoxComment.tsx b/src/client/views/nodes/FormattedTextBoxComment.tsx deleted file mode 100644 index dfea0f6bb..000000000 --- a/src/client/views/nodes/FormattedTextBoxComment.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { Mark, ResolvedPos } from "prosemirror-model"; -import { EditorState, Plugin } from "prosemirror-state"; -import { EditorView } from "prosemirror-view"; -import * as ReactDOM from 'react-dom'; -import { Doc, DocCastAsync } from "../../../new_fields/Doc"; -import { Cast, FieldValue, NumCast } from "../../../new_fields/Types"; -import { emptyFunction, returnEmptyString, returnFalse, Utils, emptyPath } from "../../../Utils"; -import { DocServer } from "../../DocServer"; -import { DocumentManager } from "../../util/DocumentManager"; -import { schema } from "../../util/schema_rts"; -import { Transform } from "../../util/Transform"; -import { ContentFittingDocumentView } from "./ContentFittingDocumentView"; -import { FormattedTextBox } from "./FormattedTextBox"; -import './FormattedTextBoxComment.scss'; -import React = require("react"); -import { Docs } from "../../documents/Documents"; -import wiki from "wikijs"; -import { DocumentType } from "../../documents/DocumentTypes"; - -export let formattedTextBoxCommentPlugin = new Plugin({ - view(editorView) { return new FormattedTextBoxComment(editorView); } -}); -export function findOtherUserMark(marks: Mark[]): Mark | undefined { - return marks.find(m => m.attrs.userid && m.attrs.userid !== Doc.CurrentUserEmail); -} -export function findUserMark(marks: Mark[]): Mark | undefined { - return marks.find(m => m.attrs.userid); -} -export function findLinkMark(marks: Mark[]): Mark | undefined { - return marks.find(m => m.type === schema.marks.link); -} -export function findStartOfMark(rpos: ResolvedPos, view: EditorView, finder: (marks: Mark[]) => Mark | undefined) { - let before = 0; - let nbef = rpos.nodeBefore; - while (nbef && finder(nbef.marks)) { - before += nbef.nodeSize; - rpos = view.state.doc.resolve(rpos.pos - nbef.nodeSize); - rpos && (nbef = rpos.nodeBefore); - } - return before; -} -export function findEndOfMark(rpos: ResolvedPos, view: EditorView, finder: (marks: Mark[]) => Mark | undefined) { - let after = 0; - let naft = rpos.nodeAfter; - while (naft && finder(naft.marks)) { - after += naft.nodeSize; - rpos = view.state.doc.resolve(rpos.pos + naft.nodeSize); - rpos && (naft = rpos.nodeAfter); - } - return after; -} - - -export class FormattedTextBoxComment { - static tooltip: HTMLElement; - static tooltipText: HTMLElement; - static tooltipInput: HTMLInputElement; - static start: number; - static end: number; - static mark: Mark; - static textBox: FormattedTextBox | undefined; - static linkDoc: Doc | undefined; - constructor(view: any) { - if (!FormattedTextBoxComment.tooltip) { - const root = document.getElementById("root"); - FormattedTextBoxComment.tooltipInput = document.createElement("input"); - FormattedTextBoxComment.tooltipInput.type = "checkbox"; - FormattedTextBoxComment.tooltip = document.createElement("div"); - FormattedTextBoxComment.tooltipText = document.createElement("div"); - FormattedTextBoxComment.tooltipText.style.width = "100%"; - FormattedTextBoxComment.tooltipText.style.height = "100%"; - FormattedTextBoxComment.tooltipText.style.textOverflow = "ellipsis"; - FormattedTextBoxComment.tooltip.appendChild(FormattedTextBoxComment.tooltipText); - FormattedTextBoxComment.tooltip.className = "FormattedTextBox-tooltip"; - FormattedTextBoxComment.tooltip.style.pointerEvents = "all"; - FormattedTextBoxComment.tooltip.style.maxWidth = "350px"; - FormattedTextBoxComment.tooltip.style.maxHeight = "250px"; - FormattedTextBoxComment.tooltip.style.width = "100%"; - FormattedTextBoxComment.tooltip.style.height = "100%"; - FormattedTextBoxComment.tooltip.style.overflow = "hidden"; - FormattedTextBoxComment.tooltip.style.display = "none"; - FormattedTextBoxComment.tooltip.appendChild(FormattedTextBoxComment.tooltipInput); - FormattedTextBoxComment.tooltip.onpointerdown = (e: PointerEvent) => { - const keep = e.target && (e.target as any).type === "checkbox" ? true : false; - const textBox = FormattedTextBoxComment.textBox; - if (FormattedTextBoxComment.linkDoc && !keep && textBox) { - if (FormattedTextBoxComment.linkDoc.type !== DocumentType.LINK) { - textBox.props.addDocTab(FormattedTextBoxComment.linkDoc, e.ctrlKey ? "inTab" : "onRight"); - } else { - DocumentManager.Instance.FollowLink(FormattedTextBoxComment.linkDoc, textBox.props.Document, - (doc: Doc, followLinkLocation: string) => textBox.props.addDocTab(doc, e.ctrlKey ? "inTab" : followLinkLocation)); - } - } else if (textBox && (FormattedTextBoxComment.tooltipText as any).href) { - textBox.props.addDocTab(Docs.Create.WebDocument((FormattedTextBoxComment.tooltipText as any).href, { title: (FormattedTextBoxComment.tooltipText as any).href, _width: 200, _height: 400 }), "onRight"); - } - keep && textBox && FormattedTextBoxComment.start !== undefined && textBox.adoptAnnotation( - FormattedTextBoxComment.start, FormattedTextBoxComment.end, FormattedTextBoxComment.mark); - e.stopPropagation(); - e.preventDefault(); - }; - root && root.appendChild(FormattedTextBoxComment.tooltip); - } - } - - public static Hide() { - FormattedTextBoxComment.textBox = undefined; - FormattedTextBoxComment.tooltip && (FormattedTextBoxComment.tooltip.style.display = "none"); - ReactDOM.unmountComponentAtNode(FormattedTextBoxComment.tooltipText); - } - public static SetState(textBox: any, start: number, end: number, mark: Mark) { - FormattedTextBoxComment.textBox = textBox; - FormattedTextBoxComment.start = start; - FormattedTextBoxComment.end = end; - FormattedTextBoxComment.mark = mark; - FormattedTextBoxComment.tooltip && (FormattedTextBoxComment.tooltip.style.display = ""); - } - - static update(view: EditorView, lastState?: EditorState) { - const 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; - } - FormattedTextBoxComment.linkDoc = undefined; - - const textBox = FormattedTextBoxComment.textBox; - if (!textBox || !textBox.props) { - return; - } - let set = "none"; - let nbef = 0; - FormattedTextBoxComment.tooltipInput.style.display = "none"; - FormattedTextBoxComment.tooltip.style.width = ""; - FormattedTextBoxComment.tooltip.style.height = ""; - (FormattedTextBoxComment.tooltipText as any).href = ""; - FormattedTextBoxComment.tooltipText.style.whiteSpace = ""; - FormattedTextBoxComment.tooltipText.style.overflow = ""; - // this section checks to see if the insertion point is over text entered by a different user. If so, it sets ths comment text to indicate the user and the modification date - if (state.selection.$from) { - nbef = findStartOfMark(state.selection.$from, view, findOtherUserMark); - const naft = findEndOfMark(state.selection.$from, view, findOtherUserMark); - const noselection = view.state.selection.$from === view.state.selection.$to; - let child: any = null; - state.doc.nodesBetween(state.selection.from, state.selection.to, (node: any, pos: number, parent: any) => !child && node.marks.length && (child = node)); - const mark = child && findOtherUserMark(child.marks); - if (mark && child && (nbef || naft) && (!mark.attrs.opened || noselection)) { - FormattedTextBoxComment.SetState(FormattedTextBoxComment.textBox, state.selection.$from.pos - nbef, state.selection.$from.pos + naft, mark); - } - if (mark && child && ((nbef && naft) || !noselection)) { - FormattedTextBoxComment.tooltipText.textContent = mark.attrs.userid + " date=" + (new Date(mark.attrs.modified * 5000)).toDateString(); - set = ""; - FormattedTextBoxComment.tooltipInput.style.display = ""; - } - } - // this checks if the selection is a hyperlink. If so, it displays the target doc's text for internal links, and the url of the target for external links. - if (set === "none" && state.selection.$from) { - nbef = findStartOfMark(state.selection.$from, view, findLinkMark); - const naft = findEndOfMark(state.selection.$from, view, findLinkMark); - let child: any = null; - state.doc.nodesBetween(state.selection.from, state.selection.to, (node: any, pos: number, parent: any) => !child && node.marks.length && (child = node)); - const mark = child && findLinkMark(child.marks); - if (mark && child && nbef && naft && mark.attrs.showPreview) { - FormattedTextBoxComment.tooltipText.textContent = "external => " + mark.attrs.href; - (FormattedTextBoxComment.tooltipText as any).href = mark.attrs.href; - if (mark.attrs.href.startsWith("https://en.wikipedia.org/wiki/")) { - wiki().page(mark.attrs.href.replace("https://en.wikipedia.org/wiki/", "")).then(page => page.summary().then(summary => FormattedTextBoxComment.tooltipText.textContent = summary.substring(0, 500))); - } else { - FormattedTextBoxComment.tooltipText.style.whiteSpace = "pre"; - FormattedTextBoxComment.tooltipText.style.overflow = "hidden"; - } - if (mark.attrs.href.indexOf(Utils.prepend("/doc/")) === 0) { - FormattedTextBoxComment.tooltipText.textContent = "target not found..."; - (FormattedTextBoxComment.tooltipText as any).href = ""; - const docTarget = mark.attrs.href.replace(Utils.prepend("/doc/"), "").split("?")[0]; - try { - ReactDOM.unmountComponentAtNode(FormattedTextBoxComment.tooltipText); - } catch (e) { } - docTarget && DocServer.GetRefField(docTarget).then(async linkDoc => { - if (linkDoc instanceof Doc) { - (FormattedTextBoxComment.tooltipText as any).href = mark.attrs.href; - FormattedTextBoxComment.linkDoc = linkDoc; - const anchor = FieldValue(Doc.AreProtosEqual(FieldValue(Cast(linkDoc.anchor1, Doc)), textBox.dataDoc) ? Cast(linkDoc.anchor2, Doc) : (Cast(linkDoc.anchor1, Doc)) || linkDoc); - const target = anchor?.annotationOn ? await DocCastAsync(anchor.annotationOn) : anchor; - if (anchor !== target && anchor && target) { - target.scrollY = NumCast(anchor?.y); - } - if (target) { - ReactDOM.render( Math.min(350, NumCast(target._width, 350))} - PanelHeight={() => Math.min(250, NumCast(target._height, 250))} - focus={emptyFunction} - whenActiveChanged={returnFalse} - />, FormattedTextBoxComment.tooltipText); - FormattedTextBoxComment.tooltip.style.width = NumCast(target.width) ? `${NumCast(target.width)}` : "100%"; - FormattedTextBoxComment.tooltip.style.height = NumCast(target.height) ? `${NumCast(target.height)}` : "100%"; - } - // let ext = (target && target.type !== DocumentType.PDFANNO && Doc.fieldExtensionDoc(target, "data")) || target; // try guessing that the target doc's data is in the 'data' field. probably need an 'overviewLayout' and then just display the target Document .... - // let text = ext && StrCast(ext.text); - // ext && (FormattedTextBoxComment.tooltipText.textContent = (target && target.type === DocumentType.PDFANNO ? "Quoted from " : "") + "=> " + (text || StrCast(ext.title))); - } - }); - } - set = ""; - } - } - if (set !== "none") { - // These are in screen coordinates - // let start = view.coordsAtPos(state.selection.from), end = view.coordsAtPos(state.selection.to); - const start = view.coordsAtPos(state.selection.from - nbef), end = view.coordsAtPos(state.selection.from - nbef); - // The box in which the tooltip is positioned, to use as base - const box = (document.getElementsByClassName("mainView-container") as any)[0].getBoundingClientRect(); - // Find a center-ish x position from the selection endpoints (when - // crossing lines, end may be more to the left) - const left = Math.max((start.left + end.left) / 2, start.left + 3); - FormattedTextBoxComment.tooltip.style.left = (left - box.left) + "px"; - FormattedTextBoxComment.tooltip.style.bottom = (box.bottom - start.top) + "px"; - } - FormattedTextBoxComment.tooltip && (FormattedTextBoxComment.tooltip.style.display = set); - } - - destroy() { } -} diff --git a/src/client/views/nodes/formattedText/DashDocCommentView.tsx b/src/client/views/nodes/formattedText/DashDocCommentView.tsx new file mode 100644 index 000000000..d94fe7fc6 --- /dev/null +++ b/src/client/views/nodes/formattedText/DashDocCommentView.tsx @@ -0,0 +1,95 @@ +import { IReactionDisposer, observable, reaction, runInAction } from "mobx"; +import { baseKeymap, toggleMark } from "prosemirror-commands"; +import { redo, undo } from "prosemirror-history"; +import { keymap } from "prosemirror-keymap"; +import { DOMOutputSpecArray, Fragment, MarkSpec, Node, NodeSpec, Schema, Slice } from "prosemirror-model"; +import { bulletList, listItem, orderedList } from 'prosemirror-schema-list'; +import { EditorState, NodeSelection, Plugin, TextSelection } from "prosemirror-state"; +import { StepMap } from "prosemirror-transform"; +import { EditorView } from "prosemirror-view"; +import * as ReactDOM from 'react-dom'; +import { Doc, DocListCast, Field, HeightSym, WidthSym } from "../../../../new_fields/Doc"; +import { Id } from "../../../../new_fields/FieldSymbols"; +import { List } from "../../../../new_fields/List"; +import { ObjectField } from "../../../../new_fields/ObjectField"; +import { listSpec } from "../../../../new_fields/Schema"; +import { SchemaHeaderField } from "../../../../new_fields/SchemaHeaderField"; +import { ComputedField } from "../../../../new_fields/ScriptField"; +import { BoolCast, Cast, NumCast, StrCast } from "../../../../new_fields/Types"; +import { emptyFunction, returnEmptyString, returnFalse, returnOne, Utils, returnZero } from "../../../../Utils"; +import { DocServer } from "../../../DocServer"; + +import React = require("react"); + +import { schema } from "./schema_rts"; + +interface IDashDocCommentView { + node: any; + view: any; + getPos: any; +} + +export class DashDocCommentView extends React.Component{ + constructor(props: IDashDocCommentView) { + super(props); + } + + targetNode = () => { // search forward in the prosemirror doc for the attached dashDocNode that is the target of the comment anchor + for (let i = this.props.getPos() + 1; i < this.props.view.state.doc.content.size; i++) { + const m = this.props.view.state.doc.nodeAt(i); + if (m && m.type === this.props.view.state.schema.nodes.dashDoc && m.attrs.docid === this.props.node.attrs.docid) { + return { node: m, pos: i, hidden: m.attrs.hidden } as { node: any, pos: number, hidden: boolean }; + } + } + const dashDoc = this.props.view.state.schema.nodes.dashDoc.create({ width: 75, height: 35, title: "dashDoc", docid: this.props.node.attrs.docid, float: "right" }); + this.props.view.dispatch(this.props.view.state.tr.insert(this.props.getPos() + 1, dashDoc)); + setTimeout(() => { try { this.props.view.dispatch(this.props.view.state.tr.setSelection(TextSelection.create(this.props.view.state.tr.doc, this.props.getPos() + 2))); } catch (e) { } }, 0); + return undefined; + } + + onPointerDownCollapse = (e: any) => e.stopPropagation(); + + onPointerUpCollapse = (e: any) => { + const target = this.targetNode(); + if (target) { + const expand = target.hidden; + const tr = this.props.view.state.tr.setNodeMarkup(target.pos, undefined, { ...target.node.attrs, hidden: target.node.attrs.hidden ? false : true }); + this.props.view.dispatch(tr.setSelection(TextSelection.create(tr.doc, this.props.getPos() + (expand ? 2 : 1)))); // update the attrs + setTimeout(() => { + expand && DocServer.GetRefField(this.props.node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc)); + try { this.props.view.dispatch(this.props.view.state.tr.setSelection(TextSelection.create(this.props.view.state.tr.doc, this.props.getPos() + (expand ? 2 : 1)))); } catch (e) { } + }, 0); + } + e.stopPropagation(); + } + + onPointerEnterCollapse = (e: any) => { + DocServer.GetRefField(this.props.node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc, false)); + e.preventDefault(); + e.stopPropagation(); + } + + onPointerLeaveCollapse = (e: any) => { + DocServer.GetRefField(this.props.node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowUnhighlight()); + e.preventDefault(); + e.stopPropagation(); + } + + render() { + + const collapsedId = "DashDocCommentView-" + this.props.node.attrs.docid; + + return ( + + + + ); + } +} \ No newline at end of file diff --git a/src/client/views/nodes/formattedText/DashDocView.tsx b/src/client/views/nodes/formattedText/DashDocView.tsx new file mode 100644 index 000000000..9fe8fa320 --- /dev/null +++ b/src/client/views/nodes/formattedText/DashDocView.tsx @@ -0,0 +1,269 @@ +import { IReactionDisposer, reaction } from "mobx"; +import { NodeSelection } from "prosemirror-state"; +import { Doc, HeightSym, WidthSym } from "../../../../new_fields/Doc"; +import { Id } from "../../../../new_fields/FieldSymbols"; +import { ObjectField } from "../../../../new_fields/ObjectField"; +import { ComputedField } from "../../../../new_fields/ScriptField"; +import { BoolCast, Cast, NumCast, StrCast } from "../../../../new_fields/Types"; +import { emptyFunction, returnEmptyString, returnFalse, Utils, returnZero } from "../../../../Utils"; +import { DocServer } from "../../../DocServer"; +import { Docs } from "../../../documents/Documents"; +import { DocumentView } from "../DocumentView"; +import { FormattedTextBox } from "./FormattedTextBox"; +import { Transform } from "../../../util/Transform"; +import React = require("react"); + +interface IDashDocView { + node: any; + view: any; + getPos: any; + tbox?: FormattedTextBox; + self: any; +} + +export class DashDocView extends React.Component { + + _dashDoc: Doc | undefined; + _reactionDisposer: IReactionDisposer | undefined; + _renderDisposer: IReactionDisposer | undefined; + _textBox: FormattedTextBox; + _finalLayout: any; + _resolvedDataDoc: any; + + + // constructor(node: any, view: any, getPos: any, tbox: FormattedTextBox) { + + constructor(props: IDashDocView) { + super(props); + + const node = this.props.node; + this._textBox = this.props.tbox as FormattedTextBox; + + const alias = node.attrs.alias; + const docid = node.attrs.docid || this._textBox.props.Document[Id]; + + DocServer.GetRefField(docid + alias).then(async dashDoc => { + if (!(dashDoc instanceof Doc)) { + alias && DocServer.GetRefField(docid).then(async dashDocBase => { + if (dashDocBase instanceof Doc) { + const aliasedDoc = Doc.MakeAlias(dashDocBase, docid + alias); + aliasedDoc.layoutKey = "layout"; + node.attrs.fieldKey && DocumentView.makeCustomViewClicked(aliasedDoc, Docs.Create.StackingDocument, node.attrs.fieldKey, undefined); + this._dashDoc = aliasedDoc; + // self.doRender(aliasedDoc, removeDoc, node, view, getPos); + } + }); + } else { + this._dashDoc = dashDoc; + // self.doRender(dashDoc, removeDoc, node, view, getPos); + } + }); + + this.onPointerLeave = this.onPointerLeave.bind(this); + this.onPointerEnter = this.onPointerEnter.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onKeyPress = this.onKeyPress.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + this.onWheel = this.onWheel.bind(this); + } + /* #region Internal functions */ + + removeDoc = () => { + const view = this.props.view; + const pos = this.props.getPos(); + const ns = new NodeSelection(view.state.doc.resolve(pos)); + view.dispatch(view.state.tr.setSelection(ns).deleteSelection()); + return true; + } + + getDocTransform = () => { + const outerElement = document.getElementById('dash-document-view-outer') as HTMLElement; + const { scale, translateX, translateY } = Utils.GetScreenTransform(outerElement); + return new Transform(-translateX, -translateY, 1).scale(1 / this.contentScaling() / scale); + } + contentScaling = () => NumCast(this._dashDoc!._nativeWidth) > 0 ? this._dashDoc![WidthSym]() / NumCast(this._dashDoc!._nativeWidth) : 1; + + outerFocus = (target: Doc) => this._textBox.props.focus(this._textBox.props.Document); // ideally, this would scroll to show the focus target + + onKeyPress = (e: any) => { + e.stopPropagation(); + } + onWheel = (e: any) => { + e.preventDefault(); + } + onKeyUp = (e: any) => { + e.stopPropagation(); + } + onKeyDown = (e: any) => { + e.stopPropagation(); + if (e.key === "Tab" || e.key === "Enter") { + e.preventDefault(); + } + } + onPointerLeave = () => { + const ele = document.getElementById("DashDocCommentView-" + this.props.node.attrs.docid); + if (ele) { + (ele as HTMLDivElement).style.backgroundColor = ""; + } + } + onPointerEnter = () => { + const ele = document.getElementById("DashDocCommentView-" + this.props.node.attrs.docid); + if (ele) { + (ele as HTMLDivElement).style.backgroundColor = "orange"; + } + } + /*endregion*/ + + componentWillMount = () => { + this._reactionDisposer?.(); + } + + componentDidUpdate = () => { + + this._renderDisposer?.(); + this._renderDisposer = reaction(() => { + + const dashDoc = this._dashDoc as Doc; + const dashLayoutDoc = Doc.Layout(dashDoc); + const finalLayout = this.props.node.attrs.docid ? dashDoc : Doc.expandTemplateLayout(dashLayoutDoc, dashDoc, this.props.node.attrs.fieldKey); + + if (finalLayout) { + if (!Doc.AreProtosEqual(finalLayout, dashDoc)) { + finalLayout.rootDocument = dashDoc.aliasOf; + } + const layoutKey = StrCast(finalLayout.layoutKey); + const finalKey = layoutKey && StrCast(finalLayout[layoutKey]).split("'")?.[1]; + if (finalLayout !== dashDoc && finalKey) { + const finalLayoutField = finalLayout[finalKey]; + if (finalLayoutField instanceof ObjectField) { + finalLayout[finalKey + "-textTemplate"] = ComputedField.MakeFunction(`copyField(this.${finalKey})`, { this: Doc.name }); + } + } + this._finalLayout = finalLayout; + this._resolvedDataDoc = Cast(finalLayout.resolvedDataDoc, Doc, null); + return { finalLayout, resolvedDataDoc: Cast(finalLayout.resolvedDataDoc, Doc, null) }; + } + }, + (res) => { + + if (res) { + this._finalLayout = res.finalLayout; + this._resolvedDataDoc = res.resolvedDataDoc; + + this.forceUpdate(); // doReactRender(res.finalLayout, res.resolvedDataDoc), + } + }, + { fireImmediately: true }); + + } + + render() { + // doRender(dashDoc: Doc, removeDoc: any, node: any, view: any, getPos: any) { + + const node = this.props.node; + const view = this.props.view; + const getPos = this.props.getPos; + + const spanStyle = { + width: this.props.node.props.width, + height: this.props.node.props.height, + position: 'absolute' as 'absolute', + display: 'inline-block' + }; + + + const outerStyle = { + position: "relative" as "relative", + textIndent: "0", + border: "1px solid " + StrCast(this._textBox.Document.color, (Cast(Doc.UserDoc().activeWorkspace, Doc, null).darkScheme ? "dimGray" : "lightGray")), + width: this.props.node.props.width, + height: this.props.node.props.height, + display: this.props.node.props.hidden ? "none" : "inline-block", + float: this.props.node.props.float, + }; + + const dashDoc = this._dashDoc as Doc; + const self = this; + const dashLayoutDoc = Doc.Layout(dashDoc); + const finalLayout = node.attrs.docid ? dashDoc : Doc.expandTemplateLayout(dashLayoutDoc, dashDoc, node.attrs.fieldKey); + const resolvedDataDoc = this._resolvedDataDoc; //Added this + + if (!finalLayout) { + return
; + // if (!finalLayout) setTimeout(() => self.doRender(dashDoc, removeDoc, node, view, getPos), 0); + } else { + + this._reactionDisposer?.(); + this._reactionDisposer = reaction(() => + ({ + dim: [finalLayout[WidthSym](), finalLayout[HeightSym]()], + color: finalLayout.color + }), + ({ dim, color }) => { + spanStyle.width = outerStyle.width = Math.max(20, dim[0]) + "px"; + spanStyle.height = outerStyle.height = Math.max(20, dim[1]) + "px"; + outerStyle.border = "1px solid " + StrCast(finalLayout.color, (Cast(Doc.UserDoc().activeWorkspace, Doc, null).darkScheme ? "dimGray" : "lightGray")); + }, { fireImmediately: true }); + + if (node.attrs.width !== dashDoc._width + "px" || node.attrs.height !== dashDoc._height + "px") { + try { // bcz: an exception will be thrown if two aliases are open at the same time when a doc view comment is made + view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, { ...node.attrs, width: dashDoc._width + "px", height: dashDoc._height + "px" })); + } catch (e) { + console.log(e); + } + } + + + //const doReactRender = (finalLayout: Doc, resolvedDataDoc: Doc) => { + // ReactDOM.unmountComponentAtNode(this._dashSpan); + + return ( + +
+ + +
+
+ ); + + } + } + +} \ No newline at end of file diff --git a/src/client/views/nodes/formattedText/DashFieldView.scss b/src/client/views/nodes/formattedText/DashFieldView.scss new file mode 100644 index 000000000..35ff9c1e6 --- /dev/null +++ b/src/client/views/nodes/formattedText/DashFieldView.scss @@ -0,0 +1,36 @@ +.dashFieldView { + position: relative; + display: inline-block; + + .dashFieldView-enumerables { + width: 10px; + height: 10px; + position: relative; + display: inline-block; + background: dimGray; + } + .dashFieldView-fieldCheck { + min-width: 12px; + position: relative; + display: inline-block; + background-color: rgba(155, 155, 155, 0.24); + } + .dashFieldView-labelSpan { + position: relative; + display: inline-block; + font-size: small; + } + .dashFieldView-fieldSpan { + min-width: 20px; + margin-left: 2px; + margin-right: 5px; + position: relative; + display: inline-block; + background-color: rgba(155, 155, 155, 0.24); + span { + min-width: 100%; + display: inline-block; + } + } +} + \ No newline at end of file diff --git a/src/client/views/nodes/formattedText/DashFieldView.tsx b/src/client/views/nodes/formattedText/DashFieldView.tsx new file mode 100644 index 000000000..82c3185e7 --- /dev/null +++ b/src/client/views/nodes/formattedText/DashFieldView.tsx @@ -0,0 +1,211 @@ +import { IReactionDisposer, observable, runInAction, computed, action } from "mobx"; +import { Doc, DocListCast, Field } from "../../../../new_fields/Doc"; +import { List } from "../../../../new_fields/List"; +import { listSpec } from "../../../../new_fields/Schema"; +import { SchemaHeaderField } from "../../../../new_fields/SchemaHeaderField"; +import { ComputedField } from "../../../../new_fields/ScriptField"; +import { Cast, StrCast } from "../../../../new_fields/Types"; +import { DocServer } from "../../../DocServer"; +import { CollectionViewType } from "../../collections/CollectionView"; +import { FormattedTextBox } from "./FormattedTextBox"; +import React = require("react"); +import * as ReactDOM from 'react-dom'; +import "./DashFieldView.scss"; +import { observer } from "mobx-react"; + + +export class DashFieldView { + _fieldWrapper: HTMLDivElement; // container for label and value + + constructor(node: any, view: any, getPos: any, tbox: FormattedTextBox) { + this._fieldWrapper = document.createElement("div"); + this._fieldWrapper.style.width = node.attrs.width; + this._fieldWrapper.style.height = node.attrs.height; + this._fieldWrapper.style.fontWeight = "bold"; + this._fieldWrapper.style.position = "relative"; + this._fieldWrapper.style.display = "inline-block"; + this._fieldWrapper.onkeypress = function (e: any) { e.stopPropagation(); }; + this._fieldWrapper.onkeydown = function (e: any) { e.stopPropagation(); }; + this._fieldWrapper.onkeyup = function (e: any) { e.stopPropagation(); }; + this._fieldWrapper.onmousedown = function (e: any) { e.stopPropagation(); }; + + ReactDOM.render(, this._fieldWrapper); + (this as any).dom = this._fieldWrapper; + } + destroy() { + ReactDOM.unmountComponentAtNode(this._fieldWrapper); + } + selectNode() { } + +} +interface IDashFieldViewInternal { + fieldKey: string; + docid: string; + view: any; + getPos: any; + tbox: FormattedTextBox; + width: number; + height: number; +} + +@observer +export class DashFieldViewInternal extends React.Component { + _reactionDisposer: IReactionDisposer | undefined; + _textBoxDoc: Doc; + _fieldKey: string; + _fieldStringRef = React.createRef(); + @observable _showEnumerables: boolean = false; + @observable _dashDoc: Doc | undefined; + + constructor(props: IDashFieldViewInternal) { + super(props); + this._fieldKey = this.props.fieldKey; + this._textBoxDoc = this.props.tbox.props.Document; + + if (this.props.docid) { + DocServer.GetRefField(this.props.docid). + then(action(async dashDoc => dashDoc instanceof Doc && (this._dashDoc = dashDoc))); + } else { + this._dashDoc = this.props.tbox.props.DataDoc || this.props.tbox.dataDoc; + } + } + componentWillUnmount() { + this._reactionDisposer?.(); + } + + // set the display of the field's value (checkbox for booleans, span of text for strings) + @computed get fieldValueContent() { + if (this._dashDoc) { + const dashVal = this._dashDoc[this._fieldKey]; + const fval = StrCast(dashVal).startsWith(":=") || dashVal === "" ? Doc.Layout(this._textBoxDoc)[this._fieldKey] : dashVal; + const boolVal = Cast(fval, "boolean", null); + const strVal = Field.toString(fval as Field) || ""; + + // field value is a boolean, so use a checkbox or similar widget to display it + if (boolVal === true || boolVal === false) { + return this._dashDoc![this._fieldKey] = e.target.checked} + />; + } + else // field value is a string, so display it as an editable span + { + // bcz: this is unfortunate, but since this React component is nested within a non-React text box (prosemirror), we can't + // use React events. Essentially, React events occur after native events have been processed, so corresponding React events + // will never fire because Prosemirror has handled the native events. So we add listeners for native events here. + return { + r?.addEventListener("keydown", e => this.fieldSpanKeyDown(e, r)); + r?.addEventListener("blur", e => r && this.updateText(r.textContent!, false)); + r?.addEventListener("pointerdown", action((e) => this._showEnumerables = true)); + }}> + {strVal} + ; + } + } + } + + // we need to handle all key events on the input span or else they will propagate to prosemirror. + @action + fieldSpanKeyDown = (e: KeyboardEvent, span: HTMLSpanElement) => { + if (e.key === "Enter") { // handle the enter key by "submitting" the current text to Dash's database. + e.ctrlKey && Doc.addFieldEnumerations(this._textBoxDoc, this._fieldKey, [{ title: span.textContent! }]); + this.updateText(span.textContent!, true); + e.preventDefault();// prevent default to avoid a newline from being generated and wiping out this field view + } + if (e.key === "a" && (e.ctrlKey || e.metaKey)) { // handle ctrl-A to select all the text within the span + if (window.getSelection) { + const range = document.createRange(); + range.selectNodeContents(span); + window.getSelection()!.removeAllRanges(); + window.getSelection()!.addRange(range); + } + e.preventDefault(); //prevent default so that all the text in the prosemirror text box isn't selected + } + e.stopPropagation(); // we need to handle all events or else they will propagate to prosemirror. + } + + @action + updateText = (nodeText: string, forceMatch: boolean) => { + this._showEnumerables = false; + if (nodeText) { + const newText = nodeText.startsWith(":=") || nodeText.startsWith("=:=") ? ":=-computed-" : nodeText; + + // look for a document whose id === the fieldKey being displayed. If there's a match, then that document + // holds the different enumerated values for the field in the titles of its collected documents. + // if there's a partial match from the start of the input text, complete the text --- TODO: make this an auto suggest box and select from a drop down. + DocServer.GetRefField(this._fieldKey).then(options => { + let modText = ""; + (options instanceof Doc) && DocListCast(options.data).forEach(opt => (forceMatch ? StrCast(opt.title).startsWith(newText) : StrCast(opt.title) === newText) && (modText = StrCast(opt.title))); + if (modText) { + // elementfieldSpan.innerHTML = this._dashDoc![this._fieldKey as string] = modText; + Doc.addFieldEnumerations(this._textBoxDoc, this._fieldKey, []); + this._dashDoc![this._fieldKey] = modText; + } // if the text starts with a ':=' then treat it as an expression by making a computed field from its value storing it in the key + else if (nodeText.startsWith(":=")) { + this._dashDoc![this._fieldKey] = ComputedField.MakeFunction(nodeText.substring(2)); + } else if (nodeText.startsWith("=:=")) { + Doc.Layout(this._textBoxDoc)[this._fieldKey] = ComputedField.MakeFunction(nodeText.substring(3)); + } else { + this._dashDoc![this._fieldKey] = newText; + } + }); + } + } + + // display a collection of all the enumerable values for this field + onPointerDownEnumerables = async (e: any) => { + e.stopPropagation(); + const collview = await Doc.addFieldEnumerations(this._textBoxDoc, this._fieldKey, [{ title: this._fieldKey }]); + collview instanceof Doc && this.props.tbox.props.addDocTab(collview, "onRight"); + } + + + // clicking on the label creates a pivot view collection of all documents + // in the same collection. The pivot field is the fieldKey of this label + onPointerDownLabelSpan = (e: any) => { + e.stopPropagation(); + let container = this.props.tbox.props.ContainingCollectionView; + while (container?.props.Document.isTemplateForField || container?.props.Document.isTemplateDoc) { + container = container.props.ContainingCollectionView; + } + if (container) { + const alias = Doc.MakeAlias(container.props.Document); + alias.viewType = CollectionViewType.Time; + let list = Cast(alias.schemaColumns, listSpec(SchemaHeaderField)); + if (!list) { + alias.schemaColumns = list = new List(); + } + list.map(c => c.heading).indexOf(this._fieldKey) === -1 && list.push(new SchemaHeaderField(this._fieldKey, "#f1efeb")); + list.map(c => c.heading).indexOf("text") === -1 && list.push(new SchemaHeaderField("text", "#f1efeb")); + alias._pivotField = this._fieldKey; + this.props.tbox.props.addDocTab(alias, "onRight"); + } + } + + render() { + return
+ + {this._fieldKey} + + +
+ {this.fieldValueContent} +
+ + {!this._showEnumerables ? (null) :
} + +
; + } +} \ No newline at end of file diff --git a/src/client/views/nodes/formattedText/FootnoteView.tsx b/src/client/views/nodes/formattedText/FootnoteView.tsx new file mode 100644 index 000000000..ee21fb765 --- /dev/null +++ b/src/client/views/nodes/formattedText/FootnoteView.tsx @@ -0,0 +1,162 @@ +import { EditorView } from "prosemirror-view"; +import { EditorState } from "prosemirror-state"; +import { keymap } from "prosemirror-keymap"; +import { baseKeymap, toggleMark } from "prosemirror-commands"; +import { schema } from "./schema_rts"; +import { redo, undo } from "prosemirror-history"; +import { StepMap } from "prosemirror-transform"; + +import React = require("react"); + +interface IFootnoteView { + innerView: any; + outerView: any; + node: any; + dom: any; + getPos: any; +} + +export class FootnoteView extends React.Component { + _innerView: any; + _node: any; + + constructor(props: IFootnoteView) { + super(props); + const node = this.props.node; + const outerView = this.props.outerView; + const _innerView = this.props.innerView; + const getPos = this.props.getPos; + } + + selectNode() { + const attrs = { ...this.props.node.attrs }; + attrs.visibility = true; + this.dom.classList.add("ProseMirror-selectednode"); + if (!this.props.innerView) this.open(); + } + + deselectNode() { + const attrs = { ...this.props.node.attrs }; + attrs.visibility = false; + this.dom.classList.remove("ProseMirror-selectednode"); + if (this.props.innerView) this.close(); + } + open() { + // Append a tooltip to the outer node + const tooltip = this.dom.appendChild(document.createElement("div")); + tooltip.className = "footnote-tooltip"; + // And put a sub-ProseMirror into that + this.props.innerView.defineProperty(new EditorView(tooltip, { + // You can use any node as an editor document + state: EditorState.create({ + doc: this.props.node, + plugins: [keymap(baseKeymap), + keymap({ + "Mod-z": () => undo(this.props.outerView.state, this.props.outerView.dispatch), + "Mod-y": () => redo(this.props.outerView.state, this.props.outerView.dispatch), + "Mod-b": toggleMark(schema.marks.strong) + }), + // new Plugin({ + // view(newView) { + // // TODO -- make this work with RichTextMenu + // // return FormattedTextBox.getToolTip(newView); + // } + // }) + ], + + }), + // This is the magic part + dispatchTransaction: this.dispatchInner.bind(this), + handleDOMEvents: { + pointerdown: ((view: any, e: PointerEvent) => { + // Kludge to prevent issues due to the fact that the whole + // footnote is node-selected (and thus DOM-selected) when + // the parent editor is focused. + e.stopPropagation(); + document.addEventListener("pointerup", this.ignore, true); + if (this.props.outerView.hasFocus()) this.props.innerView.focus(); + }) as any + } + })); + setTimeout(() => this.props.innerView && this.props.innerView.docView.setSelection(0, 0, this.props.innerView.root, true), 0); + } + + ignore = (e: PointerEvent) => { + e.stopPropagation(); + document.removeEventListener("pointerup", this.ignore, true); + } + + dispatchInner(tr: any) { + const { state, transactions } = this.props.innerView.state.applyTransaction(tr); + this.props.innerView.updateState(state); + + if (!tr.getMeta("fromOutside")) { + const outerTr = this.props.outerView.state.tr, offsetMap = StepMap.offset(this.props.getPos() + 1); + for (const transaction of transactions) { + const steps = transaction.steps; + for (const step of steps) { + outerTr.step(step.map(offsetMap)); + } + } + if (outerTr.docChanged) this.props.outerView.dispatch(outerTr); + } + } + update(node: any) { + if (!node.sameMarkup(this.props.node)) return false; + this._node = node; //not sure + if (this.props.innerView) { + const state = this.props.innerView.state; + const start = node.content.findDiffStart(state.doc.content); + if (start !== null) { + let { a: endA, b: endB } = node.content.findDiffEnd(state.doc.content); + const overlap = start - Math.min(endA, endB); + if (overlap > 0) { endA += overlap; endB += overlap; } + this.props.innerView.dispatch( + state.tr + .replace(start, endB, node.slice(start, endA)) + .setMeta("fromOutside", true)); + } + } + return true; + } + onPointerUp = (e: any) => { + this.toggle(e); + } + + toggle = (e: any) => { + e.preventDefault(); + if (this.props.innerView) this.close(); + else { + this.open(); + } + } + + close() { + this.props.innerView && this.props.innerView.destroy(); + this._innerView = null; + this.dom.textContent = ""; + } + + destroy() { + if (this.props.innerView) this.close(); + } + + stopEvent(event: any) { + return this.props.innerView && this.props.innerView.dom.contains(event.target); + } + + ignoreMutation() { return true; } + + + render() { + return ( +
+
+ +
+
+ ); + } +} diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.scss b/src/client/views/nodes/formattedText/FormattedTextBox.scss new file mode 100644 index 000000000..477a2ca08 --- /dev/null +++ b/src/client/views/nodes/formattedText/FormattedTextBox.scss @@ -0,0 +1,265 @@ +@import "../../globalCssVariables"; + +.ProseMirror { + width: 100%; + height: 100%; + min-height: 100%; +} + +.ProseMirror:focus { + outline: none !important; +} + +.formattedTextBox-cont { + touch-action: none; + cursor: text; + background: inherit; + padding: 0; + border-width: 0px; + border-radius: inherit; + border-color: $intermediate-color; + box-sizing: border-box; + background-color: inherit; + border-style: solid; + overflow-y: auto; + overflow-x: hidden; + color: initial; + max-height: 100%; + display: flex; + flex-direction: row; + transition: opacity 1s; + + .formattedTextBox-dictation { + height: 12px; + width: 10px; + top: 0px; + left: 0px; + position: absolute; + } +} +.formattedTextBox-outer { + position: relative; + overflow: auto; + display: inline-block; + width: 100%; + height: 100%; +} + +.formattedTextBox-sidebar-handle { + position: absolute; + top: calc(50% - 17.5px); + width: 10px; + height: 35px; + background: lightgray; + border-radius: 20px; + cursor:grabbing; +} + +.formattedTextBox-cont>.formattedTextBox-sidebar-handle { + right: 0; + left: unset; +} + +.formattedTextBox-sidebar, +.formattedTextBox-sidebar-inking { + border-left: dashed 1px black; + height: 100%; + display: inline-block; + position: absolute; + right: 0; + + .collectionfreeformview-container { + position: relative; + } + + >.formattedTextBox-sidebar-handle { + right: unset; + left: -5; + } +} + +.formattedTextBox-sidebar-inking { + pointer-events: all; +} + +.formattedTextBox-inner-rounded { + height: 70%; + width: 85%; + position: absolute; + overflow: auto; + top: 15%; + left: 10%; +} + +.formattedTextBox-inner-rounded, +.formattedTextBox-inner { + height: 100%; + white-space: pre-wrap; +} + +// .menuicon { +// display: inline-block; +// border-right: 1px solid rgba(0, 0, 0, 0.2); +// color: #888; +// line-height: 1; +// padding: 0 7px; +// margin: 1px; +// cursor: pointer; +// text-align: center; +// min-width: 1.4em; +// } + +.strong, +.heading { + font-weight: bold; +} + +.em { + font-style: italic; +} + +.userMarkOpen { + background: rgba(255, 255, 0, 0.267); + display: inline; +} + +.userMark { + background: rgba(255, 255, 0, 0.267); + font-size: 2px; + display: inline-grid; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 10px; + min-height: 10px; + text-align: center; + align-content: center; +} + +footnote { + display: inline-block; + position: relative; + cursor: pointer; + + div { + padding: 0 !important; + } +} + +footnote::after { + content: counter(prosemirror-footnote); + vertical-align: super; + font-size: 75%; + counter-increment: prosemirror-footnote; +} + +.ProseMirror { + counter-reset: prosemirror-footnote; +} + +.footnote-tooltip { + cursor: auto; + font-size: 75%; + position: absolute; + left: -30px; + top: calc(100% + 10px); + background: silver; + padding: 3px; + border-radius: 2px; + max-width: 100px; + min-width: 50px; + width: max-content; +} + +.prosemirror-attribution { + font-size: 8px; +} + +.footnote-tooltip::before { + border: 5px solid silver; + border-top-width: 0px; + border-left-color: transparent; + border-right-color: transparent; + position: absolute; + top: -5px; + left: 27px; + content: " "; + height: 0; + width: 0; +} + + +.formattedTextBox-inlineComment { + position: relative; + width: 40px; + height: 20px; + &::before { + content: "→"; + } + &:hover { + background: orange; + } +} + +.formattedTextBox-summarizer { + opacity: 0.5; + position: relative; + width: 40px; + height: 20px; + &::after { + content: "←"; + } +} + +.formattedTextBox-summarizer-collapsed { + opacity: 0.5; + position: relative; + width: 40px; + height: 20px; + &::after { + content: "..."; + } +} + +.ProseMirror { + touch-action: none; + span { + font-family: inherit; + } + + ol, ul { + counter-reset: deci1 0 multi1 0; + padding-left: 1em; + font-family: inherit; + } + ol { + margin-left: 1em; + font-family: inherit; + } + + .decimal1-ol { counter-reset: deci1; p {display: inline; font-family: inherit} margin-left: 0; } + .decimal2-ol { counter-reset: deci2; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 1em;} + .decimal3-ol { counter-reset: deci3; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 2em;} + .decimal4-ol { counter-reset: deci4; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 3em;} + .decimal5-ol { counter-reset: deci5; p {display: inline; font-family: inherit} font-size: smaller; } + .decimal6-ol { counter-reset: deci6; p {display: inline; font-family: inherit} font-size: smaller; } + .decimal7-ol { counter-reset: deci7; p {display: inline; font-family: inherit} font-size: smaller; } + + .multi1-ol { counter-reset: multi1; p {display: inline; font-family: inherit} margin-left: 0; padding-left: 1.2em } + .multi2-ol { counter-reset: multi2; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 1.4em;} + .multi3-ol { counter-reset: multi3; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 2em;} + .multi4-ol { counter-reset: multi4; p {display: inline; font-family: inherit} font-size: smaller; padding-left: 3.4em;} + + .decimal1:before { transition: 0.5s;counter-increment: deci1; display: inline-block; margin-left: -1em; width: 1em; content: counter(deci1) ". "; } + .decimal2:before { transition: 0.5s;counter-increment: deci2; display: inline-block; margin-left: -2.1em; width: 2.1em; content: counter(deci1) "."counter(deci2) ". "; } + .decimal3:before { transition: 0.5s;counter-increment: deci3; display: inline-block; margin-left: -2.85em;width: 2.85em; content: counter(deci1) "."counter(deci2) "."counter(deci3) ". "; } + .decimal4:before { transition: 0.5s;counter-increment: deci4; display: inline-block; margin-left: -3.85em;width: 3.85em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) ". "; } + .decimal5:before { transition: 0.5s;counter-increment: deci5; display: inline-block; margin-left: -2em; width: 5em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) ". "; } + .decimal6:before { transition: 0.5s;counter-increment: deci6; display: inline-block; margin-left: -2em; width: 6em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) "."counter(deci6) ". "; } + .decimal7:before { transition: 0.5s;counter-increment: deci7; display: inline-block; margin-left: -2em; width: 7em; content: counter(deci1) "."counter(deci2) "."counter(deci3) "."counter(deci4) "."counter(deci5) "."counter(deci6) "."counter(deci7) ". "; } + + .multi1:before { transition: 0.5s;counter-increment: multi1; display: inline-block; margin-left: -1em; width: 1.2em; content: counter(multi1, upper-alpha) ". "; } + .multi2:before { transition: 0.5s;counter-increment: multi2; display: inline-block; margin-left: -2em; width: 2em; content: counter(multi1, upper-alpha) "."counter(multi2, decimal) ". "; } + .multi3:before { transition: 0.5s;counter-increment: multi3; display: inline-block; margin-left: -2.85em; width:2.85em; content: counter(multi1, upper-alpha) "."counter(multi2, decimal) "."counter(multi3, lower-alpha) ". "; } + .multi4:before { transition: 0.5s;counter-increment: multi4; display: inline-block; margin-left: -4.2em; width: 4.2em; content: counter(multi1, upper-alpha) "."counter(multi2, decimal) "."counter(multi3, lower-alpha) "."counter(multi4, lower-roman) ". "; } +} \ No newline at end of file diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx new file mode 100644 index 000000000..248b4f467 --- /dev/null +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -0,0 +1,1303 @@ +import { library } from '@fortawesome/fontawesome-svg-core'; +import { faEdit, faSmile, faTextHeight, faUpload } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { isEqual } from "lodash"; +import { action, computed, IReactionDisposer, Lambda, observable, reaction, runInAction } from "mobx"; +import { observer } from "mobx-react"; +import { baseKeymap } from "prosemirror-commands"; +import { history } from "prosemirror-history"; +import { inputRules } from 'prosemirror-inputrules'; +import { keymap } from "prosemirror-keymap"; +import { Fragment, Mark, Node, Slice } from "prosemirror-model"; +import { EditorState, NodeSelection, Plugin, TextSelection, Transaction } from "prosemirror-state"; +import { ReplaceStep } from 'prosemirror-transform'; +import { EditorView } from "prosemirror-view"; +import { DateField } from '../../../../new_fields/DateField'; +import { DataSym, Doc, DocListCast, DocListCastAsync, Field, HeightSym, Opt, WidthSym } from "../../../../new_fields/Doc"; +import { documentSchema } from '../../../../new_fields/documentSchemas'; +import { Id } from '../../../../new_fields/FieldSymbols'; +import { InkTool } from '../../../../new_fields/InkField'; +import { PrefetchProxy } from '../../../../new_fields/Proxy'; +import { RichTextField } from "../../../../new_fields/RichTextField"; +import { RichTextUtils } from '../../../../new_fields/RichTextUtils'; +import { createSchema, makeInterface } from "../../../../new_fields/Schema"; +import { Cast, DateCast, NumCast, StrCast } from "../../../../new_fields/Types"; +import { TraceMobx } from '../../../../new_fields/util'; +import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, numberRange, returnOne, returnZero, Utils } from '../../../../Utils'; +import { GoogleApiClientUtils, Pulls, Pushes } from '../../../apis/google_docs/GoogleApiClientUtils'; +import { DocServer } from "../../../DocServer"; +import { Docs, DocUtils } from '../../../documents/Documents'; +import { DocumentType } from '../../../documents/DocumentTypes'; +import { DictationManager } from '../../../util/DictationManager'; +import { DragManager } from "../../../util/DragManager"; +import { makeTemplate } from '../../../util/DropConverter'; +import buildKeymap from "./ProsemirrorExampleTransfer"; +import RichTextMenu from './RichTextMenu'; +import { RichTextRules } from "./RichTextRules"; +import { DashDocCommentView, DashDocView, FootnoteView, ImageResizeView, OrderedListView, SummaryView } from "./RichTextSchema"; +// import { DashDocCommentView, DashDocView, DashFieldView, FootnoteView, SummaryView } from "./RichTextSchema"; +// import { OrderedListView } from "./RichTextSchema"; +// import { ImageResizeView } from "./ImageResizeView"; +// import { DashDocCommentView } from "./DashDocCommentView"; +// import { FootnoteView } from "./FootnoteView"; +// import { SummaryView } from "./SummaryView"; +// import { DashDocView } from "./DashDocView"; +import { DashFieldView } from "./DashFieldView"; + +import { schema } from "./schema_rts"; +import { SelectionManager } from "../../../util/SelectionManager"; +import { undoBatch, UndoManager } from "../../../util/UndoManager"; +import { CollectionFreeFormView } from '../../collections/collectionFreeForm/CollectionFreeFormView'; +import { ContextMenu } from '../../ContextMenu'; +import { ContextMenuProps } from '../../ContextMenuItem'; +import { ViewBoxAnnotatableComponent } from "../../DocComponent"; +import { DocumentButtonBar } from '../../DocumentButtonBar'; +import { InkingControl } from "../../InkingControl"; +import { AudioBox } from '../AudioBox'; +import { FieldView, FieldViewProps } from "../FieldView"; +import "./FormattedTextBox.scss"; +import { FormattedTextBoxComment, formattedTextBoxCommentPlugin } from './FormattedTextBoxComment'; +import React = require("react"); + +library.add(faEdit); +library.add(faSmile, faTextHeight, faUpload); + +export interface FormattedTextBoxProps { + hideOnLeave?: boolean; + makeLink?: () => Opt; + xMargin?: number; + yMargin?: number; +} + +const richTextSchema = createSchema({ + documentText: "string" +}); + +export const GoogleRef = "googleDocId"; + +type RichTextDocument = makeInterface<[typeof richTextSchema, typeof documentSchema]>; +const RichTextDocument = makeInterface(richTextSchema, documentSchema); + +type PullHandler = (exportState: Opt, dataDoc: Doc) => void; + +@observer +export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProps & FormattedTextBoxProps), RichTextDocument>(RichTextDocument) { + public static LayoutString(fieldStr: string) { return FieldView.LayoutString(FormattedTextBox, fieldStr); } + public static blankState = () => EditorState.create(FormattedTextBox.Instance.config); + public static Instance: FormattedTextBox; + public ProseRef?: HTMLDivElement; + private _ref: React.RefObject = React.createRef(); + private _scrollRef: React.RefObject = React.createRef(); + private _editorView: Opt; + private _applyingChange: boolean = false; + private _searchIndex = 0; + private _sidebarMovement = 0; + private _lastX = 0; + private _lastY = 0; + private _undoTyping?: UndoManager.Batch; + private _disposers: { [name: string]: IReactionDisposer } = {}; + private dropDisposer?: DragManager.DragDropDisposer; + + @computed get _recording() { return this.dataDoc.audioState === "recording"; } + set _recording(value) { this.dataDoc.audioState = value ? "recording" : undefined; } + + @observable private _entered = false; + + public static FocusedBox: FormattedTextBox | undefined; + public static SelectOnLoad = ""; + public static SelectOnLoadChar = ""; + public static IsFragment(html: string) { + return html.indexOf("data-pm-slice") !== -1; + } + public static GetHref(html: string): string { + const parser = new DOMParser(); + const parsedHtml = parser.parseFromString(html, 'text/html'); + if (parsedHtml.body.childNodes.length === 1 && parsedHtml.body.childNodes[0].childNodes.length === 1 && + (parsedHtml.body.childNodes[0].childNodes[0] as any).href) { + return (parsedHtml.body.childNodes[0].childNodes[0] as any).href; + } + return ""; + } + public static GetDocFromUrl(url: string) { + if (url.startsWith(document.location.origin)) { + const split = new URL(url).pathname.split("doc/"); + const docid = split[split.length - 1]; + return docid; + } + return ""; + } + + @undoBatch + public setFontColor(color: string) { + const view = this._editorView!; + if (view.state.selection.from === view.state.selection.to) return false; + if (view.state.selection.to - view.state.selection.from > view.state.doc.nodeSize - 3) { + this.layoutDoc.color = color; + } + const colorMark = view.state.schema.mark(view.state.schema.marks.pFontColor, { color: color }); + view.dispatch(view.state.tr.addMark(view.state.selection.from, view.state.selection.to, colorMark)); + return true; + } + + constructor(props: any) { + super(props); + FormattedTextBox.Instance = this; + this.updateHighlights(); + } + + public get CurrentDiv(): HTMLDivElement { return this._ref.current!; } + + linkOnDeselect: Map = new Map(); + + doLinkOnDeselect() { + Array.from(this.linkOnDeselect.entries()).map(entry => { + const key = entry[0]; + const value = entry[1]; + const id = Utils.GenerateDeterministicGuid(this.dataDoc[Id] + key); + DocServer.GetRefField(value).then(doc => { + DocServer.GetRefField(id).then(linkDoc => { + this.dataDoc[key] = doc || Docs.Create.FreeformDocument([], { title: value, _width: 500, _height: 500 }, value); + DocUtils.Publish(this.dataDoc[key] as Doc, value, this.props.addDocument, this.props.removeDocument); + if (linkDoc) { (linkDoc as Doc).anchor2 = this.dataDoc[key] as Doc; } + else DocUtils.MakeLink({ doc: this.props.Document }, { doc: this.dataDoc[key] as Doc }, "link to named target", id); + }); + }); + }); + this.linkOnDeselect.clear(); + } + + dispatchTransaction = (tx: Transaction) => { + if (this._editorView) { + const metadata = tx.selection.$from.marks().find((m: Mark) => m.type === schema.marks.metadata); + if (metadata) { + const range = tx.selection.$from.blockRange(tx.selection.$to); + let text = range ? tx.doc.textBetween(range.start, range.end) : ""; + let textEndSelection = tx.selection.to; + for (; textEndSelection < range!.end && text[textEndSelection - range!.start] !== " "; textEndSelection++) { } + text = text.substr(0, textEndSelection - range!.start); + text = text.split(" ")[text.split(" ").length - 1]; + const split = text.split("::"); + if (split.length > 1 && split[1]) { + const key = split[0]; + const value = split[split.length - 1]; + this.linkOnDeselect.set(key, value); + + const id = Utils.GenerateDeterministicGuid(this.dataDoc[Id] + key); + const link = this._editorView.state.schema.marks.link.create({ href: Utils.prepend("/doc/" + id), location: "onRight", title: value }); + const mval = this._editorView.state.schema.marks.metadataVal.create(); + const offset = (tx.selection.to === range!.end - 1 ? -1 : 0); + tx = tx.addMark(textEndSelection - value.length + offset, textEndSelection, link).addMark(textEndSelection - value.length + offset, textEndSelection, mval); + this.dataDoc[key] = value; + } + } + const state = this._editorView.state.apply(tx); + this._editorView.updateState(state); + (tx.storedMarks && !this._editorView.state.storedMarks) && (this._editorView.state.storedMarks = tx.storedMarks); + + const tsel = this._editorView.state.selection.$from; + tsel.marks().filter(m => m.type === this._editorView!.state.schema.marks.user_mark).map(m => AudioBox.SetScrubTime(Math.max(0, m.attrs.modified * 1000))); + const curText = state.doc.textBetween(0, state.doc.content.size, " \n"); + const curTemp = Cast(this.props.Document[this.props.fieldKey + "-textTemplate"], RichTextField); + if (!this._applyingChange) { + this._applyingChange = true; + this.dataDoc[this.props.fieldKey + "-lastModified"] = new DateField(new Date(Date.now())); + if (!curTemp || curText) { // if no template, or there's text, write it to the document. (if this is driven by a template, then this overwrites the template text which is intended) + this.dataDoc[this.props.fieldKey] = new RichTextField(JSON.stringify(state.toJSON()), curText); + this.dataDoc[this.props.fieldKey + "-noTemplate"] = (curTemp?.Text || "") !== curText; // mark the data field as being split from the template if it has been edited + } else { // if we've deleted all the text in a note driven by a template, then restore the template data + this._editorView.updateState(EditorState.fromJSON(this.config, JSON.parse(curTemp.Data))); + this.dataDoc[this.props.fieldKey + "-noTemplate"] = undefined; // mark the data field as not being split from any template it might have + } + this._applyingChange = false; + } + this.updateTitle(); + this.tryUpdateHeight(); + } + } + + updateTitle = () => { + if ((this.props.Document.isTemplateForField === "text" || !this.props.Document.isTemplateForField) && // only update the title if the data document's data field is changing + StrCast(this.dataDoc.title).startsWith("-") && this._editorView && !this.rootDoc.customTitle) { + const str = this._editorView.state.doc.textContent; + const titlestr = str.substr(0, Math.min(40, str.length)); + this.dataDoc.title = "-" + titlestr + (str.length > 40 ? "..." : ""); + } + } + + // needs a better API for taking in a set of words with target documents instead of just one target + public hyperlinkTerms = (terms: string[], target: Doc) => { + if (this._editorView && (this._editorView as any).docView && terms.some(t => t)) { + const res = terms.filter(t => t).map(term => this.findInNode(this._editorView!, this._editorView!.state.doc, term)); + const tr = this._editorView.state.tr; + const flattened: TextSelection[] = []; + res.map(r => r.map(h => flattened.push(h))); + const lastSel = Math.min(flattened.length - 1, this._searchIndex); + this._searchIndex = ++this._searchIndex > flattened.length - 1 ? 0 : this._searchIndex; + const alink = DocUtils.MakeLink({ doc: this.props.Document }, { doc: target }, "automatic")!; + const link = this._editorView.state.schema.marks.link.create({ + href: Utils.prepend("/doc/" + alink[Id]), + title: "a link", location: location, linkId: alink[Id], targetId: target[Id] + }); + this._editorView.dispatch(tr.addMark(flattened[lastSel].from, flattened[lastSel].to, link)); + } + } + public highlightSearchTerms = (terms: string[]) => { + if (this._editorView && (this._editorView as any).docView && terms.some(t => t)) { + const mark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight); + const activeMark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight, { selected: true }); + const res = terms.filter(t => t).map(term => this.findInNode(this._editorView!, this._editorView!.state.doc, term)); + let tr = this._editorView.state.tr; + const flattened: TextSelection[] = []; + res.map(r => r.map(h => flattened.push(h))); + const lastSel = Math.min(flattened.length - 1, this._searchIndex); + flattened.forEach((h: TextSelection, ind: number) => tr = tr.addMark(h.from, h.to, ind === lastSel ? activeMark : mark)); + this._searchIndex = ++this._searchIndex > flattened.length - 1 ? 0 : this._searchIndex; + this._editorView.dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(flattened[lastSel].from), tr.doc.resolve(flattened[lastSel].to))).scrollIntoView()); + } + } + + public unhighlightSearchTerms = () => { + if (this._editorView && (this._editorView as any).docView) { + const mark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight); + const activeMark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight, { selected: true }); + const end = this._editorView.state.doc.nodeSize - 2; + this._editorView.dispatch(this._editorView.state.tr.removeMark(0, end, mark).removeMark(0, end, activeMark)); + } + } + adoptAnnotation = (start: number, end: number, mark: Mark) => { + const view = this._editorView!; + const nmark = view.state.schema.marks.user_mark.create({ ...mark.attrs, userid: Doc.CurrentUserEmail }); + view.dispatch(view.state.tr.removeMark(start, end, nmark).addMark(start, end, nmark)); + } + protected createDropTarget = (ele: HTMLDivElement) => { + this.ProseRef = ele; + this.dropDisposer?.(); + ele && (this.dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.props.Document)); + } + + @undoBatch + @action + drop = async (e: Event, de: DragManager.DropEvent) => { + if (de.complete.docDragData) { + const draggedDoc = de.complete.docDragData.draggedDocuments.length && de.complete.docDragData.draggedDocuments[0]; + // replace text contents whend dragging with Alt + if (draggedDoc && draggedDoc.type === DocumentType.RTF && !Doc.AreProtosEqual(draggedDoc, this.props.Document) && de.altKey) { + if (draggedDoc.data instanceof RichTextField) { + Doc.GetProto(this.dataDoc)[this.props.fieldKey] = new RichTextField(draggedDoc.data.Data, draggedDoc.data.Text); + e.stopPropagation(); + } + // embed document when dragging with a userDropAction or an embedDoc flag set + } else if (de.complete.docDragData.userDropAction || de.complete.docDragData.embedDoc) { + const target = de.complete.docDragData.droppedDocuments[0]; + // const link = DocUtils.MakeLink({ doc: this.dataDoc, ctx: this.props.ContainingCollectionDoc }, { doc: target }, "Embedded Doc:" + target.title); + // if (link) { + target._fitToBox = true; + const node = schema.nodes.dashDoc.create({ + width: target[WidthSym](), height: target[HeightSym](), + title: "dashDoc", docid: target[Id], + float: "right" + }); + const view = this._editorView!; + view.dispatch(view.state.tr.insert(view.posAtCoords({ left: de.x, top: de.y })!.pos, node)); + this.tryUpdateHeight(); + e.stopPropagation(); + // } + } // otherwise, fall through to outer collection to handle drop + } else if (de.complete.linkDragData) { + de.complete.linkDragData.linkDropCallback = this.linkDrop; + } + } + linkDrop = (data: DragManager.LinkDragData) => { + const linkDoc = data.linkDocument!; + const anchor1Title = linkDoc.anchor1 instanceof Doc ? StrCast(linkDoc.anchor1.title) : "-untitled-"; + const anchor1Id = linkDoc.anchor1 instanceof Doc ? linkDoc.anchor1[Id] : ""; + this.makeLinkToSelection(linkDoc[Id], anchor1Title, "onRight", anchor1Id); + } + + getNodeEndpoints(context: Node, node: Node): { from: number, to: number } | null { + let offset = 0; + + if (context === node) return { from: offset, to: offset + node.nodeSize }; + + if (node.isBlock) { + // tslint:disable-next-line: prefer-for-of + for (let i = 0; i < (context.content as any).content.length; i++) { + const result = this.getNodeEndpoints((context.content as any).content[i], node); + if (result) { + return { + from: result.from + offset + (context.type.name === "doc" ? 0 : 1), + to: result.to + offset + (context.type.name === "doc" ? 0 : 1) + }; + } + offset += (context.content as any).content[i].nodeSize; + } + return null; + } else { + return null; + } + } + + + //Recursively finds matches within a given node + findInNode(pm: EditorView, node: Node, find: string) { + let ret: TextSelection[] = []; + + if (node.isTextblock) { + let index = 0, foundAt; + const ep = this.getNodeEndpoints(pm.state.doc, node); + while (ep && (foundAt = node.textContent.slice(index).search(RegExp(find, "i"))) > -1) { + const sel = new TextSelection(pm.state.doc.resolve(ep.from + index + foundAt + 1), pm.state.doc.resolve(ep.from + index + foundAt + find.length + 1)); + ret.push(sel); + index = index + foundAt + find.length; + } + } else { + node.content.forEach((child, i) => ret = ret.concat(this.findInNode(pm, child, find))); + } + return ret; + } + static _highlights: string[] = ["Text from Others", "Todo Items", "Important Items", "Disagree Items", "Ignore Items"]; + + updateHighlights = () => { + clearStyleSheetRules(FormattedTextBox._userStyleSheet); + if (FormattedTextBox._highlights.indexOf("Text from Others") !== -1) { + addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-remote", { background: "yellow" }); + } + if (FormattedTextBox._highlights.indexOf("My Text") !== -1) { + addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-" + Doc.CurrentUserEmail.replace(".", "").replace("@", ""), { background: "moccasin" }); + } + if (FormattedTextBox._highlights.indexOf("Todo Items") !== -1) { + addStyleSheetRule(FormattedTextBox._userStyleSheet, "userTag-" + "todo", { outline: "black solid 1px" }); + } + if (FormattedTextBox._highlights.indexOf("Important Items") !== -1) { + addStyleSheetRule(FormattedTextBox._userStyleSheet, "userTag-" + "important", { "font-size": "larger" }); + } + if (FormattedTextBox._highlights.indexOf("Disagree Items") !== -1) { + addStyleSheetRule(FormattedTextBox._userStyleSheet, "userTag-" + "disagree", { "text-decoration": "line-through" }); + } + if (FormattedTextBox._highlights.indexOf("Ignore Items") !== -1) { + addStyleSheetRule(FormattedTextBox._userStyleSheet, "userTag-" + "ignore", { "font-size": "1" }); + } + if (FormattedTextBox._highlights.indexOf("By Recent Minute") !== -1) { + addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-" + Doc.CurrentUserEmail.replace(".", "").replace("@", ""), { opacity: "0.1" }); + const min = Math.round(Date.now() / 1000 / 60); + numberRange(10).map(i => addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-min-" + (min - i), { opacity: ((10 - i - 1) / 10).toString() })); + setTimeout(() => this.updateHighlights()); + } + if (FormattedTextBox._highlights.indexOf("By Recent Hour") !== -1) { + addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-" + Doc.CurrentUserEmail.replace(".", "").replace("@", ""), { opacity: "0.1" }); + const hr = Math.round(Date.now() / 1000 / 60 / 60); + numberRange(10).map(i => addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-hr-" + (hr - i), { opacity: ((10 - i - 1) / 10).toString() })); + } + } + + sidebarDown = (e: React.PointerEvent) => { + this._lastX = e.clientX; + this._lastY = e.clientY; + this._sidebarMovement = 0; + document.addEventListener("pointermove", this.sidebarMove); + document.addEventListener("pointerup", this.sidebarUp); + e.stopPropagation(); + e.preventDefault(); // prevents text from being selected during drag + } + sidebarMove = (e: PointerEvent) => { + const bounds = this.CurrentDiv.getBoundingClientRect(); + this._sidebarMovement += Math.sqrt((e.clientX - this._lastX) * (e.clientX - this._lastX) + (e.clientY - this._lastY) * (e.clientY - this._lastY)); + this.props.Document.sidebarWidthPercent = "" + 100 * (1 - (e.clientX - bounds.left) / bounds.width) + "%"; + } + sidebarUp = (e: PointerEvent) => { + document.removeEventListener("pointermove", this.sidebarMove); + document.removeEventListener("pointerup", this.sidebarUp); + } + + toggleSidebar = () => this._sidebarMovement < 5 && (this.props.Document.sidebarWidthPercent = StrCast(this.props.Document.sidebarWidthPercent, "0%") === "0%" ? "25%" : "0%"); + + public static get DefaultLayout(): Doc | string | undefined { + return Cast(Doc.UserDoc().defaultTextLayout, Doc, null) || StrCast(Doc.UserDoc().defaultTextLayout, null); + } + specificContextMenu = (e: React.MouseEvent): void => { + const cm = ContextMenu.Instance; + + const funcs: ContextMenuProps[] = []; + this.props.Document.isTemplateDoc && funcs.push({ description: "Make Default Layout", event: async () => Doc.UserDoc().defaultTextLayout = new PrefetchProxy(this.props.Document), icon: "eye" }); + funcs.push({ description: "Reset Default Layout", event: () => Doc.UserDoc().defaultTextLayout = undefined, icon: "eye" }); + !this.props.Document.rootDocument && funcs.push({ + description: "Make Template", event: () => { + this.props.Document.isTemplateDoc = makeTemplate(this.props.Document); + Doc.AddDocToList(Cast(Doc.UserDoc()["template-notes"], Doc, null), "data", this.props.Document); + }, icon: "eye" + }); + funcs.push({ description: "Toggle Single Line", event: () => this.props.Document._singleLine = !this.props.Document._singleLine, icon: "expand-arrows-alt" }); + funcs.push({ description: "Toggle Sidebar", event: () => this.props.Document._showSidebar = !this.props.Document._showSidebar, icon: "expand-arrows-alt" }); + funcs.push({ description: "Toggle Dictation Icon", event: () => this.props.Document._showAudio = !this.props.Document._showAudio, icon: "expand-arrows-alt" }); + funcs.push({ description: "Toggle Menubar", event: () => this.toggleMenubar(), icon: "expand-arrows-alt" }); + + const highlighting: ContextMenuProps[] = []; + ["My Text", "Text from Others", "Todo Items", "Important Items", "Ignore Items", "Disagree Items", "By Recent Minute", "By Recent Hour"].forEach(option => + highlighting.push({ + description: (FormattedTextBox._highlights.indexOf(option) === -1 ? "Highlight " : "Unhighlight ") + option, event: () => { + e.stopPropagation(); + if (FormattedTextBox._highlights.indexOf(option) === -1) { + FormattedTextBox._highlights.push(option); + } else { + FormattedTextBox._highlights.splice(FormattedTextBox._highlights.indexOf(option), 1); + } + this.updateHighlights(); + }, icon: "expand-arrows-alt" + })); + funcs.push({ description: "highlighting...", subitems: highlighting, icon: "hand-point-right" }); + + ContextMenu.Instance.addItem({ description: "Options...", subitems: funcs, icon: "asterisk" }); + + const change = cm.findByDescription("Change Perspective..."); + const changeItems: ContextMenuProps[] = change && "subitems" in change ? change.subitems : []; + + const noteTypesDoc = Cast(Doc.UserDoc()["template-notes"], Doc, null); + DocListCast(noteTypesDoc?.data).forEach(note => { + changeItems.push({ + description: StrCast(note.title), event: undoBatch(() => { + Doc.setNativeView(this.props.Document); + Doc.makeCustomViewClicked(this.rootDoc, Docs.Create.TreeDocument, StrCast(note.title), note); + }), icon: "eye" + }); + }); + changeItems.push({ description: "FreeForm", event: undoBatch(() => Doc.makeCustomViewClicked(this.rootDoc, Docs.Create.FreeformDocument, "freeform"), "change view"), icon: "eye" }); + !change && cm.addItem({ description: "Change Perspective...", subitems: changeItems, icon: "external-link-alt" }); + + const open = cm.findByDescription("Add a Perspective..."); + const openItems: ContextMenuProps[] = open && "subitems" in open ? open.subitems : []; + + openItems.push({ + description: "FreeForm", event: undoBatch(() => { + const alias = Doc.MakeAlias(this.rootDoc); + Doc.makeCustomViewClicked(alias, Docs.Create.FreeformDocument, "freeform"); + this.props.addDocTab(alias, "onRight"); + }), icon: "eye" + }); + !open && cm.addItem({ description: "Add a Perspective...", subitems: openItems, icon: "external-link-alt" }); + + } + + recordDictation = () => { + DictationManager.Controls.listen({ + interimHandler: this.setCurrentBulletContent, + continuous: { indefinite: false }, + }).then(results => { + if (results && [DictationManager.Controls.Infringed].includes(results)) { + DictationManager.Controls.stop(); + } + //this._editorView!.focus(); + }); + } + stopDictation = (abort: boolean) => { DictationManager.Controls.stop(!abort); }; + + @action + toggleMenubar = () => { + this.props.Document._chromeStatus = this.props.Document._chromeStatus === "disabled" ? "enabled" : "disabled"; + } + + recordBullet = async () => { + const completedCue = "end session"; + const results = await DictationManager.Controls.listen({ + interimHandler: this.setCurrentBulletContent, + continuous: { indefinite: false }, + terminators: [completedCue, "bullet", "next"] + }); + if (results && [DictationManager.Controls.Infringed, completedCue].includes(results)) { + DictationManager.Controls.stop(); + return; + } + this.nextBullet(this._editorView!.state.selection.to); + setTimeout(this.recordBullet, 2000); + } + + setCurrentBulletContent = (value: string) => { + if (this._editorView) { + const state = this._editorView.state; + const now = Date.now(); + let mark = schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.floor(now / 1000) }); + if (!this._break && state.selection.to !== state.selection.from) { + for (let i = state.selection.from; i <= state.selection.to; i++) { + const pos = state.doc.resolve(i); + const um = Array.from(pos.marks()).find(m => m.type === schema.marks.user_mark); + if (um) { + mark = um; + break; + } + } + } + const recordingStart = DateCast(this.props.Document.recordingStart).date.getTime(); + this._break = false; + value = "" + (mark.attrs.modified * 1000 - recordingStart) / 1000 + value; + const from = state.selection.from; + const inserted = state.tr.insertText(value).addMark(from, from + value.length + 1, mark); + this._editorView.dispatch(inserted.setSelection(TextSelection.create(inserted.doc, from, from + value.length + 1))); + } + } + + nextBullet = (pos: number) => { + if (this._editorView) { + const frag = Fragment.fromArray(this.newListItems(2)); + if (this._editorView.state.doc.resolve(pos).depth >= 2) { + const slice = new Slice(frag, 2, 2); + let state = this._editorView.state; + this._editorView.dispatch(state.tr.step(new ReplaceStep(pos, pos, slice))); + pos += 4; + state = this._editorView.state; + this._editorView.dispatch(state.tr.setSelection(TextSelection.create(this._editorView.state.doc, pos, pos))); + } + } + } + + private newListItems = (count: number) => { + return numberRange(count).map(x => schema.nodes.list_item.create(undefined, schema.nodes.paragraph.create())); + } + + _keymap: any = undefined; + _rules: RichTextRules | undefined; + @computed get config() { + this._keymap = buildKeymap(schema, this.props); + this._rules = new RichTextRules(this.props.Document, this); + return { + schema, + plugins: [ + inputRules(this._rules.inpRules), + this.richTextMenuPlugin(), + history(), + keymap(this._keymap), + keymap(baseKeymap), + new Plugin({ + props: { + attributes: { class: "ProseMirror-example-setup-style" } + } + }), + formattedTextBoxCommentPlugin + ] + }; + } + + makeLinkToSelection(linkDocId: string, title: string, location: string, targetDocId: string) { + if (this._editorView) { + const link = this._editorView.state.schema.marks.link.create({ href: Utils.prepend("/doc/" + linkDocId), title: title, location: location, linkId: linkDocId, targetId: targetDocId }); + this._editorView.dispatch(this._editorView.state.tr.removeMark(this._editorView.state.selection.from, this._editorView.state.selection.to, this._editorView.state.schema.marks.link). + addMark(this._editorView.state.selection.from, this._editorView.state.selection.to, link)); + } + } + componentDidMount() { + this._disposers.buttonBar = reaction( + () => DocumentButtonBar.Instance, + instance => { + if (instance) { + this.pullFromGoogleDoc(this.checkState); + this.dataDoc[GoogleRef] && this.dataDoc.unchanged && runInAction(() => instance.isAnimatingFetch = true); + } + } + ); + this._disposers.linkMaker = reaction( + () => this.props.makeLink?.(), + (linkDoc: Opt) => { + if (linkDoc) { + const anchor2Title = linkDoc.anchor2 instanceof Doc ? StrCast(linkDoc.anchor2.title) : "-untitled-"; + const anchor2Id = linkDoc.anchor2 instanceof Doc ? linkDoc.anchor2[Id] : ""; + this.makeLinkToSelection(linkDoc[Id], anchor2Title, "onRight", anchor2Id); + } + }, + { fireImmediately: true } + ); + this._disposers.editorState = reaction( + () => { + if (this.dataDoc[this.props.fieldKey + "-noTemplate"] || !this.props.Document[this.props.fieldKey + "-textTemplate"]) { + return Cast(this.dataDoc[this.props.fieldKey], RichTextField, null)?.Data; + } + return Cast(this.props.Document[this.props.fieldKey + "-textTemplate"], RichTextField, null)?.Data; + }, + incomingValue => { + if (incomingValue !== undefined && this._editorView && !this._applyingChange) { + const updatedState = JSON.parse(incomingValue); + this._editorView.updateState(EditorState.fromJSON(this.config, updatedState)); + this.tryUpdateHeight(); + } + } + ); + this._disposers.pullDoc = reaction( + () => this.props.Document[Pulls], + () => { + if (!DocumentButtonBar.hasPulledHack) { + DocumentButtonBar.hasPulledHack = true; + const unchanged = this.dataDoc.unchanged; + this.pullFromGoogleDoc(unchanged ? this.checkState : this.updateState); + } + } + ); + this._disposers.pushDoc = reaction( + () => this.props.Document[Pushes], + () => { + if (!DocumentButtonBar.hasPushedHack) { + DocumentButtonBar.hasPushedHack = true; + this.pushToGoogleDoc(); + } + } + ); + this._disposers.height = reaction( + () => [this.layoutDoc[WidthSym](), this.layoutDoc._autoHeight], + () => this.tryUpdateHeight() + ); + + this.setupEditor(this.config, this.props.fieldKey); + + this._disposers.search = reaction(() => this.rootDoc.searchMatch, + search => search ? this.highlightSearchTerms([Doc.SearchQuery()]) : this.unhighlightSearchTerms(), + { fireImmediately: true }); + + this._disposers.record = reaction(() => this._recording, + () => { + if (this._recording) { + setTimeout(action(() => { + this.stopDictation(true); + setTimeout(() => this.recordDictation(), 500); + }), 500); + } else setTimeout(() => this.stopDictation(true), 0); + } + ); + this._disposers.scrollToRegion = reaction( + () => StrCast(this.layoutDoc.scrollToLinkID), + async (scrollToLinkID) => { + const findLinkFrag = (frag: Fragment, editor: EditorView) => { + const nodes: Node[] = []; + frag.forEach((node, index) => { + const examinedNode = findLinkNode(node, editor); + if (examinedNode && examinedNode.textContent) { + nodes.push(examinedNode); + start += index; + } + }); + return { frag: Fragment.fromArray(nodes), start: start }; + }; + const findLinkNode = (node: Node, editor: EditorView) => { + if (!node.isText) { + const content = findLinkFrag(node.content, editor); + return node.copy(content.frag); + } + const marks = [...node.marks]; + const linkIndex = marks.findIndex(mark => mark.type === editor.state.schema.marks.link); + return linkIndex !== -1 && scrollToLinkID === marks[linkIndex].attrs.href.replace(/.*\/doc\//, "") ? node : undefined; + }; + + let start = -1; + if (this._editorView && scrollToLinkID) { + const editor = this._editorView; + const ret = findLinkFrag(editor.state.doc.content, editor); + + if (ret.frag.size > 2 && ret.start >= 0) { + let selection = TextSelection.near(editor.state.doc.resolve(ret.start)); // default to near the start + if (ret.frag.firstChild) { + selection = TextSelection.between(editor.state.doc.resolve(ret.start), editor.state.doc.resolve(ret.start + ret.frag.firstChild.nodeSize)); // bcz: looks better to not have the target selected + } + editor.dispatch(editor.state.tr.setSelection(new TextSelection(selection.$from, selection.$from)).scrollIntoView()); + const mark = editor.state.schema.mark(this._editorView.state.schema.marks.search_highlight); + setTimeout(() => editor.dispatch(editor.state.tr.addMark(selection.from, selection.to, mark)), 0); + setTimeout(() => this.unhighlightSearchTerms(), 2000); + } + Doc.SetInPlace(this.layoutDoc, "scrollToLinkID", undefined, false); + } + + }, + { fireImmediately: true } + ); + this._disposers.scroll = reaction(() => NumCast(this.props.Document.scrollPos), + pos => this._scrollRef.current && this._scrollRef.current.scrollTo({ top: pos }), { fireImmediately: true } + ); + + setTimeout(() => this.tryUpdateHeight(NumCast(this.layoutDoc.limitHeight, 0))); + } + + pushToGoogleDoc = async () => { + this.pullFromGoogleDoc(async (exportState: Opt, dataDoc: Doc) => { + const modes = GoogleApiClientUtils.Docs.WriteMode; + let mode = modes.Replace; + let reference: Opt = Cast(this.dataDoc[GoogleRef], "string"); + if (!reference) { + mode = modes.Insert; + reference = { title: StrCast(this.dataDoc.title) }; + } + const redo = async () => { + if (this._editorView && reference) { + const content = await RichTextUtils.GoogleDocs.Export(this._editorView.state); + const response = await GoogleApiClientUtils.Docs.write({ reference, content, mode }); + response && (this.dataDoc[GoogleRef] = response.documentId); + const pushSuccess = response !== undefined && !("errors" in response); + dataDoc.unchanged = pushSuccess; + DocumentButtonBar.Instance.startPushOutcome(pushSuccess); + } + }; + const undo = () => { + if (!exportState) { + return; + } + const content: GoogleApiClientUtils.Docs.Content = { + text: exportState.text, + requests: [] + }; + if (reference && content) { + GoogleApiClientUtils.Docs.write({ reference, content, mode }); + } + }; + UndoManager.AddEvent({ undo, redo }); + redo(); + }); + } + + pullFromGoogleDoc = async (handler: PullHandler) => { + const dataDoc = this.dataDoc; + const documentId = StrCast(dataDoc[GoogleRef]); + let exportState: Opt; + if (documentId) { + exportState = await RichTextUtils.GoogleDocs.Import(documentId, dataDoc); + } + UndoManager.RunInBatch(() => handler(exportState, dataDoc), Pulls); + } + + updateState = (exportState: Opt, dataDoc: Doc) => { + let pullSuccess = false; + if (exportState !== undefined) { + pullSuccess = true; + dataDoc.data = new RichTextField(JSON.stringify(exportState.state.toJSON())); + setTimeout(() => { + if (this._editorView) { + const state = this._editorView.state; + const end = state.doc.content.size - 1; + this._editorView.dispatch(state.tr.setSelection(TextSelection.create(state.doc, end, end))); + } + }, 0); + dataDoc.title = exportState.title; + this.rootDoc.customTitle = true; + dataDoc.unchanged = true; + } else { + delete dataDoc[GoogleRef]; + } + DocumentButtonBar.Instance.startPullOutcome(pullSuccess); + } + + checkState = (exportState: Opt, dataDoc: Doc) => { + if (exportState && this._editorView) { + const equalContent = isEqual(this._editorView.state.doc, exportState.state.doc); + const equalTitles = dataDoc.title === exportState.title; + const unchanged = equalContent && equalTitles; + dataDoc.unchanged = unchanged; + DocumentButtonBar.Instance.setPullState(unchanged); + } + } + + clipboardTextSerializer = (slice: Slice): string => { + let text = "", separated = true; + const from = 0, to = slice.content.size; + slice.content.nodesBetween(from, to, (node, pos) => { + if (node.isText) { + text += node.text!.slice(Math.max(from, pos) - pos, to - pos); + separated = false; + } else if (!separated && node.isBlock) { + text += "\n"; + separated = true; + } else if (node.type.name === "hard_break") { + text += "\n"; + } + }, 0); + return text; + } + + sliceSingleNode(slice: Slice) { + return slice.openStart === 0 && slice.openEnd === 0 && slice.content.childCount === 1 ? slice.content.firstChild : null; + } + + handlePaste = (view: EditorView, event: Event, slice: Slice): boolean => { + const cbe = event as ClipboardEvent; + const pdfDocId = cbe.clipboardData && cbe.clipboardData.getData("dash/pdfOrigin"); + const pdfRegionId = cbe.clipboardData && cbe.clipboardData.getData("dash/pdfRegion"); + if (pdfDocId && pdfRegionId) { + DocServer.GetRefField(pdfDocId).then(pdfDoc => { + DocServer.GetRefField(pdfRegionId).then(pdfRegion => { + if ((pdfDoc instanceof Doc) && (pdfRegion instanceof Doc)) { + setTimeout(async () => { + const targetField = Doc.LayoutFieldKey(pdfDoc); + const targetAnnotations = await DocListCastAsync(pdfDoc[DataSym][targetField + "-annotations"]);// bcz: better to have the PDF's view handle updating its own annotations + targetAnnotations?.push(pdfRegion); + }); + + const link = DocUtils.MakeLink({ doc: this.props.Document }, { doc: pdfRegion }, "PDF pasted"); + if (link) { + cbe.clipboardData!.setData("dash/linkDoc", link[Id]); + const linkId = link[Id]; + const frag = addMarkToFrag(slice.content, (node: Node) => addLinkMark(node, StrCast(pdfDoc.title), linkId)); + slice = new Slice(frag, slice.openStart, slice.openEnd); + const tr = view.state.tr.replaceSelection(slice); + view.dispatch(tr.scrollIntoView().setMeta("paste", true).setMeta("uiEvent", "paste")); + } + } + }); + }); + return true; + } + return false; + + + function addMarkToFrag(frag: Fragment, marker: (node: Node) => Node) { + const nodes: Node[] = []; + frag.forEach(node => nodes.push(marker(node))); + return Fragment.fromArray(nodes); + } + function addLinkMark(node: Node, title: string, linkId: string) { + if (!node.isText) { + const content = addMarkToFrag(node.content, (node: Node) => addLinkMark(node, title, linkId)); + return node.copy(content); + } + const marks = [...node.marks]; + const linkIndex = marks.findIndex(mark => mark.type.name === "link"); + const link = view.state.schema.mark(view.state.schema.marks.link, { href: `http://localhost:1050/doc/${linkId}`, location: "onRight", title: title, docref: true }); + marks.splice(linkIndex === -1 ? 0 : linkIndex, 1, link); + return node.mark(marks); + } + } + + private setupEditor(config: any, fieldKey: string) { + const curText = Cast(this.dataDoc[this.props.fieldKey], RichTextField, null); + const useTemplate = !curText?.Text && this.props.Document[this.props.fieldKey + "-textTemplate"]; + const rtfField = Cast((useTemplate && this.props.Document[this.props.fieldKey + "-textTemplate"]) || this.dataDoc[fieldKey], RichTextField); + if (this.ProseRef) { + const self = this; + this._editorView?.destroy(); + this._editorView = new EditorView(this.ProseRef, { + state: rtfField?.Data ? EditorState.fromJSON(config, JSON.parse(rtfField.Data)) : EditorState.create(config), + handleScrollToSelection: (editorView) => { + const docPos = editorView.coordsAtPos(editorView.state.selection.from); + const viewRect = self._ref.current!.getBoundingClientRect(); + if (docPos.top < viewRect.top || docPos.top > viewRect.bottom) { + docPos && (self._scrollRef.current!.scrollTop += (docPos.top - viewRect.top) * self.props.ScreenToLocalTransform().Scale); + } + return true; + }, + dispatchTransaction: this.dispatchTransaction, + nodeViews: { + dashComment(node, view, getPos) { return new DashDocCommentView(node, view, getPos); }, + dashField(node, view, getPos) { return new DashFieldView(node, view, getPos, self); }, + dashDoc(node, view, getPos) { return new DashDocView(node, view, getPos, self); }, + // dashDoc(node, view, getPos) { return new DashDocView({ node, view, getPos, self }); }, + + // image(node, view, getPos) { + // //const addDocTab = this.props.addDocTab; + // return new ImageResizeView({ node, view, getPos, addDocTab: this.props.addDocTab }); + // }, + // // WAS : + // //image(node, view, getPos) { return new ImageResizeView(node, view, getPos, this.props.addDocTab); }, + + summary(node, view, getPos) { return new SummaryView(node, view, getPos); }, + ordered_list(node, view, getPos) { return new OrderedListView(); }, + footnote(node, view, getPos) { return new FootnoteView(node, view, getPos); } + }, + clipboardTextSerializer: this.clipboardTextSerializer, + handlePaste: this.handlePaste, + }); + const startupText = !rtfField && this._editorView && Field.toString(this.dataDoc[fieldKey] as Field); + if (startupText) { + const { state: { tr }, dispatch } = this._editorView; + dispatch(tr.insertText(startupText)); + } + } + + const selectOnLoad = this.rootDoc[Id] === FormattedTextBox.SelectOnLoad; + if (selectOnLoad && !this.props.dontRegisterView) { + FormattedTextBox.SelectOnLoad = ""; + this.props.select(false); + FormattedTextBox.SelectOnLoadChar && this._editorView!.dispatch(this._editorView!.state.tr.insertText(FormattedTextBox.SelectOnLoadChar)); + FormattedTextBox.SelectOnLoadChar = ""; + + } + (selectOnLoad /* || !rtfField?.Text*/) && this._editorView!.focus(); + // add user mark for any first character that was typed since the user mark that gets set in KeyPress won't have been called yet. + this._editorView!.state.storedMarks = [...(this._editorView!.state.storedMarks ? this._editorView!.state.storedMarks : []), schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.floor(Date.now() / 1000) })]; + } + getFont(font: string) { + switch (font) { + case "Arial": return schema.marks.arial.create(); + case "Times New Roman": return schema.marks.timesNewRoman.create(); + case "Georgia": return schema.marks.georgia.create(); + case "Comic Sans MS": return schema.marks.comicSans.create(); + case "Tahoma": return schema.marks.tahoma.create(); + case "Impact": return schema.marks.impact.create(); + case "ACrimson Textrial": return schema.marks.crimson.create(); + } + return schema.marks.arial.create(); + } + + componentWillUnmount() { + Object.values(this._disposers).forEach(disposer => disposer?.()); + this._editorView?.destroy(); + } + + static _downEvent: any; + _downX = 0; + _downY = 0; + _break = false; + onPointerDown = (e: React.PointerEvent): void => { + if (this._recording && !e.ctrlKey && e.button === 0) { + this.stopDictation(true); + this._break = true; + const state = this._editorView!.state; + const to = state.selection.to; + const updated = TextSelection.create(state.doc, to, to); + this._editorView!.dispatch(this._editorView!.state.tr.setSelection(updated).insertText("\n", to)); + e.preventDefault(); + e.stopPropagation(); + if (this._recording) setTimeout(() => this.recordDictation(), 500); + } + this._downX = e.clientX; + this._downY = e.clientY; + this.doLinkOnDeselect(); + FormattedTextBox._downEvent = true; + FormattedTextBoxComment.textBox = this; + if (this.props.onClick && e.button === 0 && !this.props.isSelected(false)) { + e.preventDefault(); + } + if (e.button === 0 && this.active(true) && !e.altKey && !e.ctrlKey && !e.metaKey) { + if (e.clientX < this.ProseRef!.getBoundingClientRect().right) { // don't stop propagation if clicking in the sidebar + e.stopPropagation(); + } + } + if (e.button === 2 || (e.button === 0 && e.ctrlKey)) { + e.preventDefault(); + } + } + + onPointerUp = (e: React.PointerEvent): void => { + if (!FormattedTextBox._downEvent) return; + FormattedTextBox._downEvent = false; + if (!(e.nativeEvent as any).formattedHandled) { + FormattedTextBoxComment.textBox = this; + FormattedTextBoxComment.update(this._editorView!); + } + (e.nativeEvent as any).formattedHandled = true; + + if (e.buttons === 1 && this.props.isSelected(true) && !e.altKey) { + e.stopPropagation(); + } + this._downX = this._downY = Number.NaN; + } + + @action + onFocused = (e: React.FocusEvent): void => { + FormattedTextBox.FocusedBox = this; + this.tryUpdateHeight(); + + // see if we need to preserve the insertion point + const prosediv = this.ProseRef?.children?.[0] as any; + const keeplocation = prosediv?.keeplocation; + prosediv && (prosediv.keeplocation = undefined); + const pos = this._editorView?.state.selection.$from.pos || 1; + keeplocation && setTimeout(() => this._editorView?.dispatch(this._editorView?.state.tr.setSelection(TextSelection.create(this._editorView.state.doc, pos)))); + const coords = !Number.isNaN(this._downX) ? { left: this._downX, top: this._downY, bottom: this._downY, right: this._downX } : this._editorView?.coordsAtPos(pos); + + // jump rich text menu to this textbox + const bounds = this._ref.current?.getBoundingClientRect(); + if (bounds && this.props.Document._chromeStatus !== "disabled") { + const x = Math.min(Math.max(bounds.left, 0), window.innerWidth - RichTextMenu.Instance.width); + let y = Math.min(Math.max(0, bounds.top - RichTextMenu.Instance.height - 50), window.innerHeight - RichTextMenu.Instance.height); + if (coords && coords.left > x && coords.left < x + RichTextMenu.Instance.width && coords.top > y && coords.top < y + RichTextMenu.Instance.height + 50) { + y = Math.min(bounds.bottom, window.innerHeight - RichTextMenu.Instance.height); + } + RichTextMenu.Instance.jumpTo(x, y); + } + } + onPointerWheel = (e: React.WheelEvent): void => { + // if a text note is not selected and scrollable, this prevents us from being able to scroll and zoom out at the same time + if (this.props.isSelected(true) || e.currentTarget.scrollHeight > e.currentTarget.clientHeight) { + e.stopPropagation(); + } + } + + static _bulletStyleSheet: any = addStyleSheet(); + static _userStyleSheet: any = addStyleSheet(); + + onClick = (e: React.MouseEvent): void => { + if ((this._editorView!.root as any).getSelection().isCollapsed) { // this is a hack to allow the cursor to be placed at the end of a document when the document ends in an inline dash comment. Apparently Chrome on Windows has a bug/feature which breaks this when clicking after the end of the text. + const pcords = this._editorView!.posAtCoords({ left: e.clientX, top: e.clientY }); + const node = pcords && this._editorView!.state.doc.nodeAt(pcords.pos); // get what prosemirror thinks the clicked node is (if it's null, then we didn't click on any text) + if (pcords && node?.type === this._editorView!.state.schema.nodes.dashComment) { + this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, pcords.pos + 2))); + e.preventDefault(); + } + if (!node && this.ProseRef) { + const lastNode = this.ProseRef.children[this.ProseRef.children.length - 1].children[this.ProseRef.children[this.ProseRef.children.length - 1].children.length - 1]; // get the last prosemirror div + if (e.clientY > lastNode?.getBoundingClientRect().bottom) { // if we clicked below the last prosemirror div, then set the selection to be the end of the document + this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, this._editorView!.state.doc.content.size))); + } + } + } + if ((e.nativeEvent as any).formattedHandled) { e.stopPropagation(); return; } + (e.nativeEvent as any).formattedHandled = true; + // if (e.button === 0 && ((!this.props.isSelected(true) && !e.ctrlKey) || (this.props.isSelected(true) && e.ctrlKey)) && !e.metaKey && e.target) { + // let href = (e.target as any).href; + // let location: string; + // if ((e.target as any).attributes.location) { + // location = (e.target as any).attributes.location.value; + // } + // let pcords = this._editorView!.posAtCoords({ left: e.clientX, top: e.clientY }); + // let node = pcords && this._editorView!.state.doc.nodeAt(pcords.pos); + // if (node) { + // let link = node.marks.find(m => m.type === this._editorView!.state.schema.marks.link); + // if (link && !(link.attrs.docref && link.attrs.title)) { // bcz: getting hacky. this indicates that we clicked on a PDF excerpt quotation. In this case, we don't want to follow the link (we follow only the actual hyperlink for the quotation which is handled above). + // href = link && link.attrs.href; + // location = link && link.attrs.location; + // } + // } + // if (href) { + // if (href.indexOf(Utils.prepend("/doc/")) === 0) { + // let linkClicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0]; + // if (linkClicked) { + // DocServer.GetRefField(linkClicked).then(async linkDoc => { + // (linkDoc instanceof Doc) && + // DocumentManager.Instance.FollowLink(linkDoc, this.props.Document, document => this.props.addDocTab(document, location ? location : "inTab"), false); + // }); + // } + // } else { + // let webDoc = Docs.Create.WebDocument(href, { x: NumCast(this.layoutDoc.x, 0) + NumCast(this.layoutDoc.width, 0), y: NumCast(this.layoutDoc.y) }); + // this.props.addDocument && this.props.addDocument(webDoc); + // } + // e.stopPropagation(); + // e.preventDefault(); + // } + // } + + if (Math.abs(e.clientX - this._downX) < 4 && Math.abs(e.clientX - this._downX) < 4) { + this.props.select(e.ctrlKey); + this.hitBulletTargets(e.clientX, e.clientY, e.shiftKey, false); + } + } + + // this hackiness handles clicking on the list item bullets to do expand/collapse. the bullets are ::before pseudo elements so there's no real way to hit test against them. + hitBulletTargets(x: number, y: number, select: boolean, highlightOnly: boolean) { + clearStyleSheetRules(FormattedTextBox._bulletStyleSheet); + const pos = this._editorView!.posAtCoords({ left: x, top: y }); + if (pos && this.props.isSelected(true)) { + // let beforeEle = document.querySelector("." + hit.className) as Element; // const before = hit ? window.getComputedStyle(hit, ':before') : undefined; + //const node = this._editorView!.state.doc.nodeAt(pos.pos); + const $pos = this._editorView!.state.doc.resolve(pos.pos); + let list_node = $pos.node().type === schema.nodes.list_item ? $pos.node() : undefined; + if ($pos.node().type === schema.nodes.ordered_list) { + for (let off = 1; off < 100; off++) { + const pos = this._editorView!.posAtCoords({ left: x + off, top: y }); + const node = pos && this._editorView!.state.doc.nodeAt(pos.pos); + if (node?.type === schema.nodes.list_item) { + list_node = node; + break; + } + } + } + if (list_node && pos.inside >= 0 && this._editorView!.state.doc.nodeAt(pos.inside)!.attrs.bulletStyle === list_node.attrs.bulletStyle) { + if (select) { + const $olist_pos = this._editorView!.state.doc.resolve($pos.pos - $pos.parentOffset - 1); + if (!highlightOnly) { + this._editorView!.dispatch(this._editorView!.state.tr.setSelection(new NodeSelection($olist_pos))); + } + addStyleSheetRule(FormattedTextBox._bulletStyleSheet, list_node.attrs.mapStyle + list_node.attrs.bulletStyle + ":hover:before", { background: "lightgray" }); + } else if (Math.abs(pos.pos - pos.inside) < 2) { + if (!highlightOnly) { + const offset = this._editorView!.state.doc.nodeAt(pos.inside)?.type === schema.nodes.ordered_list ? 1 : 0; + this._editorView!.dispatch(this._editorView!.state.tr.setNodeMarkup(pos.inside + offset, list_node.type, { ...list_node.attrs, visibility: !list_node.attrs.visibility })); + this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, pos.inside + offset))); + } + addStyleSheetRule(FormattedTextBox._bulletStyleSheet, list_node.attrs.mapStyle + list_node.attrs.bulletStyle + ":hover:before", { background: "lightgray" }); + } + } + } + } + onMouseUp = (e: React.MouseEvent): void => { + e.stopPropagation(); + + const view = this._editorView as any; + // this interposes on prosemirror's upHandler to prevent prosemirror's up from invoked multiple times when there + // are nested prosemirrors. We only want the lowest level prosemirror to be invoked. + if (view.mouseDown) { + const originalUpHandler = view.mouseDown.up; + view.root.removeEventListener("mouseup", originalUpHandler); + view.mouseDown.up = (e: MouseEvent) => { + !(e as any).formattedHandled && originalUpHandler(e); + // e.stopPropagation(); + (e as any).formattedHandled = true; + }; + view.root.addEventListener("mouseup", view.mouseDown.up); + } + } + + richTextMenuPlugin() { + return new Plugin({ + view(newView) { + RichTextMenu.Instance && RichTextMenu.Instance.changeView(newView); + return RichTextMenu.Instance; + } + }); + } + + public static HadSelection: boolean = false; + onBlur = (e: any) => { + FormattedTextBox.HadSelection = window.getSelection()?.toString() !== ""; + //DictationManager.Controls.stop(false); + if (this._undoTyping) { + this._undoTyping.end(); + this._undoTyping = undefined; + } + this.doLinkOnDeselect(); + + // move the richtextmenu offscreen + if (!RichTextMenu.Instance.Pinned && !RichTextMenu.Instance.overMenu) RichTextMenu.Instance.jumpTo(-300, -300); + } + + _lastTimedMark: Mark | undefined = undefined; + onKeyPress = (e: React.KeyboardEvent) => { + if (e.altKey) { + e.preventDefault(); + return; + } + const state = this._editorView!.state; + if (!state.selection.empty && e.key === "%") { + this._rules!.EnteringStyle = true; + e.preventDefault(); + e.stopPropagation(); + return; + } + + if (state.selection.empty || !this._rules!.EnteringStyle) { + this._rules!.EnteringStyle = false; + } + if (e.key === "Escape") { + this._editorView!.dispatch(state.tr.setSelection(TextSelection.create(state.doc, state.selection.from, state.selection.from))); + (document.activeElement as any).blur?.(); + SelectionManager.DeselectAll(); + } + e.stopPropagation(); + if (e.key === "Tab" || e.key === "Enter") { + e.preventDefault(); + } + const mark = e.key !== " " && this._lastTimedMark ? this._lastTimedMark : schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.floor(Date.now() / 1000) }); + this._lastTimedMark = mark; + this._editorView!.dispatch(this._editorView!.state.tr.removeStoredMark(schema.marks.user_mark.create({})).addStoredMark(mark)); + + if (!this._undoTyping) { + this._undoTyping = UndoManager.StartBatch("undoTyping"); + } + } + + onscrolled = (ev: React.UIEvent) => { + this.props.Document.scrollPos = this._scrollRef.current!.scrollTop; + } + @action + tryUpdateHeight(limitHeight?: number) { + let scrollHeight = this._ref.current?.scrollHeight; + if (this.layoutDoc._autoHeight && scrollHeight && + getComputedStyle(this._ref.current!.parentElement!).top === "0px") { // if top === 0, then the text box is growing upward (as the overlay caption) which doesn't contribute to the height computation + if (limitHeight && scrollHeight > limitHeight) { + scrollHeight = limitHeight; + this.layoutDoc.limitHeight = undefined; + this.layoutDoc._autoHeight = false; + } + const nh = this.layoutDoc.isTemplateForField ? 0 : NumCast(this.dataDoc._nativeHeight, 0); + const dh = NumCast(this.layoutDoc._height, 0); + const newHeight = Math.max(10, (nh ? dh / nh * scrollHeight : scrollHeight) + (this.props.ChromeHeight ? this.props.ChromeHeight() : 0)); + if (Math.abs(newHeight - dh) > 1) { // bcz: Argh! without this, we get into a React crash if the same document is opened in a freeform view and in the treeview. no idea why, but after dragging the freeform document, selecting it, and selecting text, it will compute to 1 pixel higher than the treeview which causes a cycle + this.layoutDoc._height = newHeight; + this.dataDoc._nativeHeight = nh ? scrollHeight : undefined; + } + } + } + + @computed get sidebarWidthPercent() { return StrCast(this.props.Document.sidebarWidthPercent, "0%"); } + sidebarWidth = () => Number(this.sidebarWidthPercent.substring(0, this.sidebarWidthPercent.length - 1)) / 100 * this.props.PanelWidth(); + sidebarScreenToLocal = () => this.props.ScreenToLocalTransform().translate(-(this.props.PanelWidth() - this.sidebarWidth()), 0); + @computed get sidebarColor() { return StrCast(this.layoutDoc[this.props.fieldKey + "-backgroundColor"], StrCast(this.layoutDoc[this.props.fieldKey + "-backgroundColor"], "transparent")); } + render() { + TraceMobx(); + const rounded = StrCast(this.layoutDoc.borderRounding) === "100%" ? "-rounded" : ""; + const interactive = InkingControl.Instance.selectedTool || this.layoutDoc.isBackground; + if (this.props.isSelected()) { + this._editorView && RichTextMenu.Instance.updateFromDash(this._editorView, undefined, this.props); + } else if (FormattedTextBoxComment.textBox === this) { + FormattedTextBoxComment.Hide(); + } + return ( + +
this.hitBulletTargets(e.clientX, e.clientY, e.shiftKey, true)} + onBlur={this.onBlur} + onPointerUp={this.onPointerUp} + onPointerDown={this.onPointerDown} + onMouseUp={this.onMouseUp} + onWheel={this.onPointerWheel} + onPointerEnter={action(() => this._entered = true)} + onPointerLeave={action((e: React.PointerEvent) => { + this._entered = false; + const target = document.elementFromPoint(e.nativeEvent.x, e.nativeEvent.y); + for (let child: any = target; child; child = child?.parentElement) { + if (child === this._ref.current!) { + this._entered = true; + } + } + })} + > +
+
+
+ {!this.props.Document._showSidebar ? (null) : this.sidebarWidthPercent === "0%" ? +
this.toggleSidebar()} /> : +
+ + +
this.toggleSidebar()} /> +
} + {!this.props.Document._showAudio ? (null) : +
{ + runInAction(() => this._recording = !this._recording); + setTimeout(() => this._editorView!.focus(), 500); + e.stopPropagation(); + }} > + +
} +
+ ); + } +} diff --git a/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss b/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss new file mode 100644 index 000000000..2dd63ec21 --- /dev/null +++ b/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss @@ -0,0 +1,33 @@ +.FormattedTextBox-tooltip { + position: absolute; + pointer-events: none; + z-index: 20; + background: white; + border: 1px solid silver; + border-radius: 2px; + margin-bottom: 7px; + -webkit-transform: translateX(-50%); + transform: translateX(-50%); + } + .FormattedTextBox-tooltip:before { + content: ""; + height: 0; width: 0; + position: absolute; + left: 50%; + margin-left: -5px; + bottom: -6px; + border: 5px solid transparent; + border-bottom-width: 0; + border-top-color: silver; + } + .FormattedTextBox-tooltip:after { + content: ""; + height: 0; width: 0; + position: absolute; + left: 50%; + margin-left: -5px; + bottom: -4.5px; + border: 5px solid transparent; + border-bottom-width: 0; + border-top-color: white; + } \ No newline at end of file diff --git a/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx b/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx new file mode 100644 index 000000000..f9e4c5210 --- /dev/null +++ b/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx @@ -0,0 +1,236 @@ +import { Mark, ResolvedPos } from "prosemirror-model"; +import { EditorState, Plugin } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import * as ReactDOM from 'react-dom'; +import { Doc, DocCastAsync } from "../../../../new_fields/Doc"; +import { Cast, FieldValue, NumCast } from "../../../../new_fields/Types"; +import { emptyFunction, returnEmptyString, returnFalse, Utils, emptyPath } from "../../../../Utils"; +import { DocServer } from "../../../DocServer"; +import { DocumentManager } from "../../../util/DocumentManager"; +import { schema } from "./schema_rts"; +import { Transform } from "../../../util/Transform"; +import { ContentFittingDocumentView } from "../ContentFittingDocumentView"; +import { FormattedTextBox } from "./FormattedTextBox"; +import './FormattedTextBoxComment.scss'; +import React = require("react"); +import { Docs } from "../../../documents/Documents"; +import wiki from "wikijs"; +import { DocumentType } from "../../../documents/DocumentTypes"; + +export let formattedTextBoxCommentPlugin = new Plugin({ + view(editorView) { return new FormattedTextBoxComment(editorView); } +}); +export function findOtherUserMark(marks: Mark[]): Mark | undefined { + return marks.find(m => m.attrs.userid && m.attrs.userid !== Doc.CurrentUserEmail); +} +export function findUserMark(marks: Mark[]): Mark | undefined { + return marks.find(m => m.attrs.userid); +} +export function findLinkMark(marks: Mark[]): Mark | undefined { + return marks.find(m => m.type === schema.marks.link); +} +export function findStartOfMark(rpos: ResolvedPos, view: EditorView, finder: (marks: Mark[]) => Mark | undefined) { + let before = 0; + let nbef = rpos.nodeBefore; + while (nbef && finder(nbef.marks)) { + before += nbef.nodeSize; + rpos = view.state.doc.resolve(rpos.pos - nbef.nodeSize); + rpos && (nbef = rpos.nodeBefore); + } + return before; +} +export function findEndOfMark(rpos: ResolvedPos, view: EditorView, finder: (marks: Mark[]) => Mark | undefined) { + let after = 0; + let naft = rpos.nodeAfter; + while (naft && finder(naft.marks)) { + after += naft.nodeSize; + rpos = view.state.doc.resolve(rpos.pos + naft.nodeSize); + rpos && (naft = rpos.nodeAfter); + } + return after; +} + + +export class FormattedTextBoxComment { + static tooltip: HTMLElement; + static tooltipText: HTMLElement; + static tooltipInput: HTMLInputElement; + static start: number; + static end: number; + static mark: Mark; + static textBox: FormattedTextBox | undefined; + static linkDoc: Doc | undefined; + constructor(view: any) { + if (!FormattedTextBoxComment.tooltip) { + const root = document.getElementById("root"); + FormattedTextBoxComment.tooltipInput = document.createElement("input"); + FormattedTextBoxComment.tooltipInput.type = "checkbox"; + FormattedTextBoxComment.tooltip = document.createElement("div"); + FormattedTextBoxComment.tooltipText = document.createElement("div"); + FormattedTextBoxComment.tooltipText.style.width = "100%"; + FormattedTextBoxComment.tooltipText.style.height = "100%"; + FormattedTextBoxComment.tooltipText.style.textOverflow = "ellipsis"; + FormattedTextBoxComment.tooltip.appendChild(FormattedTextBoxComment.tooltipText); + FormattedTextBoxComment.tooltip.className = "FormattedTextBox-tooltip"; + FormattedTextBoxComment.tooltip.style.pointerEvents = "all"; + FormattedTextBoxComment.tooltip.style.maxWidth = "350px"; + FormattedTextBoxComment.tooltip.style.maxHeight = "250px"; + FormattedTextBoxComment.tooltip.style.width = "100%"; + FormattedTextBoxComment.tooltip.style.height = "100%"; + FormattedTextBoxComment.tooltip.style.overflow = "hidden"; + FormattedTextBoxComment.tooltip.style.display = "none"; + FormattedTextBoxComment.tooltip.appendChild(FormattedTextBoxComment.tooltipInput); + FormattedTextBoxComment.tooltip.onpointerdown = (e: PointerEvent) => { + const keep = e.target && (e.target as any).type === "checkbox" ? true : false; + const textBox = FormattedTextBoxComment.textBox; + if (FormattedTextBoxComment.linkDoc && !keep && textBox) { + if (FormattedTextBoxComment.linkDoc.type !== DocumentType.LINK) { + textBox.props.addDocTab(FormattedTextBoxComment.linkDoc, e.ctrlKey ? "inTab" : "onRight"); + } else { + DocumentManager.Instance.FollowLink(FormattedTextBoxComment.linkDoc, textBox.props.Document, + (doc: Doc, followLinkLocation: string) => textBox.props.addDocTab(doc, e.ctrlKey ? "inTab" : followLinkLocation)); + } + } else if (textBox && (FormattedTextBoxComment.tooltipText as any).href) { + textBox.props.addDocTab(Docs.Create.WebDocument((FormattedTextBoxComment.tooltipText as any).href, { title: (FormattedTextBoxComment.tooltipText as any).href, _width: 200, _height: 400 }), "onRight"); + } + keep && textBox && FormattedTextBoxComment.start !== undefined && textBox.adoptAnnotation( + FormattedTextBoxComment.start, FormattedTextBoxComment.end, FormattedTextBoxComment.mark); + e.stopPropagation(); + e.preventDefault(); + }; + root && root.appendChild(FormattedTextBoxComment.tooltip); + } + } + + public static Hide() { + FormattedTextBoxComment.textBox = undefined; + FormattedTextBoxComment.tooltip && (FormattedTextBoxComment.tooltip.style.display = "none"); + ReactDOM.unmountComponentAtNode(FormattedTextBoxComment.tooltipText); + } + public static SetState(textBox: any, start: number, end: number, mark: Mark) { + FormattedTextBoxComment.textBox = textBox; + FormattedTextBoxComment.start = start; + FormattedTextBoxComment.end = end; + FormattedTextBoxComment.mark = mark; + FormattedTextBoxComment.tooltip && (FormattedTextBoxComment.tooltip.style.display = ""); + } + + static update(view: EditorView, lastState?: EditorState) { + const 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; + } + FormattedTextBoxComment.linkDoc = undefined; + + const textBox = FormattedTextBoxComment.textBox; + if (!textBox || !textBox.props) { + return; + } + let set = "none"; + let nbef = 0; + FormattedTextBoxComment.tooltipInput.style.display = "none"; + FormattedTextBoxComment.tooltip.style.width = ""; + FormattedTextBoxComment.tooltip.style.height = ""; + (FormattedTextBoxComment.tooltipText as any).href = ""; + FormattedTextBoxComment.tooltipText.style.whiteSpace = ""; + FormattedTextBoxComment.tooltipText.style.overflow = ""; + // this section checks to see if the insertion point is over text entered by a different user. If so, it sets ths comment text to indicate the user and the modification date + if (state.selection.$from) { + nbef = findStartOfMark(state.selection.$from, view, findOtherUserMark); + const naft = findEndOfMark(state.selection.$from, view, findOtherUserMark); + const noselection = view.state.selection.$from === view.state.selection.$to; + let child: any = null; + state.doc.nodesBetween(state.selection.from, state.selection.to, (node: any, pos: number, parent: any) => !child && node.marks.length && (child = node)); + const mark = child && findOtherUserMark(child.marks); + if (mark && child && (nbef || naft) && (!mark.attrs.opened || noselection)) { + FormattedTextBoxComment.SetState(FormattedTextBoxComment.textBox, state.selection.$from.pos - nbef, state.selection.$from.pos + naft, mark); + } + if (mark && child && ((nbef && naft) || !noselection)) { + FormattedTextBoxComment.tooltipText.textContent = mark.attrs.userid + " date=" + (new Date(mark.attrs.modified * 5000)).toDateString(); + set = ""; + FormattedTextBoxComment.tooltipInput.style.display = ""; + } + } + // this checks if the selection is a hyperlink. If so, it displays the target doc's text for internal links, and the url of the target for external links. + if (set === "none" && state.selection.$from) { + nbef = findStartOfMark(state.selection.$from, view, findLinkMark); + const naft = findEndOfMark(state.selection.$from, view, findLinkMark); + let child: any = null; + state.doc.nodesBetween(state.selection.from, state.selection.to, (node: any, pos: number, parent: any) => !child && node.marks.length && (child = node)); + const mark = child && findLinkMark(child.marks); + if (mark && child && nbef && naft && mark.attrs.showPreview) { + FormattedTextBoxComment.tooltipText.textContent = "external => " + mark.attrs.href; + (FormattedTextBoxComment.tooltipText as any).href = mark.attrs.href; + if (mark.attrs.href.startsWith("https://en.wikipedia.org/wiki/")) { + wiki().page(mark.attrs.href.replace("https://en.wikipedia.org/wiki/", "")).then(page => page.summary().then(summary => FormattedTextBoxComment.tooltipText.textContent = summary.substring(0, 500))); + } else { + FormattedTextBoxComment.tooltipText.style.whiteSpace = "pre"; + FormattedTextBoxComment.tooltipText.style.overflow = "hidden"; + } + if (mark.attrs.href.indexOf(Utils.prepend("/doc/")) === 0) { + FormattedTextBoxComment.tooltipText.textContent = "target not found..."; + (FormattedTextBoxComment.tooltipText as any).href = ""; + const docTarget = mark.attrs.href.replace(Utils.prepend("/doc/"), "").split("?")[0]; + try { + ReactDOM.unmountComponentAtNode(FormattedTextBoxComment.tooltipText); + } catch (e) { } + docTarget && DocServer.GetRefField(docTarget).then(async linkDoc => { + if (linkDoc instanceof Doc) { + (FormattedTextBoxComment.tooltipText as any).href = mark.attrs.href; + FormattedTextBoxComment.linkDoc = linkDoc; + const anchor = FieldValue(Doc.AreProtosEqual(FieldValue(Cast(linkDoc.anchor1, Doc)), textBox.dataDoc) ? Cast(linkDoc.anchor2, Doc) : (Cast(linkDoc.anchor1, Doc)) || linkDoc); + const target = anchor?.annotationOn ? await DocCastAsync(anchor.annotationOn) : anchor; + if (anchor !== target && anchor && target) { + target.scrollY = NumCast(anchor?.y); + } + if (target) { + ReactDOM.render( Math.min(350, NumCast(target._width, 350))} + PanelHeight={() => Math.min(250, NumCast(target._height, 250))} + focus={emptyFunction} + whenActiveChanged={returnFalse} + />, FormattedTextBoxComment.tooltipText); + FormattedTextBoxComment.tooltip.style.width = NumCast(target.width) ? `${NumCast(target.width)}` : "100%"; + FormattedTextBoxComment.tooltip.style.height = NumCast(target.height) ? `${NumCast(target.height)}` : "100%"; + } + // let ext = (target && target.type !== DocumentType.PDFANNO && Doc.fieldExtensionDoc(target, "data")) || target; // try guessing that the target doc's data is in the 'data' field. probably need an 'overviewLayout' and then just display the target Document .... + // let text = ext && StrCast(ext.text); + // ext && (FormattedTextBoxComment.tooltipText.textContent = (target && target.type === DocumentType.PDFANNO ? "Quoted from " : "") + "=> " + (text || StrCast(ext.title))); + } + }); + } + set = ""; + } + } + if (set !== "none") { + // These are in screen coordinates + // let start = view.coordsAtPos(state.selection.from), end = view.coordsAtPos(state.selection.to); + const start = view.coordsAtPos(state.selection.from - nbef), end = view.coordsAtPos(state.selection.from - nbef); + // The box in which the tooltip is positioned, to use as base + const box = (document.getElementsByClassName("mainView-container") as any)[0].getBoundingClientRect(); + // Find a center-ish x position from the selection endpoints (when + // crossing lines, end may be more to the left) + const left = Math.max((start.left + end.left) / 2, start.left + 3); + FormattedTextBoxComment.tooltip.style.left = (left - box.left) + "px"; + FormattedTextBoxComment.tooltip.style.bottom = (box.bottom - start.top) + "px"; + } + FormattedTextBoxComment.tooltip && (FormattedTextBoxComment.tooltip.style.display = set); + } + + destroy() { } +} diff --git a/src/client/views/nodes/formattedText/ImageResizeView.tsx b/src/client/views/nodes/formattedText/ImageResizeView.tsx new file mode 100644 index 000000000..8f98da0fd --- /dev/null +++ b/src/client/views/nodes/formattedText/ImageResizeView.tsx @@ -0,0 +1,138 @@ +import { NodeSelection } from "prosemirror-state"; +import { Doc } from "../../../../new_fields/Doc"; +import { DocServer } from "../../../DocServer"; +import { DocumentManager } from "../../../util/DocumentManager"; +import React = require("react"); + +import { schema } from "./schema_rts"; + +interface IImageResizeView { + node: any; + view: any; + getPos: any; + addDocTab: any; +} + +export class ImageResizeView extends React.Component { + constructor(props: IImageResizeView) { + super(props); + } + + onClickImg = (e: any) => { + e.stopPropagation(); + e.preventDefault(); + if (this.props.view.state.selection.node && this.props.view.state.selection.node.type !== this.props.view.state.schema.nodes.image) { + this.props.view.dispatch(this.props.view.state.tr.setSelection(new NodeSelection(this.props.view.state.doc.resolve(this.props.view.state.selection.from - 2)))); + } + } + + onPointerDownImg = (e: any) => { + if (e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); + DocServer.GetRefField(this.props.node.attrs.docid).then(async linkDoc => + (linkDoc instanceof Doc) && + DocumentManager.Instance.FollowLink(linkDoc, this.props.view.state.schema.Document, + document => this.props.addDocTab(document, this.props.node.attrs.location ? this.props.node.attrs.location : "inTab"), false)); + } + } + + onPointerDownHandle = (e: any) => { + e.preventDefault(); + e.stopPropagation(); + const elementImage = document.getElementById("imageId") as HTMLElement; + const wid = Number(getComputedStyle(elementImage).width.replace(/px/, "")); + const hgt = Number(getComputedStyle(elementImage).height.replace(/px/, "")); + const startX = e.pageX; + const startWidth = parseFloat(this.props.node.attrs.width); + + const onpointermove = (e: any) => { + const elementOuter = document.getElementById("outerId") as HTMLElement; + + const currentX = e.pageX; + const diffInPx = currentX - startX; + elementOuter.style.width = `${startWidth + diffInPx}`; + elementOuter.style.height = `${(startWidth + diffInPx) * hgt / wid}`; + }; + + const onpointerup = () => { + document.removeEventListener("pointermove", onpointermove); + document.removeEventListener("pointerup", onpointerup); + const pos = this.props.view.state.selection.from; + const elementOuter = document.getElementById("outerId") as HTMLElement; + this.props.view.dispatch(this.props.view.state.tr.setNodeMarkup(this.props.getPos(), null, { ...this.props.node.attrs, width: elementOuter.style.width, height: elementOuter.style.height })); + this.props.view.dispatch(this.props.view.state.tr.setSelection(new NodeSelection(this.props.view.state.doc.resolve(pos)))); + }; + + document.addEventListener("pointermove", onpointermove); + document.addEventListener("pointerup", onpointerup); + } + + selectNode() { + const elementImage = document.getElementById("imageId") as HTMLElement; + const elementHandle = document.getElementById("handleId") as HTMLElement; + + elementImage.classList.add("ProseMirror-selectednode"); + elementHandle.style.display = ""; + } + + deselectNode() { + const elementImage = document.getElementById("imageId") as HTMLElement; + const elementHandle = document.getElementById("handleId") as HTMLElement; + + elementImage.classList.remove("ProseMirror-selectednode"); + elementHandle.style.display = "none"; + } + + + render() { + + const outerStyle = { + width: this.props.node.attrs.width, + height: this.props.node.attrs.height, + display: "inline-block", + overflow: "hidden", + float: this.props.node.attrs.float + }; + + const imageStyle = { + width: "100%", + }; + + const handleStyle = { + position: "absolute", + width: "20px", + heiht: "20px", + backgroundColor: "blue", + borderRadius: "15px", + display: "none", + bottom: "-10px", + right: "-10px" + + }; + + + + return ( +
+ + + + + +
+ ); + } +} \ No newline at end of file diff --git a/src/client/views/nodes/formattedText/ParagraphNodeSpec.ts b/src/client/views/nodes/formattedText/ParagraphNodeSpec.ts new file mode 100644 index 000000000..d80e64634 --- /dev/null +++ b/src/client/views/nodes/formattedText/ParagraphNodeSpec.ts @@ -0,0 +1,143 @@ +import clamp from '../../../util/clamp'; +import convertToCSSPTValue from '../../../util/convertToCSSPTValue'; +import toCSSLineSpacing from '../../../util/toCSSLineSpacing'; +import { Node, DOMOutputSpec } from 'prosemirror-model'; + +//import type { NodeSpec } from './Types'; +type NodeSpec = { + attrs?: { [key: string]: any }, + content?: string, + draggable?: boolean, + group?: string, + inline?: boolean, + name?: string, + parseDOM?: Array, + toDOM?: (node: any) => DOMOutputSpec, +}; + +// This assumes that every 36pt maps to one indent level. +export const INDENT_MARGIN_PT_SIZE = 36; +export const MIN_INDENT_LEVEL = 0; +export const MAX_INDENT_LEVEL = 7; +export const ATTRIBUTE_INDENT = 'data-indent'; + +export const EMPTY_CSS_VALUE = new Set(['', '0%', '0pt', '0px']); + +const ALIGN_PATTERN = /(left|right|center|justify)/; + +// https://github.com/ProseMirror/prosemirror-schema-basic/blob/master/src/schema-basic.js +// :: NodeSpec A plain paragraph textblock. Represented in the DOM +// as a `

` element. +const ParagraphNodeSpec: NodeSpec = { + attrs: { + align: { default: null }, + color: { default: null }, + id: { default: null }, + indent: { default: null }, + inset: { default: null }, + lineSpacing: { default: null }, + // TODO: Add UI to let user edit / clear padding. + paddingBottom: { default: null }, + // TODO: Add UI to let user edit / clear padding. + paddingTop: { default: null }, + }, + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'p', getAttrs }], + toDOM, +}; + +function getAttrs(dom: HTMLElement): Object { + const { + lineHeight, + textAlign, + marginLeft, + paddingTop, + paddingBottom, + } = dom.style; + + let align = dom.getAttribute('align') || textAlign || ''; + align = ALIGN_PATTERN.test(align) ? align : ""; + + let indent = parseInt(dom.getAttribute(ATTRIBUTE_INDENT) || "", 10); + + if (!indent && marginLeft) { + indent = convertMarginLeftToIndentValue(marginLeft); + } + + indent = indent || MIN_INDENT_LEVEL; + + const lineSpacing = lineHeight ? toCSSLineSpacing(lineHeight) : null; + + const id = dom.getAttribute('id') || ''; + return { align, indent, lineSpacing, paddingTop, paddingBottom, id }; +} + +function toDOM(node: Node): DOMOutputSpec { + const { + align, + indent, + inset, + lineSpacing, + paddingTop, + paddingBottom, + id, + } = node.attrs; + const attrs: { [key: string]: any } | null = {}; + + let style = ''; + if (align && align !== 'left') { + style += `text-align: ${align};`; + } + + if (lineSpacing) { + const cssLineSpacing = toCSSLineSpacing(lineSpacing); + style += + `line-height: ${cssLineSpacing};` + + // This creates the local css variable `--czi-content-line-height` + // that its children may apply. + `--czi-content-line-height: ${cssLineSpacing}`; + } + + if (paddingTop && !EMPTY_CSS_VALUE.has(paddingTop)) { + style += `padding-top: ${paddingTop};`; + } + + if (paddingBottom && !EMPTY_CSS_VALUE.has(paddingBottom)) { + style += `padding-bottom: ${paddingBottom};`; + } + + if (indent) { + style += `text-indent: ${indent}; padding-left: ${indent < 0 ? -indent : undefined};`; + } + + if (inset) { + style += `margin-left: ${inset}; margin-right: ${inset};`; + } + + style && (attrs.style = style); + + if (indent) { + attrs[ATTRIBUTE_INDENT] = String(indent); + } + + if (id) { + attrs.id = id; + } + + return ['p', attrs, 0]; +} + +export const toParagraphDOM = toDOM; +export const getParagraphNodeAttrs = getAttrs; + +export function convertMarginLeftToIndentValue(marginLeft: string): number { + const ptValue = convertToCSSPTValue(marginLeft); + return clamp( + MIN_INDENT_LEVEL, + Math.floor(ptValue / INDENT_MARGIN_PT_SIZE), + MAX_INDENT_LEVEL + ); +} + +export default ParagraphNodeSpec; \ No newline at end of file diff --git a/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts b/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts new file mode 100644 index 000000000..a0b02880e --- /dev/null +++ b/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts @@ -0,0 +1,241 @@ +import { chainCommands, exitCode, joinDown, joinUp, lift, selectParentNode, setBlockType, splitBlockKeepMarks, toggleMark, wrapIn } from "prosemirror-commands"; +import { redo, undo } from "prosemirror-history"; +import { undoInputRule } from "prosemirror-inputrules"; +import { Schema } from "prosemirror-model"; +import { liftListItem, sinkListItem } from "./prosemirrorPatches.js"; +import { splitListItem, wrapInList, } from "prosemirror-schema-list"; +import { EditorState, Transaction, TextSelection } from "prosemirror-state"; +import { SelectionManager } from "../../../util/SelectionManager"; +import { Docs } from "../../../documents/Documents"; +import { NumCast, BoolCast, Cast, StrCast } from "../../../../new_fields/Types"; +import { Doc } from "../../../../new_fields/Doc"; +import { FormattedTextBox } from "./FormattedTextBox"; +import { Id } from "../../../../new_fields/FieldSymbols"; + +const mac = typeof navigator !== "undefined" ? /Mac/.test(navigator.platform) : false; + +export type KeyMap = { [key: string]: any }; + +export let updateBullets = (tx2: Transaction, schema: Schema, mapStyle?: string) => { + let fontSize: number | undefined = undefined; + tx2.doc.descendants((node: any, offset: any, index: any) => { + if (node.type === schema.nodes.ordered_list || node.type === schema.nodes.list_item) { + const path = (tx2.doc.resolve(offset) as any).path; + let depth = Array.from(path).reduce((p: number, c: any) => p + (c.hasOwnProperty("type") && c.type === schema.nodes.ordered_list ? 1 : 0), 0); + if (node.type === schema.nodes.ordered_list) depth++; + fontSize = depth === 1 && node.attrs.setFontSize ? Number(node.attrs.setFontSize) : fontSize; + const fsize = fontSize && node.type === schema.nodes.ordered_list ? Math.max(6, fontSize - (depth - 1) * 4) : undefined; + tx2.setNodeMarkup(offset, node.type, { ...node.attrs, mapStyle: mapStyle ? mapStyle : node.attrs.mapStyle, bulletStyle: depth, inheritedFontSize: fsize }, node.marks); + } + }); + return tx2; +}; +export default function buildKeymap>(schema: S, props: any, mapKeys?: KeyMap): KeyMap { + const keys: { [key: string]: any } = {}; + + function bind(key: string, cmd: any) { + if (mapKeys) { + const 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); + + !mac && bind("Mod-y", redo); + + bind("Alt-ArrowUp", joinUp); + bind("Alt-ArrowDown", joinDown); + bind("Mod-BracketLeft", lift); + bind("Escape", (state: EditorState, dispatch: (tx: Transaction) => void) => { + dispatch(state.tr.setSelection(TextSelection.create(state.doc, state.selection.from, state.selection.from))); + (document.activeElement as any).blur?.(); + SelectionManager.DeselectAll(); + }); + + bind("Mod-b", toggleMark(schema.marks.strong)); + bind("Mod-B", toggleMark(schema.marks.strong)); + + bind("Mod-e", toggleMark(schema.marks.em)); + bind("Mod-E", toggleMark(schema.marks.em)); + + bind("Mod-u", toggleMark(schema.marks.underline)); + bind("Mod-U", toggleMark(schema.marks.underline)); + + bind("Mod-`", toggleMark(schema.marks.code)); + + bind("Ctrl-.", wrapInList(schema.nodes.bullet_list)); + + bind("Ctrl-n", wrapInList(schema.nodes.ordered_list)); + + bind("Ctrl->", wrapIn(schema.nodes.blockquote)); + + // bind("^", (state: EditorState, dispatch: (tx: Transaction) => void) => { + // let newNode = schema.nodes.footnote.create({}); + // if (dispatch && state.selection.from === state.selection.to) { + // let tr = state.tr; + // tr.replaceSelectionWith(newNode); // replace insertion with a footnote. + // dispatch(tr.setSelection(new NodeSelection( // select the footnote node to open its display + // tr.doc.resolve( // get the location of the footnote node by subtracting the nodesize of the footnote from the current insertion point anchor (which will be immediately after the footnote node) + // tr.selection.anchor - tr.selection.$anchor.nodeBefore!.nodeSize)))); + // return true; + // } + // return false; + // }); + + + const cmd = chainCommands(exitCode, (state, dispatch) => { + if (dispatch) { + dispatch(state.tr.replaceSelectionWith(schema.nodes.hard_break.create()).scrollIntoView()); + return true; + } + return false; + }); + bind("Mod-Enter", cmd); + bind("Shift-Enter", cmd); + mac && bind("Ctrl-Enter", cmd); + + + bind("Shift-Ctrl-0", setBlockType(schema.nodes.paragraph)); + + bind("Shift-Ctrl-\\", setBlockType(schema.nodes.code_block)); + + for (let i = 1; i <= 6; i++) { + bind("Shift-Ctrl-" + i, setBlockType(schema.nodes.heading, { level: i })); + } + + const hr = schema.nodes.horizontal_rule; + bind("Mod-_", (state: EditorState, dispatch: (tx: Transaction) => void) => { + dispatch(state.tr.replaceSelectionWith(hr.create()).scrollIntoView()); + return true; + }); + + bind("Tab", (state: EditorState, dispatch: (tx: Transaction) => void) => { + const ref = state.selection; + const range = ref.$from.blockRange(ref.$to); + const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()); + if (!sinkListItem(schema.nodes.list_item)(state, (tx2: Transaction) => { + const tx3 = updateBullets(tx2, schema); + marks && tx3.ensureMarks([...marks]); + marks && tx3.setStoredMarks([...marks]); + dispatch(tx3); + })) { // couldn't sink into an existing list, so wrap in a new one + const newstate = state.applyTransaction(state.tr.setSelection(TextSelection.create(state.doc, range!.start, range!.end))); + if (!wrapInList(schema.nodes.ordered_list)(newstate.state, (tx2: Transaction) => { + const tx3 = updateBullets(tx2, schema); + // when promoting to a list, assume list will format things so don't copy the stored marks. + marks && tx3.ensureMarks([...marks]); + marks && tx3.setStoredMarks([...marks]); + dispatch(tx3); + })) { + console.log("bullet promote fail"); + } + } + }); + + bind("Shift-Tab", (state: EditorState, dispatch: (tx: Transaction) => void) => { + const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()); + + if (!liftListItem(schema.nodes.list_item)(state.tr, (tx2: Transaction) => { + const tx3 = updateBullets(tx2, schema); + marks && tx3.ensureMarks([...marks]); + marks && tx3.setStoredMarks([...marks]); + dispatch(tx3); + })) { + console.log("bullet demote fail"); + } + }); + bind("Ctrl-Enter", (state: EditorState, dispatch: (tx: Transaction) => void) => { + const layoutDoc = props.Document; + const originalDoc = layoutDoc.rootDocument || layoutDoc; + if (originalDoc instanceof Doc) { + const layoutKey = StrCast(originalDoc.layoutKey); + const newDoc = Docs.Create.TextDocument("", { + layout: Cast(originalDoc.layout, Doc, null) || FormattedTextBox.DefaultLayout, + layoutKey, + _singleLine: BoolCast(originalDoc._singleLine), + x: NumCast(originalDoc.x), y: NumCast(originalDoc.y) + NumCast(originalDoc._height) + 10, _width: NumCast(layoutDoc._width), _height: NumCast(layoutDoc._height) + }); + if (layoutKey !== "layout" && originalDoc[layoutKey] instanceof Doc) { + newDoc[layoutKey] = originalDoc[layoutKey]; + } + FormattedTextBox.SelectOnLoad = newDoc[Id]; + props.addDocument(newDoc); + } + }); + + const splitMetadata = (marks: any, tx: Transaction) => { + marks && tx.ensureMarks(marks.filter((val: any) => val.type !== schema.marks.metadata && val.type !== schema.marks.metadataKey && val.type !== schema.marks.metadataVal)); + marks && tx.setStoredMarks(marks.filter((val: any) => val.type !== schema.marks.metadata && val.type !== schema.marks.metadataKey && val.type !== schema.marks.metadataVal)); + return tx; + }; + const addTextOnRight = (force: boolean) => { + const layoutDoc = props.Document; + const originalDoc = layoutDoc.rootDocument || layoutDoc; + if (force || props.Document._singleLine) { + const layoutKey = StrCast(originalDoc.layoutKey); + const newDoc = Docs.Create.TextDocument("", { + layout: Cast(originalDoc.layout, Doc, null) || FormattedTextBox.DefaultLayout, + layoutKey, + _singleLine: BoolCast(originalDoc._singleLine), + x: NumCast(originalDoc.x) + NumCast(originalDoc._width) + 10, y: NumCast(originalDoc.y), _width: NumCast(layoutDoc._width), _height: NumCast(layoutDoc._height) + }); + if (layoutKey !== "layout" && originalDoc[layoutKey] instanceof Doc) { + newDoc[layoutKey] = originalDoc[layoutKey]; + } + FormattedTextBox.SelectOnLoad = newDoc[Id]; + props.addDocument(newDoc); + return true; + } + return false; + }; + bind("Alt-Enter", (state: EditorState, dispatch: (tx: Transaction>) => void) => { + return addTextOnRight(true); + }); + bind("Enter", (state: EditorState, dispatch: (tx: Transaction>) => void) => { + if (addTextOnRight(false)) return true; + const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()); + if (!splitListItem(schema.nodes.list_item)(state, dispatch)) { + if (!splitBlockKeepMarks(state, (tx3: Transaction) => { + splitMetadata(marks, tx3); + if (!liftListItem(schema.nodes.list_item)(tx3, dispatch as ((tx: Transaction>) => void))) { + dispatch(tx3); + } + })) { + return false; + } + } + return true; + }); + bind("Space", (state: EditorState, dispatch: (tx: Transaction) => void) => { + const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()); + dispatch(splitMetadata(marks, state.tr)); + return false; + }); + bind(":", (state: EditorState, dispatch: (tx: Transaction) => void) => { + const range = state.selection.$from.blockRange(state.selection.$to, (node: any) => { + return !node.marks || !node.marks.find((m: any) => m.type === schema.marks.metadata); + }); + const path = (state.doc.resolve(state.selection.from - 1) as any).path; + const spaceSeparator = path[path.length - 3].childCount > 1 ? 0 : -1; + const anchor = range!.end - path[path.length - 3].lastChild.nodeSize + spaceSeparator; + if (anchor >= 0) { + const textsel = TextSelection.create(state.doc, anchor, range!.end); + const text = range ? state.doc.textBetween(textsel.from, textsel.to) : ""; + let whitespace = text.length - 1; + for (; whitespace >= 0 && text[whitespace] !== " "; whitespace--) { } + if (text.endsWith(":")) { + dispatch(state.tr.addMark(textsel.from + whitespace + 1, textsel.to, schema.marks.metadata.create() as any). + addMark(textsel.from + whitespace + 1, textsel.to - 2, schema.marks.metadataKey.create() as any)); + } + } + return false; + }); + + + return keys; +} diff --git a/src/client/views/nodes/formattedText/RichTextMenu.scss b/src/client/views/nodes/formattedText/RichTextMenu.scss new file mode 100644 index 000000000..36da769c3 --- /dev/null +++ b/src/client/views/nodes/formattedText/RichTextMenu.scss @@ -0,0 +1,121 @@ +@import "../../globalCssVariables"; + +.button-dropdown-wrapper { + position: relative; + + .dropdown-button { + width: 15px; + padding-left: 5px; + padding-right: 5px; + } + + .dropdown-button-combined { + width: 50px; + display: flex; + justify-content: space-between; + + svg:nth-child(2) { + margin-top: 2px; + } + } + + .dropdown { + position: absolute; + top: 35px; + left: 0; + background-color: #323232; + color: $light-color-secondary; + border: 1px solid #4d4d4d; + border-radius: 0 6px 6px 6px; + box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25); + min-width: 150px; + padding: 5px; + font-size: 12px; + z-index: 10001; + + button { + background-color: #323232; + border: 1px solid black; + border-radius: 1px; + padding: 6px; + margin: 5px 0; + font-size: 10px; + + &:hover { + background-color: black; + } + + &:last-child { + margin-bottom: 0; + } + } + } + + input { + color: black; + } +} + +.link-menu { + .divider { + background-color: white; + height: 1px; + width: 100%; + } +} + +.color-preview-button { + .color-preview { + width: 100%; + height: 3px; + margin-top: 3px; + } +} + +.color-wrapper { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + + button.color-button { + width: 20px; + height: 20px; + border-radius: 15px !important; + margin: 3px; + border: 2px solid transparent !important; + padding: 3px; + + &.active { + border: 2px solid white !important; + } + } +} + +select { + background-color: #323232; + color: white; + border: 1px solid black; + // border-top: none; + // border-bottom: none; + font-size: 12px; + height: 100%; + margin-right: 3px; + + &:focus, + &:hover { + background-color: black; + } + + &::-ms-expand { + color: white; + } +} + +.row-2 { + display: flex; + justify-content: space-between; + + >div { + display: flex; + } +} \ No newline at end of file diff --git a/src/client/views/nodes/formattedText/RichTextMenu.tsx b/src/client/views/nodes/formattedText/RichTextMenu.tsx new file mode 100644 index 000000000..cc04e0d6d --- /dev/null +++ b/src/client/views/nodes/formattedText/RichTextMenu.tsx @@ -0,0 +1,875 @@ +import React = require("react"); +import AntimodeMenu from "../../AntimodeMenu"; +import { observable, action, } from "mobx"; +import { observer } from "mobx-react"; +import { Mark, MarkType, Node as ProsNode, NodeType, ResolvedPos, Schema } from "prosemirror-model"; +import { schema } from "./schema_rts"; +import { EditorView } from "prosemirror-view"; +import { EditorState, NodeSelection, TextSelection } from "prosemirror-state"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { IconProp, library } from '@fortawesome/fontawesome-svg-core'; +import { faBold, faItalic, faChevronLeft, faUnderline, faStrikethrough, faSubscript, faSuperscript, faIndent, faEyeDropper, faCaretDown, faPalette, faHighlighter, faLink, faPaintRoller, faSleigh } from "@fortawesome/free-solid-svg-icons"; +import { updateBullets } from "./ProsemirrorExampleTransfer"; +import { FieldViewProps } from "../FieldView"; +import { Cast, StrCast } from "../../../../new_fields/Types"; +import { FormattedTextBoxProps } from "./FormattedTextBox"; +import { unimplementedFunction, Utils } from "../../../../Utils"; +import { wrapInList } from "prosemirror-schema-list"; +import { PastelSchemaPalette, DarkPastelSchemaPalette } from '../../../../new_fields/SchemaHeaderField'; +import "./RichTextMenu.scss"; +import { DocServer } from "../../../DocServer"; +import { Doc } from "../../../../new_fields/Doc"; +import { SelectionManager } from "../../../util/SelectionManager"; +import { LinkManager } from "../../../util/LinkManager"; +const { toggleMark, setBlockType } = require("prosemirror-commands"); + +library.add(faBold, faItalic, faChevronLeft, faUnderline, faStrikethrough, faSuperscript, faSubscript, faIndent, faEyeDropper, faCaretDown, faPalette, faHighlighter, faLink, faPaintRoller); + +@observer +export default class RichTextMenu extends AntimodeMenu { + static Instance: RichTextMenu; + public overMenu: boolean = false; // kind of hacky way to prevent selects not being selectable + + private view?: EditorView; + public editorProps: FieldViewProps & FormattedTextBoxProps | undefined; + + public _brushMap: Map> = new Map(); + private fontSizeOptions: { mark: Mark | null, title: string, label: string, command: any, hidden?: boolean, style?: {} }[]; + private fontFamilyOptions: { mark: Mark | null, title: string, label: string, command: any, hidden?: boolean, style?: {} }[]; + private listTypeOptions: { node: NodeType | any | null, title: string, label: string, command: any, style?: {} }[]; + private fontColors: (string | undefined)[]; + private highlightColors: (string | undefined)[]; + + @observable private collapsed: boolean = false; + @observable private boldActive: boolean = false; + @observable private italicsActive: boolean = false; + @observable private underlineActive: boolean = false; + @observable private strikethroughActive: boolean = false; + @observable private subscriptActive: boolean = false; + @observable private superscriptActive: boolean = false; + + @observable private activeFontSize: string = ""; + @observable private activeFontFamily: string = ""; + @observable private activeListType: string = ""; + + @observable private brushIsEmpty: boolean = true; + @observable private brushMarks: Set = new Set(); + @observable private showBrushDropdown: boolean = false; + + @observable private activeFontColor: string = "black"; + @observable private showColorDropdown: boolean = false; + + @observable private activeHighlightColor: string = "transparent"; + @observable private showHighlightDropdown: boolean = false; + + @observable private currentLink: string | undefined = ""; + @observable private showLinkDropdown: boolean = false; + + constructor(props: Readonly<{}>) { + super(props); + RichTextMenu.Instance = this; + this._canFade = false; + + this.fontSizeOptions = [ + { mark: schema.marks.pFontSize.create({ fontSize: 7 }), title: "Set font size", label: "7pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 8 }), title: "Set font size", label: "8pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 9 }), title: "Set font size", label: "9pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 10 }), title: "Set font size", label: "10pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 12 }), title: "Set font size", label: "12pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 14 }), title: "Set font size", label: "14pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 16 }), title: "Set font size", label: "16pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 18 }), title: "Set font size", label: "18pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 20 }), title: "Set font size", label: "20pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 24 }), title: "Set font size", label: "24pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 32 }), title: "Set font size", label: "32pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 48 }), title: "Set font size", label: "48pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 72 }), title: "Set font size", label: "72pt", command: this.changeFontSize }, + { mark: null, title: "", label: "various", command: unimplementedFunction, hidden: true }, + { mark: null, title: "", label: "13pt", command: unimplementedFunction, hidden: true }, // this is here because the default size is 13, but there is no actual 13pt option + ]; + + this.fontFamilyOptions = [ + { mark: schema.marks.pFontFamily.create({ family: "Times New Roman" }), title: "Set font family", label: "Times New Roman", command: this.changeFontFamily, style: { fontFamily: "Times New Roman" } }, + { mark: schema.marks.pFontFamily.create({ family: "Arial" }), title: "Set font family", label: "Arial", command: this.changeFontFamily, style: { fontFamily: "Arial" } }, + { mark: schema.marks.pFontFamily.create({ family: "Georgia" }), title: "Set font family", label: "Georgia", command: this.changeFontFamily, style: { fontFamily: "Georgia" } }, + { mark: schema.marks.pFontFamily.create({ family: "Comic Sans MS" }), title: "Set font family", label: "Comic Sans MS", command: this.changeFontFamily, style: { fontFamily: "Comic Sans MS" } }, + { mark: schema.marks.pFontFamily.create({ family: "Tahoma" }), title: "Set font family", label: "Tahoma", command: this.changeFontFamily, style: { fontFamily: "Tahoma" } }, + { mark: schema.marks.pFontFamily.create({ family: "Impact" }), title: "Set font family", label: "Impact", command: this.changeFontFamily, style: { fontFamily: "Impact" } }, + { mark: schema.marks.pFontFamily.create({ family: "Crimson Text" }), title: "Set font family", label: "Crimson Text", command: this.changeFontFamily, style: { fontFamily: "Crimson Text" } }, + { mark: null, title: "", label: "various", command: unimplementedFunction, hidden: true }, + // { mark: null, title: "", label: "default", command: unimplementedFunction, hidden: true }, + ]; + + this.listTypeOptions = [ + { node: schema.nodes.ordered_list.create({ mapStyle: "bullet" }), title: "Set list type", label: ":", command: this.changeListType }, + { node: schema.nodes.ordered_list.create({ mapStyle: "decimal" }), title: "Set list type", label: "1.1", command: this.changeListType }, + { node: schema.nodes.ordered_list.create({ mapStyle: "multi" }), title: "Set list type", label: "1.A", command: this.changeListType }, + { node: undefined, title: "Set list type", label: "Remove", command: this.changeListType }, + ]; + + this.fontColors = [ + DarkPastelSchemaPalette.get("pink2"), + DarkPastelSchemaPalette.get("purple4"), + DarkPastelSchemaPalette.get("bluegreen1"), + DarkPastelSchemaPalette.get("yellow4"), + DarkPastelSchemaPalette.get("red2"), + DarkPastelSchemaPalette.get("bluegreen7"), + DarkPastelSchemaPalette.get("bluegreen5"), + DarkPastelSchemaPalette.get("orange1"), + "#757472", + "#000" + ]; + + this.highlightColors = [ + PastelSchemaPalette.get("pink2"), + PastelSchemaPalette.get("purple4"), + PastelSchemaPalette.get("bluegreen1"), + PastelSchemaPalette.get("yellow4"), + PastelSchemaPalette.get("red2"), + PastelSchemaPalette.get("bluegreen7"), + PastelSchemaPalette.get("bluegreen5"), + PastelSchemaPalette.get("orange1"), + "white", + "transparent" + ]; + } + + @action + changeView(view: EditorView) { + this.view = view; + } + + update(view: EditorView, lastState: EditorState | undefined) { + this.updateFromDash(view, lastState, this.editorProps); + } + + + public MakeLinkToSelection = (linkDocId: string, title: string, location: string, targetDocId: string): string => { + if (this.view) { + const link = this.view.state.schema.marks.link.create({ href: Utils.prepend("/doc/" + linkDocId), title: title, location: location, linkId: linkDocId, targetId: targetDocId }); + this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link). + addMark(this.view.state.selection.from, this.view.state.selection.to, link)); + return this.view.state.selection.$from.nodeAfter?.text || ""; + } + return ""; + } + + @action + public async updateFromDash(view: EditorView, lastState: EditorState | undefined, props: any) { + if (!view) { + console.log("no editor? why?"); + return; + } + this.view = view; + const state = view.state; + props && (this.editorProps = props); + + // Don't do anything if the document/selection didn't change + if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) return; + + // update active marks + const activeMarks = this.getActiveMarksOnSelection(); + this.setActiveMarkButtons(activeMarks); + + // update active font family and size + const active = this.getActiveFontStylesOnSelection(); + const activeFamilies = active && active.get("families"); + const activeSizes = active && active.get("sizes"); + + this.activeFontFamily = !activeFamilies || activeFamilies.length === 0 ? "Arial" : activeFamilies.length === 1 ? String(activeFamilies[0]) : "various"; + this.activeFontSize = !activeSizes || activeSizes.length === 0 ? "13pt" : activeSizes.length === 1 ? String(activeSizes[0]) + "pt" : "various"; + + // update link in current selection + const targetTitle = await this.getTextLinkTargetTitle(); + this.setCurrentLink(targetTitle); + } + + setMark = (mark: Mark, state: EditorState, dispatch: any) => { + if (mark) { + const node = (state.selection as NodeSelection).node; + if (node?.type === schema.nodes.ordered_list) { + let attrs = node.attrs; + if (mark.type === schema.marks.pFontFamily) attrs = { ...attrs, setFontFamily: mark.attrs.family }; + if (mark.type === schema.marks.pFontSize) attrs = { ...attrs, setFontSize: mark.attrs.fontSize }; + if (mark.type === schema.marks.pFontColor) attrs = { ...attrs, setFontColor: mark.attrs.color }; + const tr = updateBullets(state.tr.setNodeMarkup(state.selection.from, node.type, attrs), state.schema); + dispatch(tr.setSelection(new NodeSelection(tr.doc.resolve(state.selection.from)))); + } else { + toggleMark(mark.type, mark.attrs)(state, (tx: any) => { + const { from, $from, to, empty } = tx.selection; + if (!tx.doc.rangeHasMark(from, to, mark.type)) { + toggleMark(mark.type, mark.attrs)({ tr: tx, doc: tx.doc, selection: tx.selection, storedMarks: tx.storedMarks }, dispatch); + } else dispatch(tx); + }); + } + } + } + + // finds font sizes and families in selection + getActiveFontStylesOnSelection() { + if (!this.view) return; + + const activeFamilies: string[] = []; + const activeSizes: string[] = []; + const state = this.view.state; + const pos = this.view.state.selection.$from; + const ref_node = this.reference_node(pos); + if (ref_node && ref_node !== this.view.state.doc && ref_node.isText) { + ref_node.marks.forEach(m => { + m.type === state.schema.marks.pFontFamily && activeFamilies.push(m.attrs.family); + m.type === state.schema.marks.pFontSize && activeSizes.push(String(m.attrs.fontSize) + "pt"); + }); + } + + const styles = new Map(); + styles.set("families", activeFamilies); + styles.set("sizes", activeSizes); + return styles; + } + + getMarksInSelection(state: EditorState) { + const found = new Set(); + const { from, to } = state.selection as TextSelection; + state.doc.nodesBetween(from, to, (node) => node.marks.forEach(m => found.add(m))); + return found; + } + + //finds all active marks on selection in given group + getActiveMarksOnSelection() { + if (!this.view) return; + + const markGroup = [schema.marks.strong, schema.marks.em, schema.marks.underline, schema.marks.strikethrough, schema.marks.superscript, schema.marks.subscript]; + if (this.view.state.storedMarks) return this.view.state.storedMarks.map(mark => mark.type); + //current selection + const { empty, ranges, $to } = this.view.state.selection as TextSelection; + const state = this.view.state; + let activeMarks: MarkType[] = []; + if (!empty) { + activeMarks = markGroup.filter(mark => { + const has = false; + for (let i = 0; !has && i < ranges.length; i++) { + return state.doc.rangeHasMark(ranges[i].$from.pos, ranges[i].$to.pos, mark); + } + return false; + }); + } + else { + const pos = this.view.state.selection.$from; + const ref_node: ProsNode | null = this.reference_node(pos); + if (ref_node !== null && ref_node !== this.view.state.doc) { + if (ref_node.isText) { + } + else { + return []; + } + activeMarks = markGroup.filter(mark_type => { + if (mark_type === state.schema.marks.pFontSize) { + return ref_node.marks.some(m => m.type.name === state.schema.marks.pFontSize.name); + } + const mark = state.schema.mark(mark_type); + return ref_node.marks.includes(mark); + }); + } + } + return activeMarks; + } + + destroy() { + this.fadeOut(true); + } + + @action + setActiveMarkButtons(activeMarks: MarkType[] | undefined) { + if (!activeMarks) return; + + this.boldActive = false; + this.italicsActive = false; + this.underlineActive = false; + this.strikethroughActive = false; + this.subscriptActive = false; + this.superscriptActive = false; + + activeMarks.forEach(mark => { + switch (mark.name) { + case "strong": this.boldActive = true; break; + case "em": this.italicsActive = true; break; + case "underline": this.underlineActive = true; break; + case "strikethrough": this.strikethroughActive = true; break; + case "subscript": this.subscriptActive = true; break; + case "superscript": this.superscriptActive = true; break; + } + }); + } + + createButton(faIcon: string, title: string, isActive: boolean = false, command?: any, onclick?: any) { + const self = this; + function onClick(e: React.PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.view && command && command(self.view.state, self.view.dispatch, self.view); + self.view && onclick && onclick(self.view.state, self.view.dispatch, self.view); + self.setActiveMarkButtons(self.getActiveMarksOnSelection()); + } + + return ( + + ); + } + + createMarksDropdown(activeOption: string, options: { mark: Mark | null, title: string, label: string, command: (mark: Mark, view: EditorView) => void, hidden?: boolean, style?: {} }[], key: string): JSX.Element { + const items = options.map(({ title, label, hidden, style }) => { + if (hidden) { + return label === activeOption ? + : + ; + } + return label === activeOption ? + : + ; + }); + + const self = this; + function onChange(e: React.ChangeEvent) { + e.stopPropagation(); + e.preventDefault(); + options.forEach(({ label, mark, command }) => { + if (e.target.value === label) { + self.view && mark && command(mark, self.view); + } + }); + } + return ; + } + + createNodesDropdown(activeOption: string, options: { node: NodeType | any | null, title: string, label: string, command: (node: NodeType | any) => void, hidden?: boolean, style?: {} }[], key: string): JSX.Element { + const items = options.map(({ title, label, hidden, style }) => { + if (hidden) { + return label === activeOption ? + : + ; + } + return label === activeOption ? + : + ; + }); + + const self = this; + function onChange(val: string) { + options.forEach(({ label, node, command }) => { + if (val === label) { + self.view && node && command(node); + } + }); + } + return ; + } + + changeFontSize = (mark: Mark, view: EditorView) => { + this.setMark(view.state.schema.marks.pFontSize.create({ fontSize: mark.attrs.fontSize }), view.state, view.dispatch); + } + + changeFontFamily = (mark: Mark, view: EditorView) => { + this.setMark(view.state.schema.marks.pFontFamily.create({ family: mark.attrs.family }), view.state, view.dispatch); + } + + // TODO: remove doesn't work + //remove all node type and apply the passed-in one to the selected text + changeListType = (nodeType: NodeType | undefined) => { + if (!this.view) return; + + if (nodeType === schema.nodes.bullet_list) { + wrapInList(nodeType)(this.view.state, this.view.dispatch); + } else { + const marks = this.view.state.storedMarks || (this.view.state.selection.$to.parentOffset && this.view.state.selection.$from.marks()); + if (!wrapInList(schema.nodes.ordered_list)(this.view.state, (tx2: any) => { + const tx3 = updateBullets(tx2, schema, nodeType && (nodeType as any).attrs.mapStyle); + marks && tx3.ensureMarks([...marks]); + marks && tx3.setStoredMarks([...marks]); + + this.view!.dispatch(tx2); + })) { + const tx2 = this.view.state.tr; + const tx3 = updateBullets(tx2, schema, nodeType && (nodeType as any).attrs.mapStyle); + marks && tx3.ensureMarks([...marks]); + marks && tx3.setStoredMarks([...marks]); + + this.view.dispatch(tx3); + } + } + } + + insertSummarizer(state: EditorState, dispatch: any) { + if (state.selection.empty) return false; + const mark = state.schema.marks.summarize.create(); + const tr = state.tr; + tr.addMark(state.selection.from, state.selection.to, mark); + const content = tr.selection.content(); + const newNode = state.schema.nodes.summary.create({ visibility: false, text: content, textslice: content.toJSON() }); + dispatch && dispatch(tr.replaceSelectionWith(newNode).removeMark(tr.selection.from - 1, tr.selection.from, mark)); + return true; + } + + @action toggleBrushDropdown() { this.showBrushDropdown = !this.showBrushDropdown; } + + // todo: add brushes to brushMap to save with a style name + onBrushNameKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + RichTextMenu.Instance.brushMarks && RichTextMenu.Instance._brushMap.set(this._brushNameRef.current!.value, RichTextMenu.Instance.brushMarks); + this._brushNameRef.current!.style.background = "lightGray"; + } + } + _brushNameRef = React.createRef(); + + createBrushButton() { + const self = this; + function onBrushClick(e: React.PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.view && self.fillBrush(self.view.state, self.view.dispatch); + } + + let label = "Stored marks: "; + if (this.brushMarks && this.brushMarks.size > 0) { + this.brushMarks.forEach((mark: Mark) => { + const markType = mark.type; + label += markType.name; + label += ", "; + }); + label = label.substring(0, label.length - 2); + } else { + label = "No marks are currently stored"; + } + + const button = + ; + + const dropdownContent = +

+

{label}

+ + +
; + + return ( + + ); + } + + @action + clearBrush() { + RichTextMenu.Instance.brushIsEmpty = true; + RichTextMenu.Instance.brushMarks = new Set(); + } + + @action + fillBrush(state: EditorState, dispatch: any) { + if (!this.view) return; + + if (this.brushIsEmpty) { + const selected_marks = this.getMarksInSelection(this.view.state); + if (selected_marks.size >= 0) { + this.brushMarks = selected_marks; + this.brushIsEmpty = !this.brushIsEmpty; + } + } + else { + const { from, to, $from } = this.view.state.selection; + if (!this.view.state.selection.empty && $from && $from.nodeAfter) { + if (this.brushMarks && to - from > 0) { + this.view.dispatch(this.view.state.tr.removeMark(from, to)); + Array.from(this.brushMarks).filter(m => m.type !== schema.marks.user_mark).forEach((mark: Mark) => { + this.setMark(mark, this.view!.state, this.view!.dispatch); + }); + } + } + else { + this.brushIsEmpty = !this.brushIsEmpty; + } + } + } + + @action toggleColorDropdown() { this.showColorDropdown = !this.showColorDropdown; } + @action setActiveColor(color: string) { this.activeFontColor = color; } + + createColorButton() { + const self = this; + function onColorClick(e: React.PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.view && self.insertColor(self.activeFontColor, self.view.state, self.view.dispatch); + } + function changeColor(e: React.PointerEvent, color: string) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.setActiveColor(color); + self.view && self.insertColor(self.activeFontColor, self.view.state, self.view.dispatch); + } + + const button = + ; + + const dropdownContent = +
+

Change font color:

+
+ {this.fontColors.map(color => { + if (color) { + return this.activeFontColor === color ? + : + ; + } + })} +
+
; + + return ( + + ); + } + + public insertColor(color: String, state: EditorState, dispatch: any) { + const colorMark = state.schema.mark(state.schema.marks.pFontColor, { color: color }); + if (state.selection.empty) { + dispatch(state.tr.addStoredMark(colorMark)); + return false; + } + this.setMark(colorMark, state, dispatch); + } + + @action toggleHighlightDropdown() { this.showHighlightDropdown = !this.showHighlightDropdown; } + @action setActiveHighlight(color: string) { this.activeHighlightColor = color; } + + createHighlighterButton() { + const self = this; + function onHighlightClick(e: React.PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.view && self.insertHighlight(self.activeHighlightColor, self.view.state, self.view.dispatch); + } + function changeHighlight(e: React.PointerEvent, color: string) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.setActiveHighlight(color); + self.view && self.insertHighlight(self.activeHighlightColor, self.view.state, self.view.dispatch); + } + + const button = + ; + + const dropdownContent = +
+

Change highlight color:

+
+ {this.highlightColors.map(color => { + if (color) { + return this.activeHighlightColor === color ? + : + ; + } + })} +
+
; + + return ( + + ); + } + + insertHighlight(color: String, state: EditorState, dispatch: any) { + if (state.selection.empty) return false; + toggleMark(state.schema.marks.marker, { highlight: color })(state, dispatch); + } + + @action toggleLinkDropdown() { this.showLinkDropdown = !this.showLinkDropdown; } + @action setCurrentLink(link: string) { this.currentLink = link; } + + createLinkButton() { + const self = this; + + function onLinkChange(e: React.ChangeEvent) { + self.setCurrentLink(e.target.value); + } + + const link = this.currentLink ? this.currentLink : ""; + + const button = ; + + const dropdownContent = +
+

Linked to:

+ + +
+ +
; + + return ( + + ); + } + + async getTextLinkTargetTitle() { + if (!this.view) return; + + const node = this.view.state.selection.$from.nodeAfter; + const link = node && node.marks.find(m => m.type.name === "link"); + if (link) { + const href = link.attrs.href; + if (href) { + if (href.indexOf(Utils.prepend("/doc/")) === 0) { + const linkclicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0]; + if (linkclicked) { + const linkDoc = await DocServer.GetRefField(linkclicked); + if (linkDoc instanceof Doc) { + const anchor1 = await Cast(linkDoc.anchor1, Doc); + const anchor2 = await Cast(linkDoc.anchor2, Doc); + const currentDoc = SelectionManager.SelectedDocuments().length && SelectionManager.SelectedDocuments()[0].props.Document; + if (currentDoc && anchor1 && anchor2) { + if (Doc.AreProtosEqual(currentDoc, anchor1)) { + return StrCast(anchor2.title); + } + if (Doc.AreProtosEqual(currentDoc, anchor2)) { + return StrCast(anchor1.title); + } + } + } + } + } else { + return href; + } + } else { + return link.attrs.title; + } + } + } + + // TODO: should check for valid URL + makeLinkToURL = (target: String, lcoation: string) => { + if (!this.view) return; + + let node = this.view.state.selection.$from.nodeAfter; + let link = this.view.state.schema.mark(this.view.state.schema.marks.link, { href: target, location: location }); + this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link)); + this.view.dispatch(this.view.state.tr.addMark(this.view.state.selection.from, this.view.state.selection.to, link)); + node = this.view.state.selection.$from.nodeAfter; + link = node && node.marks.find(m => m.type.name === "link"); + } + + deleteLink = () => { + if (!this.view) return; + + const node = this.view.state.selection.$from.nodeAfter; + const link = node && node.marks.find(m => m.type === this.view!.state.schema.marks.link); + const href = link!.attrs.href; + if (href) { + if (href.indexOf(Utils.prepend("/doc/")) === 0) { + const linkclicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0]; + if (linkclicked) { + DocServer.GetRefField(linkclicked).then(async linkDoc => { + if (linkDoc instanceof Doc) { + LinkManager.Instance.deleteLink(linkDoc); + this.view!.dispatch(this.view!.state.tr.removeMark(this.view!.state.selection.from, this.view!.state.selection.to, this.view!.state.schema.marks.link)); + } + }); + } + } else { + if (node) { + const { tr, schema, selection } = this.view.state; + const extension = this.linkExtend(selection.$anchor, href); + this.view.dispatch(tr.removeMark(extension.from, extension.to, schema.marks.link)); + } + } + } + } + + linkExtend($start: ResolvedPos, href: string) { + const mark = this.view!.state.schema.marks.link; + + let startIndex = $start.index(); + let endIndex = $start.indexAfter(); + + while (startIndex > 0 && $start.parent.child(startIndex - 1).marks.filter(m => m.type === mark && m.attrs.href === href).length) startIndex--; + while (endIndex < $start.parent.childCount && $start.parent.child(endIndex).marks.filter(m => m.type === mark && m.attrs.href === href).length) endIndex++; + + let startPos = $start.start(); + let endPos = startPos; + for (let i = 0; i < endIndex; i++) { + const size = $start.parent.child(i).nodeSize; + if (i < startIndex) startPos += size; + endPos += size; + } + return { from: startPos, to: endPos }; + } + + reference_node(pos: ResolvedPos): ProsNode | null { + if (!this.view) return null; + + let ref_node: ProsNode = this.view.state.doc; + if (pos.nodeBefore !== null && pos.nodeBefore !== undefined) { + ref_node = pos.nodeBefore; + } + else if (pos.nodeAfter !== null && pos.nodeAfter !== undefined) { + ref_node = pos.nodeAfter; + } + else if (pos.pos > 0) { + let skip = false; + for (let i: number = pos.pos - 1; i > 0; i--) { + this.view.state.doc.nodesBetween(i, pos.pos, (node: ProsNode) => { + if (node.isLeaf && !skip) { + ref_node = node; + skip = true; + } + + }); + } + } + if (!ref_node.isLeaf && ref_node.childCount > 0) { + ref_node = ref_node.child(0); + } + return ref_node; + } + + @action onPointerEnter(e: React.PointerEvent) { RichTextMenu.Instance.overMenu = true; } + @action onPointerLeave(e: React.PointerEvent) { RichTextMenu.Instance.overMenu = false; } + + @action + toggleMenuPin = (e: React.MouseEvent) => { + this.Pinned = !this.Pinned; + if (!this.Pinned) { + this.fadeOut(true); + } + } + + @action + protected toggleCollapse = (e: React.MouseEvent) => { + this.collapsed = !this.collapsed; + setTimeout(() => { + const x = Math.min(this._left, window.innerWidth - RichTextMenu.Instance.width); + RichTextMenu.Instance.jumpTo(x, this._top); + }, 0); + } + + render() { + + const row1 =
{[ + this.createButton("bold", "Bold", this.boldActive, toggleMark(schema.marks.strong)), + this.createButton("italic", "Italic", this.italicsActive, toggleMark(schema.marks.em)), + this.createButton("underline", "Underline", this.underlineActive, toggleMark(schema.marks.underline)), + this.createButton("strikethrough", "Strikethrough", this.strikethroughActive, toggleMark(schema.marks.strikethrough)), + this.createButton("superscript", "Superscript", this.superscriptActive, toggleMark(schema.marks.superscript)), + this.createButton("subscript", "Subscript", this.subscriptActive, toggleMark(schema.marks.subscript)), + this.createColorButton(), + this.createHighlighterButton(), + this.createLinkButton(), + this.createBrushButton(), + this.createButton("indent", "Summarize", undefined, this.insertSummarizer), + ]}
; + + const row2 =
+
+ {[this.createMarksDropdown(this.activeFontSize, this.fontSizeOptions, "font size"), + this.createMarksDropdown(this.activeFontFamily, this.fontFamilyOptions, "font family"), + this.createNodesDropdown(this.activeListType, this.listTypeOptions, "nodes")]} +
+
+
+ +
+ + {this.getDragger()} +
+
; + + return ( +
+ {this.getElementWithRows([row1, row2], 2, false)} +
+ ); + } +} + +interface ButtonDropdownProps { + view?: EditorView; + button: JSX.Element; + dropdownContent: JSX.Element; + openDropdownOnButton?: boolean; +} + +@observer +class ButtonDropdown extends React.Component { + + @observable private showDropdown: boolean = false; + private ref: HTMLDivElement | null = null; + + componentDidMount() { + document.addEventListener("pointerdown", this.onBlur); + } + + componentWillUnmount() { + document.removeEventListener("pointerdown", this.onBlur); + } + + @action + setShowDropdown(show: boolean) { + this.showDropdown = show; + } + @action + toggleDropdown() { + this.showDropdown = !this.showDropdown; + } + + onDropdownClick = (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + this.props.view && this.props.view.focus(); + this.toggleDropdown(); + } + + onBlur = (e: PointerEvent) => { + setTimeout(() => { + if (this.ref !== null && !this.ref.contains(e.target as Node)) { + this.setShowDropdown(false); + } + }, 0); + } + + render() { + return ( +
this.ref = node}> + {this.props.openDropdownOnButton ? + : + <> + {this.props.button} + + } + + {this.showDropdown ? this.props.dropdownContent : (null)} +
+ ); + } +} \ No newline at end of file diff --git a/src/client/views/nodes/formattedText/RichTextRules.ts b/src/client/views/nodes/formattedText/RichTextRules.ts new file mode 100644 index 000000000..335094e23 --- /dev/null +++ b/src/client/views/nodes/formattedText/RichTextRules.ts @@ -0,0 +1,319 @@ +import { ellipsis, emDash, InputRule, smartQuotes, textblockTypeInputRule } from "prosemirror-inputrules"; +import { NodeSelection, TextSelection } from "prosemirror-state"; +import { DataSym, Doc } from "../../../../new_fields/Doc"; +import { Id } from "../../../../new_fields/FieldSymbols"; +import { ComputedField } from "../../../../new_fields/ScriptField"; +import { Cast, NumCast } from "../../../../new_fields/Types"; +import { returnFalse, Utils } from "../../../../Utils"; +import { DocServer } from "../../../DocServer"; +import { Docs, DocUtils } from "../../../documents/Documents"; +import { FormattedTextBox } from "./FormattedTextBox"; +import { wrappingInputRule } from "./prosemirrorPatches"; +import RichTextMenu from "./RichTextMenu"; +import { schema } from "./schema_rts"; + +export class RichTextRules { + public Document: Doc; + public TextBox: FormattedTextBox; + public EnteringStyle: boolean = false; + constructor(doc: Doc, textBox: FormattedTextBox) { + this.Document = doc; + this.TextBox = textBox; + } + public inpRules = { + rules: [ + ...smartQuotes, + ellipsis, + emDash, + + // > blockquote + wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote), + + // 1. ordered list + wrappingInputRule( + /^1\.\s$/, + schema.nodes.ordered_list, + () => { + return ({ mapStyle: "decimal", bulletStyle: 1 }); + }, + (match: any, node: any) => { + return node.childCount + node.attrs.order === +match[1]; + }, + (type: any) => ({ type: type, attrs: { mapStyle: "decimal", bulletStyle: 1 } }) + ), + // a. alphabbetical list + wrappingInputRule( + /^a\.\s$/, + schema.nodes.ordered_list, + // match => { + () => { + return ({ mapStyle: "alpha", bulletStyle: 1 }); + // return ({ order: +match[1] }) + }, + (match: any, node: any) => { + return node.childCount + node.attrs.order === +match[1]; + }, + (type: any) => ({ type: type, attrs: { mapStyle: "alpha", bulletStyle: 1 } }) + ), + + // * bullet list + wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list), + + // ``` code block + textblockTypeInputRule(/^```$/, schema.nodes.code_block), + + // create an inline view of a tag stored under the '#' field + new InputRule( + new RegExp(/#([a-zA-Z_\-]+[a-zA-Z_\-0-9]*)\s$/), + (state, match, start, end) => { + const tag = match[1]; + if (!tag) return state.tr; + this.Document[DataSym]["#"] = tag; + const fieldView = state.schema.nodes.dashField.create({ fieldKey: "#" }); + return state.tr.deleteRange(start, end).insert(start, fieldView); + }), + + // # heading + textblockTypeInputRule( + new RegExp(/^(#{1,6})\s$/), + schema.nodes.heading, + match => { + return ({ level: match[1].length }); + } + ), + + // set the font size using # + new InputRule( + new RegExp(/%([0-9]+)\s$/), + (state, match, start, end) => { + const size = Number(match[1]); + return state.tr.deleteRange(start, end).addStoredMark(schema.marks.pFontSize.create({ fontSize: size })); + }), + + // create a text display of a metadata field on this or another document, or create a hyperlink portal to another document [[ : ]] // [[:Doc]] => hyperlink [[fieldKey]] => show field [[fieldKey:Doc]] => show field of doc + new InputRule( + new RegExp(/\[\[([a-zA-Z_@\? \-0-9]*)(=[a-zA-Z_@\? \-0-9]*)?(:[a-zA-Z_@\? \-0-9]+)?\]\]$/), + (state, match, start, end) => { + const fieldKey = match[1]; + const docid = match[3]?.substring(1); + const value = match[2]?.substring(1); + if (!fieldKey) { + if (docid) { + DocServer.GetRefField(docid).then(docx => { + const target = ((docx instanceof Doc) && docx) || Docs.Create.FreeformDocument([], { title: docid, _width: 500, _height: 500, _LODdisable: true, }, docid); + DocUtils.Publish(target, docid, returnFalse, returnFalse); + DocUtils.MakeLink({ doc: this.Document }, { doc: target }, "portal to"); + }); + const link = state.schema.marks.link.create({ href: Utils.prepend("/doc/" + docid), location: "onRight", title: docid, targetId: docid }); + return state.tr.deleteRange(end - 1, end).deleteRange(start, start + 2).addMark(start, end - 3, link); + } + return state.tr; + } + if (value !== "" && value !== undefined) { + const num = value.match(/^[0-9.]/); + this.Document[DataSym][fieldKey] = value === "true" ? true : value === "false" ? false : (num ? Number(value) : value); + } + const fieldView = state.schema.nodes.dashField.create({ fieldKey, docid }); + return state.tr.deleteRange(start, end).insert(start, fieldView); + }), + // create an inline view of a document {{ : }} // {{:Doc}} => show default view of document {{}} => show layout for this doc {{ : Doc}} => show layout for another doc + new InputRule( + new RegExp(/\{\{([a-zA-Z_ \-0-9]*)(\([a-zA-Z0-9…._\-]*\))?(:[a-zA-Z_ \-0-9]+)?\}\}$/), + (state, match, start, end) => { + const fieldKey = match[1] || ""; + const fieldParam = match[2]?.replace("…", "...") || ""; + const docid = match[3]?.substring(1); + if (!fieldKey && !docid) return state.tr; + docid && DocServer.GetRefField(docid).then(docx => { + if (!(docx instanceof Doc && docx)) { + const docx = Docs.Create.FreeformDocument([], { title: docid, _width: 500, _height: 500, _LODdisable: true }, docid); + DocUtils.Publish(docx, docid, returnFalse, returnFalse); + } + }); + const node = (state.doc.resolve(start) as any).nodeAfter; + const dashDoc = schema.nodes.dashDoc.create({ width: 75, height: 75, title: "dashDoc", docid, fieldKey: fieldKey + fieldParam, float: "unset", alias: Utils.GenerateGuid() }); + const sm = state.storedMarks || undefined; + return node ? state.tr.replaceRangeWith(start, end, dashDoc).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : state.tr; + }), + new InputRule( + new RegExp(/>>$/), + (state, match, start, end) => { + const textDoc = this.Document[DataSym]; + const numInlines = NumCast(textDoc.inlineTextCount); + textDoc.inlineTextCount = numInlines + 1; + const inlineFieldKey = "inline" + numInlines; // which field on the text document this annotation will write to + const inlineLayoutKey = "layout_" + inlineFieldKey; // the field holding the layout string that will render the inline annotation + const textDocInline = Docs.Create.TextDocument("", { layoutKey: inlineLayoutKey, _width: 75, _height: 35, annotationOn: textDoc, _autoHeight: true, _fontSize: 9, title: "inline comment" }); + textDocInline.title = inlineFieldKey; // give the annotation its own title + textDocInline.customTitle = true; // And make sure that it's 'custom' so that editing text doesn't change the title of the containing doc + textDocInline.isTemplateForField = inlineFieldKey; // this is needed in case the containing text doc is converted to a template at some point + textDocInline.proto = textDoc; // make the annotation inherit from the outer text doc so that it can resolve any nested field references, e.g., [[field]] + textDocInline._textContext = ComputedField.MakeFunction(`copyField(self.${inlineFieldKey})`); + textDoc[inlineLayoutKey] = FormattedTextBox.LayoutString(inlineFieldKey); // create a layout string for the layout key that will render the annotation text + textDoc[inlineFieldKey] = ""; // set a default value for the annotation + const node = (state.doc.resolve(start) as any).nodeAfter; + const newNode = schema.nodes.dashComment.create({ docid: textDocInline[Id] }); + const dashDoc = schema.nodes.dashDoc.create({ width: 75, height: 35, title: "dashDoc", docid: textDocInline[Id], float: "right" }); + const sm = state.storedMarks || undefined; + const replaced = node ? state.tr.insert(start, newNode).replaceRangeWith(start + 1, end + 1, dashDoc).insertText(" ", start + 2).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + state.tr; + return replaced; + }), + // stop using active style + new InputRule( + new RegExp(/%%$/), + (state, match, start, end) => { + const tr = state.tr.deleteRange(start, end); + const marks = state.tr.selection.$anchor.nodeBefore?.marks; + return marks ? Array.from(marks).filter(m => m !== state.schema.marks.user_mark).reduce((tr, m) => tr.removeStoredMark(m), tr) : tr; + }), + + // set the Todo user-tag on the current selection (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/[ti!x]$/), + (state, match, start, end) => { + if (state.selection.to === state.selection.from || !this.EnteringStyle) return null; + const tag = match[0] === "t" ? "todo" : match[0] === "i" ? "ignore" : match[0] === "x" ? "disagree" : match[0] === "!" ? "important" : "??"; + const node = (state.doc.resolve(start) as any).nodeAfter; + if (node?.marks.findIndex((m: any) => m.type === schema.marks.user_tag) !== -1) return state.tr.removeMark(start, end, schema.marks.user_tag); + return node ? state.tr.addMark(start, end, schema.marks.user_tag.create({ userid: Doc.CurrentUserEmail, tag: tag, modified: Math.round(Date.now() / 1000 / 60) })) : state.tr; + }), + + // set the First-line indent node type for the selection's paragraph (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/(%d|d)$/), + (state, match, start, end) => { + if (!match[0].startsWith("%") && !this.EnteringStyle) return null; + const pos = (state.doc.resolve(start) as any); + for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const node = pos.node(depth); + if (node.type === schema.nodes.paragraph) { + const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, indent: node.attrs.indent === 25 ? undefined : 25 }); + const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); + return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + } + } + return null; + }), + + // set the Hanging indent node type for the current selection's paragraph (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/(%h|h)$/), + (state, match, start, end) => { + if (!match[0].startsWith("%") && !this.EnteringStyle) return null; + const pos = (state.doc.resolve(start) as any); + for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const node = pos.node(depth); + if (node.type === schema.nodes.paragraph) { + const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, indent: node.attrs.indent === -25 ? undefined : -25 }); + const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); + return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + } + } + return null; + }), + // set the Quoted indent node type for the current selection's paragraph (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/(%q|q)$/), + (state, match, start, end) => { + if (!match[0].startsWith("%") && !this.EnteringStyle) return null; + const pos = (state.doc.resolve(start) as any); + if (state.selection instanceof NodeSelection && state.selection.node.type === schema.nodes.ordered_list) { + const node = state.selection.node; + return state.tr.setNodeMarkup(pos.pos, node.type, { ...node.attrs, indent: node.attrs.indent === 30 ? undefined : 30 }); + } + for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const node = pos.node(depth); + if (node.type === schema.nodes.paragraph) { + const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, inset: node.attrs.inset === 30 ? undefined : 30 }); + const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); + return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + } + } + return null; + }), + + + // center justify text + new InputRule( + new RegExp(/%\^$/), + (state, match, start, end) => { + const node = (state.doc.resolve(start) as any).nodeAfter; + const sm = state.storedMarks || undefined; + const replaced = node ? state.tr.replaceRangeWith(start, end, schema.nodes.paragraph.create({ align: "center" })).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + state.tr; + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); + }), + // left justify text + new InputRule( + new RegExp(/%\[$/), + (state, match, start, end) => { + const node = (state.doc.resolve(start) as any).nodeAfter; + const sm = state.storedMarks || undefined; + const replaced = node ? state.tr.replaceRangeWith(start, end, schema.nodes.paragraph.create({ align: "left" })).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + state.tr; + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); + }), + // right justify text + new InputRule( + new RegExp(/%\]$/), + (state, match, start, end) => { + const node = (state.doc.resolve(start) as any).nodeAfter; + const sm = state.storedMarks || undefined; + const replaced = node ? state.tr.replaceRangeWith(start, end, schema.nodes.paragraph.create({ align: "right" })).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + state.tr; + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); + }), + new InputRule( + new RegExp(/%\(/), + (state, match, start, end) => { + const node = (state.doc.resolve(start) as any).nodeAfter; + const sm = state.storedMarks || []; + const mark = state.schema.marks.summarizeInclusive.create(); + sm.push(mark); + const selected = state.tr.setSelection(new TextSelection(state.doc.resolve(start), state.doc.resolve(end))).addMark(start, end, mark); + const content = selected.selection.content(); + const replaced = node ? selected.replaceRangeWith(start, end, + schema.nodes.summary.create({ visibility: true, text: content, textslice: content.toJSON() })) : + state.tr; + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end + 1))).setStoredMarks([...node.marks, ...sm]); + }), + new InputRule( + new RegExp(/%\)/), + (state, match, start, end) => { + return state.tr.deleteRange(start, end).removeStoredMark(state.schema.marks.summarizeInclusive.create()); + }), + new InputRule( + new RegExp(/%f$/), + (state, match, start, end) => { + const newNode = schema.nodes.footnote.create({}); + const tr = state.tr; + tr.deleteRange(start, end).replaceSelectionWith(newNode); // replace insertion with a footnote. + return tr.setSelection(new NodeSelection( // select the footnote node to open its display + tr.doc.resolve( // get the location of the footnote node by subtracting the nodesize of the footnote from the current insertion point anchor (which will be immediately after the footnote node) + tr.selection.anchor - tr.selection.$anchor.nodeBefore!.nodeSize))); + }), + + // activate a style by name using prefix '%' + new InputRule( + new RegExp(/%[a-z]+$/), + (state, match, start, end) => { + const color = match[0].substring(1, match[0].length); + const marks = RichTextMenu.Instance._brushMap.get(color); + if (marks) { + const tr = state.tr.deleteRange(start, end); + return marks ? Array.from(marks).reduce((tr, m) => tr.addStoredMark(m), tr) : tr; + } + const isValidColor = (strColor: string) => { + const s = new Option().style; + s.color = strColor; + return s.color === strColor.toLowerCase(); // 'false' if color wasn't assigned + }; + if (isValidColor(color)) { + return state.tr.deleteRange(start, end).addStoredMark(schema.marks.pFontColor.create({ color: color })); + } + return null; + }), + ] + }; +} diff --git a/src/client/views/nodes/formattedText/RichTextSchema.tsx b/src/client/views/nodes/formattedText/RichTextSchema.tsx new file mode 100644 index 000000000..33caf5751 --- /dev/null +++ b/src/client/views/nodes/formattedText/RichTextSchema.tsx @@ -0,0 +1,718 @@ +import { IReactionDisposer, observable, reaction, runInAction } from "mobx"; +import { baseKeymap, toggleMark } from "prosemirror-commands"; +import { redo, undo } from "prosemirror-history"; +import { keymap } from "prosemirror-keymap"; +import { DOMOutputSpecArray, Fragment, MarkSpec, Node, NodeSpec, Schema, Slice } from "prosemirror-model"; +import { bulletList, listItem, orderedList } from 'prosemirror-schema-list'; +import { EditorState, NodeSelection, Plugin, TextSelection } from "prosemirror-state"; +import { StepMap } from "prosemirror-transform"; +import { EditorView } from "prosemirror-view"; +import * as ReactDOM from 'react-dom'; +import { Doc, DocListCast, Field, HeightSym, WidthSym } from "../../../../new_fields/Doc"; +import { Id } from "../../../../new_fields/FieldSymbols"; +import { List } from "../../../../new_fields/List"; +import { ObjectField } from "../../../../new_fields/ObjectField"; +import { listSpec } from "../../../../new_fields/Schema"; +import { SchemaHeaderField } from "../../../../new_fields/SchemaHeaderField"; +import { ComputedField } from "../../../../new_fields/ScriptField"; +import { BoolCast, Cast, NumCast, StrCast, FieldValue } from "../../../../new_fields/Types"; +import { emptyFunction, returnEmptyString, returnFalse, returnOne, Utils, returnZero } from "../../../../Utils"; +import { DocServer } from "../../../DocServer"; +import { Docs } from "../../../documents/Documents"; +import { CollectionViewType } from "../../collections/CollectionView"; +import { DocumentView } from "../DocumentView"; +import { FormattedTextBox } from "./FormattedTextBox"; +import { DocumentManager } from "../../../util/DocumentManager"; +import { Transform } from "../../../util/Transform"; +import React = require("react"); + +import { schema } from "./schema_rts"; + +export class OrderedListView { + update(node: any) { + return false; // if attr's of an ordered_list (e.g., bulletStyle) change, return false forces the dom node to be recreated which is necessary for the bullet labels to update + } +} + +export class ImageResizeView { + _handle: HTMLElement; + _img: HTMLElement; + _outer: HTMLElement; + constructor(node: any, view: any, getPos: any, addDocTab: any) { + //moved + 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.height = node.attrs.height; + this._outer.style.display = "inline-block"; + this._outer.style.overflow = "hidden"; + (this._outer.style as any).float = node.attrs.float; + //moved + 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"; + const self = this; + //moved + this._img.onclick = function (e: any) { + e.stopPropagation(); + e.preventDefault(); + if (view.state.selection.node && view.state.selection.node.type !== view.state.schema.nodes.image) { + view.dispatch(view.state.tr.setSelection(new NodeSelection(view.state.doc.resolve(view.state.selection.from - 2)))); + } + }; + //moved + this._img.onpointerdown = function (e: any) { + if (e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); + DocServer.GetRefField(node.attrs.docid).then(async linkDoc => + (linkDoc instanceof Doc) && + DocumentManager.Instance.FollowLink(linkDoc, view.state.schema.Document, + document => addDocTab(document, node.attrs.location ? node.attrs.location : "inTab"), false)); + } + }; + //moved + this._handle.onpointerdown = function (e: any) { + e.preventDefault(); + e.stopPropagation(); + const wid = Number(getComputedStyle(self._img).width.replace(/px/, "")); + const hgt = Number(getComputedStyle(self._img).height.replace(/px/, "")); + 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}`; + self._outer.style.height = `${(startWidth + diffInPx) * hgt / wid}`; + }; + + const onpointerup = () => { + document.removeEventListener("pointermove", onpointermove); + document.removeEventListener("pointerup", onpointerup); + const pos = view.state.selection.from; + view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, { ...node.attrs, width: self._outer.style.width, height: self._outer.style.height })); + view.dispatch(view.state.tr.setSelection(new NodeSelection(view.state.doc.resolve(pos)))); + }; + + document.addEventListener("pointermove", onpointermove); + document.addEventListener("pointerup", onpointerup); + }; + //Moved + this._outer.appendChild(this._img); + this._outer.appendChild(this._handle); + (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"; + } +} + +export class DashDocCommentView { + _collapsed: HTMLElement; + _view: any; + constructor(node: any, view: any, getPos: any) { + //moved + this._collapsed = document.createElement("span"); + this._collapsed.className = "formattedTextBox-inlineComment"; + this._collapsed.id = "DashDocCommentView-" + node.attrs.docid; + this._view = view; + //moved + const targetNode = () => { // search forward in the prosemirror doc for the attached dashDocNode that is the target of the comment anchor + for (let i = getPos() + 1; i < view.state.doc.content.size; i++) { + const m = view.state.doc.nodeAt(i); + if (m && m.type === view.state.schema.nodes.dashDoc && m.attrs.docid === node.attrs.docid) { + return { node: m, pos: i, hidden: m.attrs.hidden } as { node: any, pos: number, hidden: boolean }; + } + } + const dashDoc = view.state.schema.nodes.dashDoc.create({ width: 75, height: 35, title: "dashDoc", docid: node.attrs.docid, float: "right" }); + view.dispatch(view.state.tr.insert(getPos() + 1, dashDoc)); + setTimeout(() => { try { view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.tr.doc, getPos() + 2))); } catch (e) { } }, 0); + return undefined; + }; + //moved + this._collapsed.onpointerdown = (e: any) => { + e.stopPropagation(); + }; + //moved + this._collapsed.onpointerup = (e: any) => { + const target = targetNode(); + if (target) { + const expand = target.hidden; + const tr = view.state.tr.setNodeMarkup(target.pos, undefined, { ...target.node.attrs, hidden: target.node.attrs.hidden ? false : true }); + view.dispatch(tr.setSelection(TextSelection.create(tr.doc, getPos() + (expand ? 2 : 1)))); // update the attrs + setTimeout(() => { + expand && DocServer.GetRefField(node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc)); + try { view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.tr.doc, getPos() + (expand ? 2 : 1)))); } catch (e) { } + }, 0); + } + e.stopPropagation(); + }; + //moved + this._collapsed.onpointerenter = (e: any) => { + DocServer.GetRefField(node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc, false)); + e.preventDefault(); + e.stopPropagation(); + }; + //moved + this._collapsed.onpointerleave = (e: any) => { + DocServer.GetRefField(node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowUnhighlight()); + e.preventDefault(); + e.stopPropagation(); + }; + + (this as any).dom = this._collapsed; + } + //moved + selectNode() { } +} + +export class DashDocView { + _dashSpan: HTMLDivElement; + _outer: HTMLElement; + _dashDoc: Doc | undefined; + _reactionDisposer: IReactionDisposer | undefined; + _renderDisposer: IReactionDisposer | undefined; + _textBox: FormattedTextBox; + + getDocTransform = () => { + const { scale, translateX, translateY } = Utils.GetScreenTransform(this._outer); + return new Transform(-translateX, -translateY, 1).scale(1 / this.contentScaling() / scale); + } + contentScaling = () => NumCast(this._dashDoc!._nativeWidth) > 0 ? this._dashDoc![WidthSym]() / NumCast(this._dashDoc!._nativeWidth) : 1; + + outerFocus = (target: Doc) => this._textBox.props.focus(this._textBox.props.Document); // ideally, this would scroll to show the focus target + + constructor(node: any, view: any, getPos: any, tbox: FormattedTextBox) { + this._textBox = tbox; + this._dashSpan = document.createElement("div"); + this._outer = document.createElement("span"); + this._outer.style.position = "relative"; + this._outer.style.textIndent = "0"; + this._outer.style.border = "1px solid " + StrCast(tbox.layoutDoc.color, (Cast(Doc.UserDoc().activeWorkspace, Doc, null).darkScheme ? "dimGray" : "lightGray")); + this._outer.style.width = node.attrs.width; + this._outer.style.height = node.attrs.height; + this._outer.style.display = node.attrs.hidden ? "none" : "inline-block"; + // this._outer.style.overflow = "hidden"; // bcz: not sure if this is needed. if it's used, then the doc doesn't highlight when you hover over a docComment + (this._outer.style as any).float = node.attrs.float; + + this._dashSpan.style.width = node.attrs.width; + this._dashSpan.style.height = node.attrs.height; + this._dashSpan.style.position = "absolute"; + this._dashSpan.style.display = "inline-block"; + this._dashSpan.onpointerleave = () => { + const ele = document.getElementById("DashDocCommentView-" + node.attrs.docid); + if (ele) { + (ele as HTMLDivElement).style.backgroundColor = ""; + } + }; + this._dashSpan.onpointerenter = () => { + const ele = document.getElementById("DashDocCommentView-" + node.attrs.docid); + if (ele) { + (ele as HTMLDivElement).style.backgroundColor = "orange"; + } + }; + const removeDoc = () => { + const pos = getPos(); + const ns = new NodeSelection(view.state.doc.resolve(pos)); + view.dispatch(view.state.tr.setSelection(ns).deleteSelection()); + return true; + }; + const alias = node.attrs.alias; + + const docid = node.attrs.docid || tbox.props.Document[Id];// tbox.props.DataDoc?.[Id] || tbox.dataDoc?.[Id]; + DocServer.GetRefField(docid + alias).then(async dashDoc => { + if (!(dashDoc instanceof Doc)) { + alias && DocServer.GetRefField(docid).then(async dashDocBase => { + if (dashDocBase instanceof Doc) { + const aliasedDoc = Doc.MakeAlias(dashDocBase, docid + alias); + aliasedDoc.layoutKey = "layout"; + node.attrs.fieldKey && Doc.makeCustomViewClicked(aliasedDoc, Docs.Create.StackingDocument, node.attrs.fieldKey, undefined); + self.doRender(aliasedDoc, removeDoc, node, view, getPos); + } + }); + } else { + self.doRender(dashDoc, removeDoc, node, view, getPos); + } + }); + const self = this; + this._dashSpan.onkeydown = function (e: any) { + e.stopPropagation(); + if (e.key === "Tab" || e.key === "Enter") { + e.preventDefault(); + } + }; + this._dashSpan.onkeypress = function (e: any) { e.stopPropagation(); }; + this._dashSpan.onwheel = function (e: any) { e.preventDefault(); }; + this._dashSpan.onkeyup = function (e: any) { e.stopPropagation(); }; + this._outer.appendChild(this._dashSpan); + (this as any).dom = this._outer; + } + + doRender(dashDoc: Doc, removeDoc: any, node: any, view: any, getPos: any) { + this._dashDoc = dashDoc; + const self = this; + const dashLayoutDoc = Doc.Layout(dashDoc); + const finalLayout = node.attrs.docid ? dashDoc : Doc.expandTemplateLayout(dashLayoutDoc, dashDoc, node.attrs.fieldKey); + + if (!finalLayout) setTimeout(() => self.doRender(dashDoc, removeDoc, node, view, getPos), 0); + else { + this._reactionDisposer?.(); + this._reactionDisposer = reaction(() => ({ dim: [finalLayout[WidthSym](), finalLayout[HeightSym]()], color: finalLayout.color }), ({ dim, color }) => { + this._dashSpan.style.width = this._outer.style.width = Math.max(20, dim[0]) + "px"; + this._dashSpan.style.height = this._outer.style.height = Math.max(20, dim[1]) + "px"; + this._outer.style.border = "1px solid " + StrCast(finalLayout.color, (Cast(Doc.UserDoc().activeWorkspace, Doc, null).darkScheme ? "dimGray" : "lightGray")); + }, { fireImmediately: true }); + const doReactRender = (finalLayout: Doc, resolvedDataDoc: Doc) => { + ReactDOM.unmountComponentAtNode(this._dashSpan); + + ReactDOM.render(, this._dashSpan); + if (node.attrs.width !== dashDoc._width + "px" || node.attrs.height !== dashDoc._height + "px") { + try { // bcz: an exception will be thrown if two aliases are open at the same time when a doc view comment is made + view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, { ...node.attrs, width: dashDoc._width + "px", height: dashDoc._height + "px" })); + } catch (e) { + console.log(e); + } + } + }; + this._renderDisposer?.(); + this._renderDisposer = reaction(() => { + // if (!Doc.AreProtosEqual(finalLayout, dashDoc)) { + // finalLayout.rootDocument = dashDoc.aliasOf; // bcz: check on this ... why is it here? + // } + const layoutKey = StrCast(finalLayout.layoutKey); + const finalKey = layoutKey && StrCast(finalLayout[layoutKey]).split("'")?.[1]; + if (finalLayout !== dashDoc && finalKey) { + const finalLayoutField = finalLayout[finalKey]; + if (finalLayoutField instanceof ObjectField) { + finalLayout[finalKey + "-textTemplate"] = ComputedField.MakeFunction(`copyField(this.${finalKey})`, { this: Doc.name }); + } + } + return { finalLayout, resolvedDataDoc: Cast(finalLayout.resolvedDataDoc, Doc, null) }; + }, + (res) => doReactRender(res.finalLayout, res.resolvedDataDoc), + { fireImmediately: true }); + } + } + destroy() { + ReactDOM.unmountComponentAtNode(this._dashSpan); + this._reactionDisposer?.(); + } +} + +export class DashFieldView { + _fieldWrapper: HTMLDivElement; // container for label and value + _labelSpan: HTMLSpanElement; // field label + _fieldSpan: HTMLSpanElement; // field value + _fieldCheck: HTMLInputElement; + _enumerables: HTMLDivElement; // field value + _reactionDisposer: IReactionDisposer | undefined; + _textBoxDoc: Doc; + @observable _dashDoc: Doc | undefined; + _fieldKey: string; + _options: Doc[] = []; + + constructor(node: any, view: any, getPos: any, tbox: FormattedTextBox) { + this._fieldKey = node.attrs.fieldKey; + this._textBoxDoc = tbox.props.Document; + this._fieldWrapper = document.createElement("p"); + this._fieldWrapper.style.width = node.attrs.width; + this._fieldWrapper.style.height = node.attrs.height; + this._fieldWrapper.style.fontWeight = "bold"; + this._fieldWrapper.style.position = "relative"; + this._fieldWrapper.style.display = "inline-block"; + + const self = this; + + this._enumerables = document.createElement("div"); + this._enumerables.style.width = "10px"; + this._enumerables.style.height = "10px"; + this._enumerables.style.position = "relative"; + this._enumerables.style.display = "none"; + + //Moved + this._enumerables.onpointerdown = async (e) => { + e.stopPropagation(); + const collview = await Doc.addFieldEnumerations(self._textBoxDoc, self._fieldKey, [{ title: self._fieldSpan.innerText }]); + collview instanceof Doc && tbox.props.addDocTab(collview, "onRight"); + }; + //Moved + const updateText = (forceMatch: boolean) => { + self._enumerables.style.display = "none"; + const newText = self._fieldSpan.innerText.startsWith(":=") || self._fieldSpan.innerText.startsWith("=:=") ? ":=-computed-" : self._fieldSpan.innerText; + + // look for a document whose id === the fieldKey being displayed. If there's a match, then that document + // holds the different enumerated values for the field in the titles of its collected documents. + // if there's a partial match from the start of the input text, complete the text --- TODO: make this an auto suggest box and select from a drop down. + DocServer.GetRefField(self._fieldKey).then(options => { + let modText = ""; + (options instanceof Doc) && DocListCast(options.data).forEach(opt => (forceMatch ? StrCast(opt.title).startsWith(newText) : StrCast(opt.title) === newText) && (modText = StrCast(opt.title))); + if (modText) { + self._fieldSpan.innerHTML = self._dashDoc![self._fieldKey] = modText; + Doc.addFieldEnumerations(self._textBoxDoc, self._fieldKey, []); + } // if the text starts with a ':=' then treat it as an expression by making a computed field from its value storing it in the key + else if (self._fieldSpan.innerText.startsWith(":=")) { + self._dashDoc![self._fieldKey] = ComputedField.MakeFunction(self._fieldSpan.innerText.substring(2)); + } else if (self._fieldSpan.innerText.startsWith("=:=")) { + Doc.Layout(tbox.props.Document)[self._fieldKey] = ComputedField.MakeFunction(self._fieldSpan.innerText.substring(3)); + } else { + self._dashDoc![self._fieldKey] = newText; + } + }); + }; + + //Moved + this._fieldCheck = document.createElement("input"); + this._fieldCheck.id = Utils.GenerateGuid(); + this._fieldCheck.type = "checkbox"; + this._fieldCheck.style.position = "relative"; + this._fieldCheck.style.display = "none"; + this._fieldCheck.style.minWidth = "12px"; + this._fieldCheck.style.backgroundColor = "rgba(155, 155, 155, 0.24)"; + this._fieldCheck.onchange = function (e: any) { + self._dashDoc![self._fieldKey] = e.target.checked; + }; + + this._fieldSpan = document.createElement("span"); + this._fieldSpan.id = Utils.GenerateGuid(); + this._fieldSpan.contentEditable = "true"; + this._fieldSpan.style.position = "relative"; + this._fieldSpan.style.display = "none"; + this._fieldSpan.style.minWidth = "12px"; + this._fieldSpan.style.fontSize = "large"; + this._fieldSpan.onkeypress = function (e: any) { e.stopPropagation(); }; + this._fieldSpan.onkeyup = function (e: any) { e.stopPropagation(); }; + this._fieldSpan.onmousedown = function (e: any) { e.stopPropagation(); self._enumerables.style.display = "inline-block"; }; + this._fieldSpan.onblur = function (e: any) { updateText(false); }; + + // MOVED + const setDashDoc = (doc: Doc) => { + self._dashDoc = doc; + if (self._options?.length && !self._dashDoc[self._fieldKey]) { + self._dashDoc[self._fieldKey] = StrCast(self._options[0].title); + } + this._labelSpan.innerHTML = `${self._fieldKey}: `; + const fieldVal = Cast(this._dashDoc?.[self._fieldKey], "boolean", null); + this._fieldCheck.style.display = (fieldVal === true || fieldVal === false) ? "inline-block" : "none"; + this._fieldSpan.style.display = !(fieldVal === true || fieldVal === false) ? StrCast(this._dashDoc?.[self._fieldKey]) ? "" : "inline-block" : "none"; + }; + + //Moved + this._fieldSpan.onkeydown = function (e: any) { + e.stopPropagation(); + if ((e.key === "a" && e.ctrlKey) || (e.key === "a" && e.metaKey)) { + if (window.getSelection) { + const range = document.createRange(); + range.selectNodeContents(self._fieldSpan); + window.getSelection()!.removeAllRanges(); + window.getSelection()!.addRange(range); + } + e.preventDefault(); + } + if (e.key === "Enter") { + e.preventDefault(); + e.ctrlKey && Doc.addFieldEnumerations(self._textBoxDoc, self._fieldKey, [{ title: self._fieldSpan.innerText }]); + updateText(true); + } + }; + + this._labelSpan = document.createElement("span"); + this._labelSpan.style.position = "relative"; + this._labelSpan.style.fontSize = "small"; + this._labelSpan.title = "click to see related tags"; + this._labelSpan.style.fontSize = "x-small"; + this._labelSpan.onpointerdown = function (e: any) { + e.stopPropagation(); + let container = tbox.props.ContainingCollectionView; + while (container?.props.Document.isTemplateForField || container?.props.Document.isTemplateDoc) { + container = container.props.ContainingCollectionView; + } + if (container) { + const alias = Doc.MakeAlias(container.props.Document); + alias.viewType = CollectionViewType.Time; + let list = Cast(alias.schemaColumns, listSpec(SchemaHeaderField)); + if (!list) { + alias.schemaColumns = list = new List(); + } + list.map(c => c.heading).indexOf(self._fieldKey) === -1 && list.push(new SchemaHeaderField(self._fieldKey, "#f1efeb")); + list.map(c => c.heading).indexOf("text") === -1 && list.push(new SchemaHeaderField("text", "#f1efeb")); + alias._pivotField = self._fieldKey; + tbox.props.addDocTab(alias, "onRight"); + } + }; + this._labelSpan.innerHTML = `${self._fieldKey}: `; + //MOVED + if (node.attrs.docid) { + DocServer.GetRefField(node.attrs.docid). + then(async dashDoc => dashDoc instanceof Doc && runInAction(() => setDashDoc(dashDoc))); + } else { + setDashDoc(tbox.props.DataDoc || tbox.dataDoc); + } + + //Moved + this._reactionDisposer?.(); + this._reactionDisposer = reaction(() => { // this reaction will update the displayed text whenever the document's fieldKey's value changes + const dashVal = this._dashDoc?.[self._fieldKey]; + return StrCast(dashVal).startsWith(":=") || dashVal === "" ? Doc.Layout(tbox.props.Document)[self._fieldKey] : dashVal; + }, fval => { + const boolVal = Cast(fval, "boolean", null); + if (boolVal === true || boolVal === false) { + this._fieldCheck.checked = boolVal; + } else { + this._fieldSpan.innerHTML = Field.toString(fval as Field) || ""; + } + this._fieldCheck.style.display = (boolVal === true || boolVal === false) ? "inline-block" : "none"; + this._fieldSpan.style.display = !(fval === true || fval === false) ? (StrCast(fval) ? "" : "inline-block") : "none"; + }, { fireImmediately: true }); + + //MOVED IN ORDER + this._fieldWrapper.appendChild(this._labelSpan); + this._fieldWrapper.appendChild(this._fieldCheck); + this._fieldWrapper.appendChild(this._fieldSpan); + this._fieldWrapper.appendChild(this._enumerables); + (this as any).dom = this._fieldWrapper; + //updateText(false); + } + //MOVED + destroy() { + this._reactionDisposer?.(); + } + //moved + selectNode() { } +} + +export class FootnoteView { + innerView: any; + outerView: any; + node: any; + dom: any; + getPos: any; + + constructor(node: any, view: any, getPos: any) { + // We'll need these later + this.node = node; + this.outerView = view; + this.getPos = getPos; + + // The node's representation in the editor (empty, for now) + this.dom = document.createElement("footnote"); + this.dom.addEventListener("pointerup", this.toggle, true); + // These are used when the footnote is selected + this.innerView = null; + } + selectNode() { + const attrs = { ...this.node.attrs }; + attrs.visibility = true; + this.dom.classList.add("ProseMirror-selectednode"); + if (!this.innerView) this.open(); + } + + deselectNode() { + const attrs = { ...this.node.attrs }; + attrs.visibility = false; + this.dom.classList.remove("ProseMirror-selectednode"); + if (this.innerView) this.close(); + } + open() { + // Append a tooltip to the outer node + const tooltip = this.dom.appendChild(document.createElement("div")); + tooltip.className = "footnote-tooltip"; + // And put a sub-ProseMirror into that + this.innerView = new EditorView(tooltip, { + // You can use any node as an editor document + state: EditorState.create({ + doc: this.node, + plugins: [keymap(baseKeymap), + keymap({ + "Mod-z": () => undo(this.outerView.state, this.outerView.dispatch), + "Mod-y": () => redo(this.outerView.state, this.outerView.dispatch), + "Mod-b": toggleMark(schema.marks.strong) + }), + // new Plugin({ + // view(newView) { + // // TODO -- make this work with RichTextMenu + // // return FormattedTextBox.getToolTip(newView); + // } + // }) + ], + + }), + // This is the magic part + dispatchTransaction: this.dispatchInner.bind(this), + handleDOMEvents: { + pointerdown: ((view: any, e: PointerEvent) => { + // Kludge to prevent issues due to the fact that the whole + // footnote is node-selected (and thus DOM-selected) when + // the parent editor is focused. + e.stopPropagation(); + document.addEventListener("pointerup", this.ignore, true); + if (this.outerView.hasFocus()) this.innerView.focus(); + }) as any + } + + }); + setTimeout(() => this.innerView && this.innerView.docView.setSelection(0, 0, this.innerView.root, true), 0); + } + + ignore = (e: PointerEvent) => { + e.stopPropagation(); + document.removeEventListener("pointerup", this.ignore, true); + } + + toggle = () => { + if (this.innerView) this.close(); + else { + this.open(); + } + } + close() { + this.innerView && this.innerView.destroy(); + this.innerView = null; + this.dom.textContent = ""; + } + + dispatchInner(tr: any) { + const { state, transactions } = this.innerView.state.applyTransaction(tr); + this.innerView.updateState(state); + + if (!tr.getMeta("fromOutside")) { + const outerTr = this.outerView.state.tr, offsetMap = StepMap.offset(this.getPos() + 1); + for (const transaction of transactions) { + const steps = transaction.steps; + for (const step of steps) { + outerTr.step(step.map(offsetMap)); + } + } + if (outerTr.docChanged) this.outerView.dispatch(outerTr); + } + } + update(node: any) { + if (!node.sameMarkup(this.node)) return false; + this.node = node; + if (this.innerView) { + const state = this.innerView.state; + const start = node.content.findDiffStart(state.doc.content); + if (start !== null) { + let { a: endA, b: endB } = node.content.findDiffEnd(state.doc.content); + const overlap = start - Math.min(endA, endB); + if (overlap > 0) { endA += overlap; endB += overlap; } + this.innerView.dispatch( + state.tr + .replace(start, endB, node.slice(start, endA)) + .setMeta("fromOutside", true)); + } + } + return true; + } + + destroy() { + if (this.innerView) this.close(); + } + + stopEvent(event: any) { + return this.innerView && this.innerView.dom.contains(event.target); + } + + ignoreMutation() { return true; } +} + +export class SummaryView { + _collapsed: HTMLElement; + _view: any; + constructor(node: any, view: any, getPos: any) { + this._collapsed = document.createElement("span"); + this._collapsed.className = this.className(node.attrs.visibility); + this._view = view; + const js = node.toJSON; + node.toJSON = function () { + return js.apply(this, arguments); + }; + + this._collapsed.onpointerdown = (e: any) => { + const visible = !node.attrs.visibility; + const attrs = { ...node.attrs, visibility: visible }; + let textSelection = TextSelection.create(view.state.doc, getPos() + 1); + if (!visible) { // update summarized text and save in attrs + textSelection = this.updateSummarizedText(getPos() + 1); + attrs.text = textSelection.content(); + attrs.textslice = attrs.text.toJSON(); + } + view.dispatch(view.state.tr. + setSelection(textSelection). // select the current summarized text (or where it will be if its collapsed) + replaceSelection(!visible ? new Slice(Fragment.fromArray([]), 0, 0) : node.attrs.text). // collapse/expand it + setNodeMarkup(getPos(), undefined, attrs)); // update the attrs + e.preventDefault(); + e.stopPropagation(); + this._collapsed.className = this.className(visible); + }; + (this as any).dom = this._collapsed; + } + selectNode() { } + + deselectNode() { } + + className = (visible: boolean) => "formattedTextBox-summarizer" + (visible ? "" : "-collapsed"); + + updateSummarizedText(start?: any) { + const mtype = this._view.state.schema.marks.summarize; + const mtypeInc = this._view.state.schema.marks.summarizeInclusive; + let endPos = start; + + const visited = new Set(); + for (let i: number = start + 1; i < this._view.state.doc.nodeSize - 1; i++) { + let skip = false; + this._view.state.doc.nodesBetween(start, i, (node: Node, pos: number, parent: Node, index: number) => { + if (node.isLeaf && !visited.has(node) && !skip) { + if (node.marks.find((m: any) => m.type === mtype || m.type === mtypeInc)) { + visited.add(node); + endPos = i + node.nodeSize - 1; + } + else skip = true; + } + }); + } + return TextSelection.create(this._view.state.doc, start, endPos); + } +} \ No newline at end of file diff --git a/src/client/views/nodes/formattedText/SummaryView.tsx b/src/client/views/nodes/formattedText/SummaryView.tsx new file mode 100644 index 000000000..89908d8ee --- /dev/null +++ b/src/client/views/nodes/formattedText/SummaryView.tsx @@ -0,0 +1,81 @@ +import { TextSelection } from "prosemirror-state"; +import { Fragment, Node, Slice } from "prosemirror-model"; + +import React = require("react"); + +interface ISummaryView { + node: any; + view: any; + getPos: any; + self: any; +} +export class SummaryView extends React.Component { + + onPointerDown = (e: any) => { + const visible = !this.props.node.attrs.visibility; + const attrs = { ...this.props.node.attrs, visibility: visible }; + let textSelection = TextSelection.create(this.props.view.state.doc, this.props.getPos() + 1); + if (!visible) { // update summarized text and save in attrs + textSelection = this.updateSummarizedText(this.props.getPos() + 1); + attrs.text = textSelection.content(); + attrs.textslice = attrs.text.toJSON(); + } + this.props.view.dispatch(this.props.view.state.tr. + setSelection(textSelection). // select the current summarized text (or where it will be if its collapsed) + replaceSelection(!visible ? new Slice(Fragment.fromArray([]), 0, 0) : this.props.node.attrs.text). // collapse/expand it + setNodeMarkup(this.props.getPos(), undefined, attrs)); // update the attrs + e.preventDefault(); + e.stopPropagation(); + const _collapsed = document.getElementById('collapse') as HTMLElement; + _collapsed.className = this.className(visible); + } + + updateSummarizedText(start?: any) { + const mtype = this.props.view.state.schema.marks.summarize; + const mtypeInc = this.props.view.state.schema.marks.summarizeInclusive; + let endPos = start; + + const visited = new Set(); + for (let i: number = start + 1; i < this.props.view.state.doc.nodeSize - 1; i++) { + let skip = false; + this.props.view.state.doc.nodesBetween(start, i, (node: Node, pos: number, parent: Node, index: number) => { + if (this.props.node.isLeaf && !visited.has(node) && !skip) { + if (this.props.node.marks.find((m: any) => m.type === mtype || m.type === mtypeInc)) { + visited.add(node); + endPos = i + this.props.node.nodeSize - 1; + } + else skip = true; + } + }); + } + return TextSelection.create(this.props.view.state.doc, start, endPos); + } + + className = (visible: boolean) => "formattedTextBox-summarizer" + (visible ? "" : "-collapsed"); + + selectNode() { } + + deselectNode() { } + + render() { + const _view = this.props.node.view; + const js = this.props.node.toJSon; + + this.props.node.toJSON = function () { + return js.apply(this, arguments); + }; + + const spanCollapsedClassName = this.className(this.props.node.attrs.visibility); + + return ( + + + + ); + + } +} \ No newline at end of file diff --git a/src/client/views/nodes/formattedText/TooltipTextMenu.scss b/src/client/views/nodes/formattedText/TooltipTextMenu.scss new file mode 100644 index 000000000..e2149e9c1 --- /dev/null +++ b/src/client/views/nodes/formattedText/TooltipTextMenu.scss @@ -0,0 +1,372 @@ +@import "../views/globalCssVariables"; +.ProseMirror-menu-dropdown-wrap { + display: inline-block; + position: relative; +} + +.ProseMirror-menu-dropdown { + vertical-align: 1px; + cursor: pointer; + position: relative; + padding: 0 15px 0 4px; + background: white; + border-radius: 2px; + text-align: left; + font-size: 12px; + white-space: nowrap; + margin-right: 4px; + + &: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-submenu-wrap { + position: relative; + margin-right: -4px; +} + +.ProseMirror-menu-dropdown-menu, +.ProseMirror-menu-submenu { + font-size: 12px; + background: white; + border: 1px solid rgb(223, 223, 223); + min-width: 40px; + z-index: 50000; + position: absolute; + box-sizing: content-box; + + .ProseMirror-menu-dropdown-item { + cursor: pointer; + z-index: 100000; + text-align: left; + padding: 3px; + + &:hover { + background-color: $light-color-secondary; + } + } +} + + +.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-icon { + display: inline-block; + // line-height: .8; + // vertical-align: -2px; /* Compensate for padding */ + // padding: 2px 8px; + cursor: pointer; + + &.ProseMirror-menu-disabled { + cursor: default; + } + + svg { + fill:white; + height: 1em; + } + + span { + vertical-align: text-top; + } + } + +.wrapper { + position: absolute; + pointer-events: all; + display: flex; + align-items: center; + transform: translateY(-85px); + + height: 35px; + background: #323232; + border-radius: 6px; + box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25); + +} + +.tooltipMenu, .basic-tools { + z-index: 20000; + pointer-events: all; + padding: 3px; + padding-bottom: 5px; + display: flex; + align-items: center; + + .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; + } +} + +.menuicon { + width: 25px; + height: 25px; + cursor: pointer; + text-align: center; + line-height: 25px; + margin: 0 2px; + border-radius: 3px; + + &:hover { + background-color: black; + + #link-drag { + background-color: black; + } + } + + &> * { + margin-top: 50%; + margin-left: 50%; + transform: translate(-50%, -50%); + } + + svg { + fill: white; + width: 18px; + height: 18px; + } +} + +.menuicon-active { + width: 25px; + height: 25px; + cursor: pointer; + text-align: center; + line-height: 25px; + margin: 0 2px; + border-radius: 3px; + + &:hover { + background-color: black; + } + + &> * { + margin-top: 50%; + margin-left: 50%; + transform: translate(-50%, -50%); + } + + svg { + fill: greenyellow; + width: 18px; + height: 18px; + } +} + +#colorPicker { + position: relative; + + svg { + width: 18px; + height: 18px; + // margin-top: 11px; + } + + .buttonColor { + position: absolute; + top: 24px; + left: 1px; + width: 24px; + height: 4px; + margin-top: 0; + } +} + +#link-drag { + background-color: #323232; +} + +.underline svg { + margin-top: 13px; +} + + .font-size-indicator { + font-size: 12px; + padding-right: 0px; + } + .summarize{ + color: white; + height: 20px; + text-align: center; + } + + +.brush{ + display: inline-block; + width: 1em; + height: 1em; + stroke-width: 0; + stroke: currentColor; + fill: currentColor; + margin-right: 15px; +} + +.brush-active{ + display: inline-block; + width: 1em; + height: 1em; + stroke-width: 3; + fill: greenyellow; + margin-right: 15px; +} + +.dragger-wrapper { + color: #eee; + height: 22px; + padding: 0 5px; + box-sizing: content-box; + cursor: grab; + + .dragger { + width: 18px; + height: 100%; + display: flex; + justify-content: space-evenly; + } + + .dragger-line { + width: 2px; + height: 100%; + background-color: black; + } +} + +.button-dropdown-wrapper { + display: flex; + align-content: center; + + &:hover { + background-color: black; + } +} + +.buttonSettings-dropdown { + + &.ProseMirror-menu-dropdown { + width: 10px; + height: 25px; + margin: 0; + padding: 0 2px; + background-color: #323232; + text-align: center; + + &:after { + border-top: 4px solid white; + right: 2px; + } + + &:hover { + background-color: black; + } + } + + &.ProseMirror-menu-dropdown-menu { + min-width: 150px; + left: -27px; + top: 31px; + background-color: #323232; + border: 1px solid #4d4d4d; + color: $light-color-secondary; + // border: none; + // border: 1px solid $light-color-secondary; + border-radius: 0 6px 6px 6px; + padding: 3px; + box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25); + + .ProseMirror-menu-dropdown-item{ + cursor: default; + + &:last-child { + border-bottom: none; + } + + &:hover { + background-color: #323232; + } + + .button-setting, .button-setting-disabled { + padding: 2px; + border-radius: 2px; + } + + .button-setting:hover { + cursor: pointer; + background-color: black; + } + + .separated-button { + border-top: 1px solid $light-color-secondary; + padding-top: 6px; + } + + input { + color: black; + border: none; + border-radius: 1px; + padding: 3px; + } + + button { + padding: 6px; + background-color: #323232; + border: 1px solid black; + border-radius: 1px; + + &:hover { + background-color: black; + } + } + } + + + } +} + +.colorPicker-wrapper { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + margin-top: 3px; + margin-left: -3px; + width: calc(100% + 6px); +} + +button.colorPicker { + width: 20px; + height: 20px; + border-radius: 15px !important; + margin: 3px; + border: none !important; + + &.active { + border: 2px solid white !important; + } +} diff --git a/src/client/views/nodes/formattedText/marks_rts.ts b/src/client/views/nodes/formattedText/marks_rts.ts new file mode 100644 index 000000000..46bf481fb --- /dev/null +++ b/src/client/views/nodes/formattedText/marks_rts.ts @@ -0,0 +1,296 @@ +import React = require("react"); +import { DOMOutputSpecArray, Fragment, MarkSpec, Node, NodeSpec, Schema, Slice } from "prosemirror-model"; +import { Doc } from "../../../../new_fields/Doc"; + + +const emDOM: DOMOutputSpecArray = ["em", 0]; +const strongDOM: DOMOutputSpecArray = ["strong", 0]; +const codeDOM: DOMOutputSpecArray = ["code", 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 `` + // element. + link: { + attrs: { + href: {}, + targetId: { default: "" }, + linkId: { default: "" }, + showPreview: { default: true }, + location: { default: null }, + title: { default: null }, + docref: { default: false } // flags whether the linked text comes from a document within Dash. If so, an attribution label is appended after the text + }, + inclusive: false, + parseDOM: [{ + tag: "a[href]", getAttrs(dom: any) { + return { href: dom.getAttribute("href"), location: dom.getAttribute("location"), title: dom.getAttribute("title"), targetId: dom.getAttribute("id") }; + } + }], + toDOM(node: any) { + return node.attrs.docref && node.attrs.title ? + ["div", ["span", `"`], ["span", 0], ["span", `"`], ["br"], ["a", { ...node.attrs, class: "prosemirror-attribution", title: `${node.attrs.title}` }, node.attrs.title], ["br"]] : + ["a", { ...node.attrs, id: node.attrs.linkId + node.attrs.targetId, title: `${node.attrs.title}` }, 0]; + } + }, + + + // :: MarkSpec Coloring on text. Has `color` attribute that defined the color of the marked text. + pFontColor: { + attrs: { + color: { default: "#000" } + }, + inclusive: true, + parseDOM: [{ + tag: "span", getAttrs(dom: any) { + return { color: dom.getAttribute("color") }; + } + }], + toDOM(node: any) { + return node.attrs.color ? ['span', { style: 'color:' + node.attrs.color }] : ['span', 0]; + } + }, + + marker: { + attrs: { + highlight: { default: "transparent" } + }, + inclusive: true, + parseDOM: [{ + tag: "span", getAttrs(dom: any) { + return { highlight: dom.getAttribute("backgroundColor") }; + } + }], + toDOM(node: any) { + return node.attrs.highlight ? ['span', { style: 'background-color:' + node.attrs.highlight }] : ['span', { style: 'background-color: transparent' }]; + } + }, + + // :: MarkSpec An emphasis mark. Rendered as an `` element. + // Has parse rules that also match `` and `font-style: italic`. + em: { + parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style: italic" }], + toDOM() { return emDOM; } + }, + + // :: MarkSpec A strong mark. Rendered as ``, parse rules + // also match `` and `font-weight: bold`. + strong: { + parseDOM: [{ tag: "strong" }, + { tag: "b" }, + { style: "font-weight" }], + toDOM() { return strongDOM; } + }, + + 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'] + }, + + mbulletType: { + attrs: { + bulletType: { default: "decimal" } + }, + toDOM(node: any) { + return ['span', { + style: `background: ${node.attrs.bulletType === "decimal" ? "yellow" : node.attrs.bulletType === "upper-alpha" ? "blue" : "green"}` + }]; + } + }, + + metadata: { + toDOM() { + return ['span', { style: 'font-size:75%; background:rgba(100, 100, 100, 0.2); ' }]; + } + }, + metadataKey: { + toDOM() { + return ['span', { style: 'font-style:italic; ' }]; + } + }, + metadataVal: { + toDOM() { + return ['span']; + } + }, + + summarizeInclusive: { + parseDOM: [ + { + tag: "span", + getAttrs: (p: any) => { + if (typeof (p) !== "string") { + const style = getComputedStyle(p); + if (style.textDecoration === "underline") return null; + if (p.parentElement.outerHTML.indexOf("text-decoration: underline") !== -1 && + p.parentElement.outerHTML.indexOf("text-decoration-style: solid") !== -1) { + return null; + } + } + return false; + } + }, + ], + inclusive: true, + toDOM() { + return ['span', { + style: 'text-decoration: underline; text-decoration-style: solid; text-decoration-color: rgba(204, 206, 210, 0.92)' + }]; + } + }, + + summarize: { + inclusive: false, + parseDOM: [ + { + tag: "span", + getAttrs: (p: any) => { + if (typeof (p) !== "string") { + const style = getComputedStyle(p); + if (style.textDecoration === "underline") return null; + if (p.parentElement.outerHTML.indexOf("text-decoration: underline") !== -1 && + p.parentElement.outerHTML.indexOf("text-decoration-style: dotted") !== -1) { + return null; + } + } + return false; + } + }, + ], + toDOM() { + return ['span', { + style: 'text-decoration: underline; text-decoration-style: dotted; text-decoration-color: rgba(204, 206, 210, 0.92)' + }]; + } + }, + + underline: { + parseDOM: [ + { + tag: "span", + getAttrs: (p: any) => { + if (typeof (p) !== "string") { + const style = getComputedStyle(p); + if (style.textDecoration === "underline" || p.parentElement.outerHTML.indexOf("text-decoration-style:line") !== -1) { + return null; + } + } + return false; + } + } + // { style: "text-decoration=underline" } + ], + toDOM: () => ['span', { + style: 'text-decoration:underline;text-decoration-style:line' + }] + }, + + search_highlight: { + attrs: { + selected: { default: false } + }, + parseDOM: [{ style: 'background: yellow' }], + toDOM(node: any) { + return ['span', { + style: `background: ${node.attrs.selected ? "orange" : "yellow"}` + }]; + } + }, + + // the id of the user who entered the text + user_mark: { + attrs: { + userid: { default: "" }, + modified: { default: "when?" }, // 1 second intervals since 1970 + }, + group: "inline", + toDOM(node: any) { + const uid = node.attrs.userid.replace(".", "").replace("@", ""); + const min = Math.round(node.attrs.modified / 12); + const hr = Math.round(min / 60); + const day = Math.round(hr / 60 / 24); + const remote = node.attrs.userid !== Doc.CurrentUserEmail ? " userMark-remote" : ""; + return ['span', { class: "userMark-" + uid + remote + " userMark-min-" + min + " userMark-hr-" + hr + " userMark-day-" + day }, 0]; + } + }, + // the id of the user who entered the text + user_tag: { + attrs: { + userid: { default: "" }, + modified: { default: "when?" }, // 1 second intervals since 1970 + tag: { default: "" } + }, + group: "inline", + inclusive: false, + toDOM(node: any) { + const uid = node.attrs.userid.replace(".", "").replace("@", ""); + return ['span', { class: "userTag-" + uid + " userTag-" + node.attrs.tag }, 0]; + } + }, + + + // :: MarkSpec Code font mark. Represented as a `` element. + code: { + parseDOM: [{ tag: "code" }], + toDOM() { return codeDOM; } + }, + + /* FONTS */ + pFontFamily: { + attrs: { + family: { default: "Crimson Text" }, + }, + parseDOM: [{ + tag: "span", getAttrs(dom: any) { + const cstyle = getComputedStyle(dom); + if (cstyle.font) { + if (cstyle.font.indexOf("Times New Roman") !== -1) return { family: "Times New Roman" }; + if (cstyle.font.indexOf("Arial") !== -1) return { family: "Arial" }; + if (cstyle.font.indexOf("Georgia") !== -1) return { family: "Georgia" }; + if (cstyle.font.indexOf("Comic Sans") !== -1) return { family: "Comic Sans MS" }; + if (cstyle.font.indexOf("Tahoma") !== -1) return { family: "Tahoma" }; + if (cstyle.font.indexOf("Crimson") !== -1) return { family: "Crimson Text" }; + } + } + }], + toDOM: (node) => ['span', { + style: `font-family: "${node.attrs.family}";` + }] + }, + + /** FONT SIZES */ + pFontSize: { + attrs: { + fontSize: { default: 10 } + }, + parseDOM: [{ style: 'font-size: 10px;' }], + toDOM: (node) => ['span', { + style: `font-size: ${node.attrs.fontSize}px;` + }] + }, +}; diff --git a/src/client/views/nodes/formattedText/nodes_rts.ts b/src/client/views/nodes/formattedText/nodes_rts.ts new file mode 100644 index 000000000..e7bcf444a --- /dev/null +++ b/src/client/views/nodes/formattedText/nodes_rts.ts @@ -0,0 +1,264 @@ +import React = require("react"); +import { DOMOutputSpecArray, Fragment, MarkSpec, Node, NodeSpec, Schema, Slice } from "prosemirror-model"; +import { bulletList, listItem, orderedList } from 'prosemirror-schema-list'; +import ParagraphNodeSpec from "./ParagraphNodeSpec"; + +const blockquoteDOM: DOMOutputSpecArray = ["blockquote", 0], hrDOM: DOMOutputSpecArray = ["hr"], + 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+" + }, + + footnote: { + group: "inline", + content: "inline*", + inline: true, + attrs: { + visibility: { default: false } + }, + // This makes the view treat the node as a leaf, even though it + // technically has content + atom: true, + toDOM: () => ["footnote", 0], + parseDOM: [{ tag: "footnote" }] + }, + + paragraph: ParagraphNodeSpec, + + // :: NodeSpec A blockquote (`
`) wrapping one or more blocks. + blockquote: { + content: "block+", + group: "block", + defining: true, + parseDOM: [{ tag: "blockquote" }], + toDOM() { return blockquoteDOM; } + }, + + // :: NodeSpec A horizontal rule (`
`). + 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 `

` to + // `

` 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 `
` element with a
+    // `` 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"
+    },
+
+    dashComment: {
+        attrs: {
+            docid: { default: "" },
+        },
+        inline: true,
+        group: "inline",
+        toDOM(node) {
+            const attrs = { style: `width: 40px` };
+            return ["span", { ...node.attrs, ...attrs }, "←"];
+        },
+    },
+
+    summary: {
+        inline: true,
+        attrs: {
+            visibility: { default: false },
+            text: { default: undefined },
+            textslice: { default: undefined },
+        },
+        group: "inline",
+        toDOM(node) {
+            const attrs = { style: `width: 40px` };
+            return ["span", { ...node.attrs, ...attrs }];
+        },
+    },
+
+    // :: NodeSpec An inline image (``) node. Supports `src`,
+    // `alt`, and `href` attributes. The latter two default to the empty
+    // string.
+    image: {
+        inline: true,
+        attrs: {
+            src: {},
+            agnostic: { default: null },
+            width: { default: 100 },
+            alt: { default: null },
+            title: { default: null },
+            float: { default: "left" },
+            location: { default: "onRight" },
+            docid: { default: "" }
+        },
+        group: "inline",
+        draggable: true,
+        parseDOM: [{
+            tag: "img[src]", getAttrs(dom: any) {
+                return {
+                    src: dom.getAttribute("src"),
+                    title: dom.getAttribute("title"),
+                    alt: dom.getAttribute("alt"),
+                    width: Math.min(100, Number(dom.getAttribute("width"))),
+                };
+            }
+        }],
+        // TODO if we don't define toDom, dragging the image crashes. Why?
+        toDOM(node) {
+            const attrs = { style: `width: ${node.attrs.width}` };
+            return ["img", { ...node.attrs, ...attrs }];
+        }
+    },
+
+    dashDoc: {
+        inline: true,
+        attrs: {
+            width: { default: 200 },
+            height: { default: 100 },
+            title: { default: null },
+            float: { default: "right" },
+            location: { default: "onRight" },
+            hidden: { default: false },
+            fieldKey: { default: "" },
+            docid: { default: "" },
+            alias: { default: "" }
+        },
+        group: "inline",
+        draggable: false,
+        toDOM(node) {
+            const attrs = { style: `width: ${node.attrs.width}, height: ${node.attrs.height}` };
+            return ["div", { ...node.attrs, ...attrs }];
+        }
+    },
+
+    dashField: {
+        inline: true,
+        attrs: {
+            fieldKey: { default: "" },
+            docid: { default: "" }
+        },
+        group: "inline",
+        draggable: false,
+        toDOM(node) {
+            const attrs = { style: `width: ${node.attrs.width}, height: ${node.attrs.height}` };
+            return ["div", { ...node.attrs, ...attrs }];
+        }
+    },
+
+    video: {
+        inline: true,
+        attrs: {
+            src: {},
+            width: { default: "100px" },
+            alt: { default: null },
+            title: { default: null }
+        },
+        group: "inline",
+        draggable: true,
+        parseDOM: [{
+            tag: "video[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"))),
+                };
+            }
+        }],
+        toDOM(node) {
+            const attrs = { style: `width: ${node.attrs.width}` };
+            return ["video", { ...node.attrs, ...attrs }];
+        }
+    },
+
+    // :: NodeSpec A hard line break, represented in the DOM as `
`. + hard_break: { + inline: true, + group: "inline", + selectable: false, + parseDOM: [{ tag: "br" }], + toDOM() { return brDOM; } + }, + + ordered_list: { + ...orderedList, + content: 'list_item+', + group: 'block', + attrs: { + bulletStyle: { default: 0 }, + mapStyle: { default: "decimal" }, + setFontSize: { default: undefined }, + setFontFamily: { default: "inherit" }, + setFontColor: { default: "inherit" }, + inheritedFontSize: { default: undefined }, + visibility: { default: true }, + indent: { default: undefined } + }, + toDOM(node: Node) { + if (node.attrs.mapStyle === "bullet") return ['ul', 0]; + const map = node.attrs.bulletStyle ? node.attrs.mapStyle + node.attrs.bulletStyle : ""; + const fsize = node.attrs.setFontSize ? node.attrs.setFontSize : node.attrs.inheritedFontSize; + const ffam = node.attrs.setFontFamily; + const color = node.attrs.setFontColor; + return node.attrs.visibility ? + ['ol', { class: `${map}-ol`, style: `list-style: none; font-size: ${fsize}; font-family: ${ffam}; color:${color}; margin-left: ${node.attrs.indent}` }, 0] : + ['ol', { class: `${map}-ol`, style: `list-style: none;` }]; + } + }, + + bullet_list: { + ...bulletList, + content: 'list_item+', + group: 'block', + // parseDOM: [{ tag: "ul" }, { style: 'list-style-type=disc' }], + toDOM(node: Node) { + return ['ul', 0]; + } + }, + + list_item: { + attrs: { + bulletStyle: { default: 0 }, + mapStyle: { default: "decimal" }, + visibility: { default: true } + }, + ...listItem, + content: 'paragraph block*', + toDOM(node: any) { + const map = node.attrs.bulletStyle ? node.attrs.mapStyle + node.attrs.bulletStyle : ""; + return node.attrs.visibility ? ["li", { class: `${map}` }, 0] : ["li", { class: `${map}` }, "..."]; + //return ["li", { class: `${map}` }, 0]; + } + }, +}; \ No newline at end of file diff --git a/src/client/views/nodes/formattedText/prosemirrorPatches.js b/src/client/views/nodes/formattedText/prosemirrorPatches.js new file mode 100644 index 000000000..269423482 --- /dev/null +++ b/src/client/views/nodes/formattedText/prosemirrorPatches.js @@ -0,0 +1,139 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +var prosemirrorInputRules = require('prosemirror-inputrules'); +var prosemirrorTransform = require('prosemirror-transform'); +var prosemirrorModel = require('prosemirror-model'); + +exports.liftListItem = liftListItem; +exports.sinkListItem = sinkListItem; +exports.wrappingInputRule = wrappingInputRule; +// :: (NodeType) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool +// Create a command to lift the list item around the selection up into +// a wrapping list. +function liftListItem(itemType) { + return function (tx, dispatch) { + var ref = tx.selection; + var $from = ref.$from; + var $to = ref.$to; + var range = $from.blockRange($to, function (node) { return node.childCount && node.firstChild.type == itemType; }); + if (!range) { return false } + if (!dispatch) { return true } + if ($from.node(range.depth - 1).type == itemType) // Inside a parent list + { return liftToOuterList(tx, dispatch, itemType, range) } + else // Outer list node + { return liftOutOfList(tx, dispatch, range) } + } +} + +function liftToOuterList(tr, dispatch, itemType, range) { + var end = range.end, endOfList = range.$to.end(range.depth); + if (end < endOfList) { + // There are siblings after the lifted items, which must become + // children of the last item + tr.step(new prosemirrorTransform.ReplaceAroundStep(end - 1, endOfList, end, endOfList, + new prosemirrorModel.Slice(prosemirrorModel.Fragment.from(itemType.create(null, range.parent.copy())), 1, 0), 1, true)); + range = new prosemirrorModel.NodeRange(tr.doc.resolve(range.$from.pos), tr.doc.resolve(endOfList), range.depth); + } + dispatch(tr.lift(range, prosemirrorTransform.liftTarget(range)).scrollIntoView()); + return true +} + +function liftOutOfList(tr, dispatch, range) { + var list = range.parent; + // Merge the list items into a single big item + for (var pos = range.end, i = range.endIndex - 1, e = range.startIndex; i > e; i--) { + pos -= list.child(i).nodeSize; + tr.delete(pos - 1, pos + 1); + } + var $start = tr.doc.resolve(range.start), item = $start.nodeAfter; + var atStart = range.startIndex == 0, atEnd = range.endIndex == list.childCount; + var parent = $start.node(-1), indexBefore = $start.index(-1); + if (!parent.canReplace(indexBefore + (atStart ? 0 : 1), indexBefore + 1, + item.content.append(atEnd ? prosemirrorModel.Fragment.empty : prosemirrorModel.Fragment.from(list)))) { return false } + var start = $start.pos, end = start + item.nodeSize; + // Strip off the surrounding list. At the sides where we're not at + // the end of the list, the existing list is closed. At sides where + // this is the end, it is overwritten to its end. + tr.step(new prosemirrorTransform.ReplaceAroundStep(start - (atStart ? 1 : 0), end + (atEnd ? 1 : 0), start + 1, end - 1, + new prosemirrorModel.Slice((atStart ? prosemirrorModel.Fragment.empty : prosemirrorModel.Fragment.from(list.copy(prosemirrorModel.Fragment.empty))) + .append(atEnd ? prosemirrorModel.Fragment.empty : prosemirrorModel.Fragment.from(list.copy(prosemirrorModel.Fragment.empty))), + atStart ? 0 : 1, atEnd ? 0 : 1), atStart ? 0 : 1)); + dispatch(tr.scrollIntoView()); + return true +} + +// :: (NodeType) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool +// Create a command to sink the list item around the selection down +// into an inner list. +function sinkListItem(itemType) { + return function (state, dispatch) { + var ref = state.selection; + var $from = ref.$from; + var $to = ref.$to; + var range = $from.blockRange($to, function (node) { return node.childCount && node.firstChild.type == itemType; }); + if (!range) { return false } + var startIndex = range.startIndex; + if (startIndex == 0) { return false } + var parent = range.parent, nodeBefore = parent.child(startIndex - 1); + if (nodeBefore.type != itemType) { return false; } + + if (dispatch) { + var nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type == parent.type; + var inner = prosemirrorModel.Fragment.from(nestedBefore ? itemType.create() : null); + let slice = new prosemirrorModel.Slice(prosemirrorModel.Fragment.from(itemType.create(null, prosemirrorModel.Fragment.from(parent.type.create({ ...parent.attrs, fontSize: parent.attrs.fontSize ? parent.attrs.fontSize - 4 : undefined }, inner)))), + nestedBefore ? 3 : 1, 0); + var before = range.start, after = range.end; + dispatch(state.tr.step(new prosemirrorTransform.ReplaceAroundStep(before - (nestedBefore ? 3 : 1), after, + before, after, slice, 1, true)) + .scrollIntoView()); + } + return true + } +} + +function findWrappingOutside(range, type) { + var parent = range.parent; + var startIndex = range.startIndex; + var endIndex = range.endIndex; + var around = parent.contentMatchAt(startIndex).findWrapping(type); + if (!around) { return null } + var outer = around.length ? around[0] : type; + return parent.canReplaceWith(startIndex, endIndex, outer) ? around : null +} + +function findWrappingInside(range, type) { + var parent = range.parent; + var startIndex = range.startIndex; + var endIndex = range.endIndex; + var inner = parent.child(startIndex); + var inside = type.contentMatch.findWrapping(inner.type); + if (!inside) { return null } + var lastType = inside.length ? inside[inside.length - 1] : type; + var innerMatch = lastType.contentMatch; + for (var i = startIndex; innerMatch && i < endIndex; i++) { innerMatch = innerMatch.matchType(parent.child(i).type); } + if (!innerMatch || !innerMatch.validEnd) { return null } + return inside +} +function findWrapping(range, nodeType, attrs, innerRange, customWithAttrs = null) { + if (innerRange === void 0) innerRange = range; + let withAttrs = (type) => ({ type: type, attrs: null }); + var around = findWrappingOutside(range, nodeType); + var inner = around && findWrappingInside(innerRange, nodeType); + if (!inner) { return null } + return around.map(withAttrs).concat({ type: nodeType, attrs: attrs }).concat(inner.map(customWithAttrs ? customWithAttrs : withAttrs)) +} +function wrappingInputRule(regexp, nodeType, getAttrs, joinPredicate, customWithAttrs = null) { + return new prosemirrorInputRules.InputRule(regexp, function (state, match, start, end) { + var attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs; + var tr = state.tr.delete(start, end); + var $start = tr.doc.resolve(start), range = $start.blockRange(), wrapping = range && findWrapping(range, nodeType, attrs, undefined, customWithAttrs); + if (!wrapping) { return null } + tr.wrap(range, wrapping); + var before = tr.doc.resolve(start - 1).nodeBefore; + if (before && before.type == nodeType && prosemirrorTransform.canJoin(tr.doc, start - 1) && + (!joinPredicate || joinPredicate(match, before))) { tr.join(start - 1); } + return tr + }) +} \ No newline at end of file diff --git a/src/client/views/nodes/formattedText/schema_rts.ts b/src/client/views/nodes/formattedText/schema_rts.ts new file mode 100644 index 000000000..83561073c --- /dev/null +++ b/src/client/views/nodes/formattedText/schema_rts.ts @@ -0,0 +1,26 @@ +import { Schema, Slice } from "prosemirror-model"; + +import { nodes } from "./nodes_rts"; +import { marks } from "./marks_rts"; + + +// :: Schema +// This schema rougly corresponds to the document schema used by +// [CommonMark](http://commonmark.org/), minus the list elements, +// which are defined in the [`prosemirror-schema-list`](#schema-list) +// module. +// +// 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 }); + +const fromJson = schema.nodeFromJSON; + +schema.nodeFromJSON = (json: any) => { + const node = fromJson(json); + if (json.type === schema.nodes.summary.name) { + node.attrs.text = Slice.fromJSON(schema, node.attrs.textslice); + } + return node; +}; \ No newline at end of file diff --git a/src/mobile/MobileInterface.tsx b/src/mobile/MobileInterface.tsx index 73ebbb303..69a80e1b4 100644 --- a/src/mobile/MobileInterface.tsx +++ b/src/mobile/MobileInterface.tsx @@ -7,7 +7,7 @@ import { observer } from 'mobx-react'; import { DocServer } from '../client/DocServer'; import { Docs } from '../client/documents/Documents'; import { DocumentManager } from '../client/util/DocumentManager'; -import RichTextMenu from '../client/util/RichTextMenu'; +import RichTextMenu from '../client/views/nodes/formattedText/RichTextMenu'; import { Scripting } from '../client/util/Scripting'; import { Transform } from '../client/util/Transform'; import { CollectionView } from '../client/views/collections/CollectionView'; diff --git a/src/new_fields/RichTextUtils.ts b/src/new_fields/RichTextUtils.ts index c9960e783..c475d0d73 100644 --- a/src/new_fields/RichTextUtils.ts +++ b/src/new_fields/RichTextUtils.ts @@ -4,11 +4,11 @@ import { Fragment, Mark, Node } from "prosemirror-model"; import { sinkListItem } from "prosemirror-schema-list"; import { Utils } from "../Utils"; import { Docs } from "../client/documents/Documents"; -import { schema } from "../client/util/schema_rts"; +import { schema } from "../client/views/nodes/formattedText/schema_rts"; import { GooglePhotos } from "../client/apis/google_docs/GooglePhotosClientUtils"; import { DocServer } from "../client/DocServer"; import { Networking } from "../client/Network"; -import { FormattedTextBox } from "../client/views/nodes/FormattedTextBox"; +import { FormattedTextBox } from "../client/views/nodes/formattedText/FormattedTextBox"; import { Doc, Opt } from "./Doc"; import { Id } from "./FieldSymbols"; import { RichTextField } from "./RichTextField"; diff --git a/src/server/authentication/models/current_user_utils.ts b/src/server/authentication/models/current_user_utils.ts index 1d41c3570..08dc21460 100644 --- a/src/server/authentication/models/current_user_utils.ts +++ b/src/server/authentication/models/current_user_utils.ts @@ -17,7 +17,7 @@ import { CollectionViewType } from "../../../client/views/collections/Collection import { makeTemplate } from "../../../client/util/DropConverter"; import { RichTextField } from "../../../new_fields/RichTextField"; import { PrefetchProxy } from "../../../new_fields/Proxy"; -import { FormattedTextBox } from "../../../client/views/nodes/FormattedTextBox"; +import { FormattedTextBox } from "../../../client/views/nodes/formattedText/FormattedTextBox"; import { MainView } from "../../../client/views/MainView"; import { DocumentType } from "../../../client/documents/DocumentTypes"; import { SchemaHeaderField } from "../../../new_fields/SchemaHeaderField"; -- cgit v1.2.3-70-g09d2