diff options
Diffstat (limited to 'src/client/views')
58 files changed, 2151 insertions, 759 deletions
diff --git a/src/client/views/DocComponent.tsx b/src/client/views/DocComponent.tsx index 32c351bf5..b9772fd57 100644 --- a/src/client/views/DocComponent.tsx +++ b/src/client/views/DocComponent.tsx @@ -44,7 +44,7 @@ interface ViewBoxBaseProps { fieldKey: string; layerProvider?: (doc: Doc, assign?: boolean) => boolean; isSelected: (outsideReaction?: boolean) => boolean; - isContentActive: () => boolean; + isContentActive: () => boolean | undefined; renderDepth: number; rootSelected: (outsideReaction?: boolean) => boolean; } @@ -65,10 +65,12 @@ export function ViewBoxBaseComponent<P extends ViewBoxBaseProps, T>(schemaCtor: lookupField = (field: string) => ScriptCast(this.layoutDoc.lookupField)?.script.run({ self: this.layoutDoc, data: this.rootDoc, field: field, container: this.props.ContainingCollectionDoc }).result; - isContentActive = (outsideReaction?: boolean) => (CurrentUserUtils.SelectedTool !== InkTool.None || - (this.props.isContentActive?.() || this.props.Document.forceActive || - this.props.isSelected(outsideReaction) || - this.props.rootSelected(outsideReaction)) ? true : false) + isContentActive = (outsideReaction?: boolean) => ( + this.props.isContentActive?.() === false ? false : + (CurrentUserUtils.SelectedTool !== InkTool.None || + (this.props.isContentActive?.() || this.props.Document.forceActive || + this.props.isSelected(outsideReaction) || + this.props.rootSelected(outsideReaction)) ? true : undefined)) protected _multiTouchDisposer?: InteractionUtils.MultiTouchEventDisposer; } return Component; @@ -82,7 +84,7 @@ export interface ViewBoxAnnotatableProps { fieldKey: string; filterAddDocument?: (doc: Doc[]) => boolean; // allows a document that renders a Collection view to filter or modify any documents added to the collection (see PresBox for an example) layerProvider?: (doc: Doc, assign?: boolean) => boolean; - isContentActive: () => boolean; + isContentActive: () => boolean | undefined; select: (isCtrlPressed: boolean) => void; whenChildContentsActiveChanged: (isActive: boolean) => void; isSelected: (outsideReaction?: boolean) => boolean; @@ -165,13 +167,13 @@ export function ViewBoxAnnotatableComponent<P extends ViewBoxAnnotatableProps, T // 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 | Doc[], targetCollection: Doc | undefined, addDocument: (doc: Doc | Doc[]) => boolean, annotationKey?: string): boolean => { + moveDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (doc: Doc | Doc[], annotationKey?: string) => boolean, annotationKey?: string): boolean => { if (Doc.AreProtosEqual(this.props.Document, targetCollection)) { return true; } const first = doc instanceof Doc ? doc : doc[0]; if (!first?._stayInCollection && addDocument !== returnFalse) { - return UndoManager.RunInTempBatch(() => this.removeDocument(doc, annotationKey, true) && addDocument(doc)); + return UndoManager.RunInTempBatch(() => this.removeDocument(doc, annotationKey, true) && addDocument(doc, annotationKey)); } return false; } diff --git a/src/client/views/DocumentDecorations.scss b/src/client/views/DocumentDecorations.scss index d8ad47ecb..82dca1287 100644 --- a/src/client/views/DocumentDecorations.scss +++ b/src/client/views/DocumentDecorations.scss @@ -240,8 +240,10 @@ $linkGap: 3px; text-align: center; display: flex; margin-left: 5px; - height: 22px; + height: 20px; position: absolute; + border-radius: 8px; + background: rgba(159,159,159,0.1); .documentDecorations-titleSpan, .documentDecorations-titleSpan-Dark { @@ -288,7 +290,7 @@ $linkGap: 3px; text-align: center; display: flex; margin-left: 5px; - height: 22px; + height: 20px; position: absolute; .documentDecorations-titleSpan { width: 100%; diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 522995479..d85709f31 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -27,6 +27,8 @@ import { LightboxView } from './LightboxView'; import { DocumentView } from "./nodes/DocumentView"; import { FormattedTextBox } from './nodes/formattedText/FormattedTextBox'; import React = require("react"); +import { InkingStroke } from './InkingStroke'; +import e = require('express'); @observer export class DocumentDecorations extends React.Component<{ PanelWidth: number, PanelHeight: number, boundsLeft: number, boundsTop: number }, { value: string }> { @@ -37,12 +39,9 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number, P private _linkBoxHeight = 20 + 3; // link button height + margin private _titleHeight = 20; private _resizeUndo?: UndoManager.Batch; - private _rotateUndo?: UndoManager.Batch; private _offX = 0; _offY = 0; // offset from click pt to inner edge of resize border private _snapX = 0; _snapY = 0; // last snapped location of resize border - private _prevY = 0; private _dragHeights = new Map<Doc, { start: number, lowest: number }>(); - private _inkCenterPts: { doc: Doc, X: number, Y: number }[] = []; private _inkDragDocs: { doc: Doc, x: number, y: number, width: number, height: number }[] = []; @observable private _accumulatedTitle = ""; @@ -194,30 +193,22 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number, P @action onRotateDown = (e: React.PointerEvent): void => { - this._rotateUndo = UndoManager.StartBatch("rotatedown"); - const pt = { x: this.Bounds.c?.X ?? (this.Bounds.x + this.Bounds.r) / 2, y: this.Bounds.c?.Y ?? (this.Bounds.y + this.Bounds.b) / 2 }; + const rotateUndo = UndoManager.StartBatch("rotatedown"); + const centerPoint = { X: this.Bounds.c?.X ?? (this.Bounds.x + this.Bounds.r) / 2, Y: this.Bounds.c?.Y ?? (this.Bounds.y + this.Bounds.b) / 2 }; setupMoveUpEvents(this, e, (e: PointerEvent, down: number[], delta: number[]) => { - const docView = SelectionManager.Views()[0]; - const { left, top, right, bottom } = docView.getBounds() || { left: 0, top: 0, right: 0, bottom: 0 }; - const centerPoint = { X: (left + right) / 2, Y: (top + bottom) / 2 }; const previousPoint = { X: e.clientX, Y: e.clientY }; const movedPoint = { X: e.clientX - delta[0], Y: e.clientY - delta[1] }; - const angle = InkStrokeProperties.Instance?.angleChange(previousPoint, movedPoint, centerPoint); - const selectedInk = SelectionManager.Views().filter(i => Document(i.rootDoc).type === DocumentType.INK); - angle && InkStrokeProperties.Instance?.rotateInk(selectedInk, -angle, pt); + const angle = InkStrokeProperties.angleChange(previousPoint, movedPoint, centerPoint); + const selectedInk = SelectionManager.Views().filter(i => i.ComponentView instanceof InkingStroke); + angle && InkStrokeProperties.Instance.rotateInk(selectedInk, -angle, centerPoint); return false; }, () => { - this._rotateUndo?.end(); + rotateUndo?.end(); UndoManager.FilterBatches(["data", "x", "y", "width", "height"]); }, emptyFunction); - this._prevY = e.clientY; - this._inkCenterPts = SelectionManager.Views() - .filter(dv => dv.rootDoc.type === DocumentType.INK) - .map(dv => ({ ink: Cast(dv.rootDoc.data, InkField)?.inkData ?? [{ X: 0, Y: 0 }], doc: dv.rootDoc })) - .map(({ ink, doc }) => ({ doc, X: Math.min(...ink.map(p => p.X)), Y: Math.min(...ink.map(p => p.Y)) })); } @action @@ -226,7 +217,7 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number, P this._inkDragDocs = DragManager.docsBeingDragged .filter(doc => doc.type === DocumentType.INK) .map(doc => { - if (InkStrokeProperties.Instance?._lock) { + if (InkStrokeProperties.Instance._lock) { Doc.SetNativeHeight(doc, NumCast(doc._height)); Doc.SetNativeWidth(doc, NumCast(doc._width)); } @@ -249,7 +240,7 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number, P const first = SelectionManager.Views()[0]; let thisPt = { x: e.clientX - this._offX, y: e.clientY - this._offY }; var fixedAspect = Doc.NativeAspect(first.layoutDoc); - InkStrokeProperties.Instance?._lock && SelectionManager.Views().filter(dv => dv.rootDoc.type === DocumentType.INK) + InkStrokeProperties.Instance._lock && SelectionManager.Views().filter(dv => dv.rootDoc.type === DocumentType.INK) .forEach(dv => fixedAspect = Doc.NativeAspect(dv.rootDoc)); const resizeHdl = this._resizeHdlId.split(" ")[0]; @@ -454,17 +445,18 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number, P </Tooltip>); const colorScheme = StrCast(CurrentUserUtils.ActiveDashboard?.colorScheme); - const titleArea = this._edtingTitle ? - <input ref={this._keyinput} className={`documentDecorations-title${colorScheme}`} - style={{ width: `calc(100% - ${seldoc?.props.hideResizeHandles ? 0 : 20}px` }} - type="text" name="dynbox" autoComplete="on" - value={this._accumulatedTitle} - onBlur={e => this.titleBlur()} - onChange={action(e => this._accumulatedTitle = e.target.value)} - onKeyPress={this.titleEntered} /> : - <div className="documentDecorations-title" style={{ width: `calc(100% - ${seldoc?.props.hideResizeHandles ? 0 : 20}px` }} key="title" onPointerDown={this.onTitleDown} > - <span className={`documentDecorations-titleSpan${colorScheme}`}>{`${this.selectionTitle}`}</span> - </div>; + const titleArea = hideTitle ? <div className="documentDecorations-title" onPointerDown={this.onTitleDown} style={{ width: "100%" }} key="title" /> : + this._edtingTitle ? + <input ref={this._keyinput} className={`documentDecorations-title${colorScheme}`} + style={{ width: `calc(100% - ${hideResizers ? 0 : 20}px` }} + type="text" name="dynbox" autoComplete="on" + value={this._accumulatedTitle} + onBlur={e => this.titleBlur()} + onChange={action(e => this._accumulatedTitle = e.target.value)} + onKeyPress={this.titleEntered} /> : + <div className="documentDecorations-title" style={{ width: `calc(100% - ${hideResizers ? 0 : 20}px` }} key="title" onPointerDown={this.onTitleDown} > + <span className={`documentDecorations-titleSpan${colorScheme}`}>{`${this.selectionTitle}`}</span> + </div>; let inMainMenuPanel = false; for (let node = seldoc.ContentDiv; node && !inMainMenuPanel; node = node?.parentNode as any) { @@ -478,7 +470,7 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number, P bounds.r = Math.max(bounds.x, Math.max(leftBounds, Math.min(window.innerWidth, bounds.r + borderRadiusDraggerWidth + this._resizeBorderWidth / 2) - this._resizeBorderWidth / 2 - borderRadiusDraggerWidth)); bounds.b = Math.max(bounds.y, Math.max(topBounds, Math.min(window.innerHeight, bounds.b + this._resizeBorderWidth / 2 + this._linkBoxHeight) - this._resizeBorderWidth / 2 - this._linkBoxHeight)); - const useRotation = seldoc.rootDoc.type === DocumentType.INK; + const useRotation = seldoc.ComponentView instanceof InkingStroke; const resizerScheme = colorScheme ? "documentDecorations-resizer" + colorScheme : ""; return (<div className={`documentDecorations${colorScheme}`}> @@ -498,8 +490,8 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number, P top: bounds.y - this._resizeBorderWidth / 2 - this._titleHeight, }}> {!canDelete ? <div /> : topBtn("close", "times", undefined, this.onCloseClick, "Close")} - {hideTitle ? (null) : titleArea}{!canOpen ? (null) : topBtn("open", "external-link-alt", this.onMaximizeDown, undefined, "Open in Tab (ctrl: as alias, shift: in new collection)")} - + {titleArea} + {!canOpen ? (null) : topBtn("open", "external-link-alt", this.onMaximizeDown, undefined, "Open in Tab (ctrl: as alias, shift: in new collection)")} {hideResizers ? (null) : <> {SelectionManager.Views().length !== 1 || hideTitle ? (null) : @@ -517,7 +509,8 @@ export class DocumentDecorations extends React.Component<{ PanelWidth: number, P {seldoc.props.renderDepth <= 1 || !seldoc.props.ContainingCollectionView ? (null) : topBtn("selector", "arrow-alt-circle-up", undefined, this.onSelectorClick, "tap to select containing document")} <div key="rot" className={`documentDecorations-${useRotation ? "rotation" : "borderRadius"}`} - onPointerDown={useRotation ? this.onRotateDown : this.onRadiusDown} onContextMenu={(e) => e.preventDefault()}>{useRotation && "⟲"}</div> + onPointerDown={useRotation ? this.onRotateDown : this.onRadiusDown} + onContextMenu={e => e.preventDefault()}>{useRotation && "⟲"}</div> </> } </div > diff --git a/src/client/views/GestureOverlay.tsx b/src/client/views/GestureOverlay.tsx index f28485e43..04abdbf37 100644 --- a/src/client/views/GestureOverlay.tsx +++ b/src/client/views/GestureOverlay.tsx @@ -18,7 +18,7 @@ import { SelectionManager } from "../util/SelectionManager"; import { Transform } from "../util/Transform"; import { CollectionFreeFormViewChrome } from "./collections/CollectionMenu"; import "./GestureOverlay.scss"; -import { ActiveArrowEnd, ActiveArrowStart, ActiveDash, ActiveFillColor, ActiveInkBezierApprox, ActiveInkColor, ActiveInkWidth, SetActiveArrowStart, SetActiveDash, SetActiveFillColor, SetActiveInkColor, SetActiveInkWidth } from "./InkingStroke"; +import { ActiveArrowEnd, ActiveArrowStart, ActiveArrowScale, ActiveDash, ActiveFillColor, ActiveInkBezierApprox, ActiveInkColor, ActiveInkWidth, SetActiveArrowStart, SetActiveDash, SetActiveFillColor, SetActiveInkColor, SetActiveInkWidth } from "./InkingStroke"; import { DocumentView } from "./nodes/DocumentView"; import { RadialMenu } from "./nodes/RadialMenu"; import HorizontalPalette from "./Palette"; @@ -850,14 +850,14 @@ export class GestureOverlay extends Touchable { const b = { left: -20000, right: 20000, top: -20000, bottom: 20000, width: 40000, height: 40000 };//this.getBounds(l, true); return <svg key={i} width={b.width} height={b.height} style={{ transform: `translate(${b.left}px, ${b.top}px)`, pointerEvents: "none", position: "absolute", zIndex: 30000, overflow: "visible" }}> {InteractionUtils.CreatePolyline(l, b.left, b.top, strokeColor, width, width, "miter", "round", - ActiveInkBezierApprox(), "none" /*ActiveFillColor()*/, ActiveArrowStart(), ActiveArrowEnd(), + ActiveInkBezierApprox(), "none" /*ActiveFillColor()*/, ActiveArrowStart(), ActiveArrowEnd(), ActiveArrowScale(), ActiveDash(), 1, 1, this.InkShape, "none", 1.0, false)} </svg>; }), this._points.length <= 1 ? (null) : <svg key="svg" width={B.width} height={B.height} style={{ transform: `translate(${B.left}px, ${B.top}px)`, pointerEvents: "none", position: "absolute", zIndex: 30000, overflow: "visible" }}> {InteractionUtils.CreatePolyline(this._points.map(p => ({ X: p.X, Y: p.Y - (rect?.y || 0) })), B.left, B.top, ActiveInkColor(), width, width, "miter", "round", "", - "none" /*ActiveFillColor()*/, ActiveArrowStart(), ActiveArrowEnd(), ActiveDash(), 1, 1, this.InkShape, "none", 1.0, false)} + "none" /*ActiveFillColor()*/, ActiveArrowStart(), ActiveArrowEnd(), ActiveArrowScale(), ActiveDash(), 1, 1, this.InkShape, "none", 1.0, false)} </svg>] ]; } diff --git a/src/client/views/GlobalKeyHandler.ts b/src/client/views/GlobalKeyHandler.ts index 364bf05e2..1a4080d81 100644 --- a/src/client/views/GlobalKeyHandler.ts +++ b/src/client/views/GlobalKeyHandler.ts @@ -112,7 +112,7 @@ export class KeyManager { case "escape": DocumentLinksButton.StartLink = undefined; DocumentLinksButton.StartLinkView = undefined; - InkStrokeProperties.Instance && (InkStrokeProperties.Instance._controlButton = false); + InkStrokeProperties.Instance._controlButton = false; CurrentUserUtils.SelectedTool = InkTool.None; var doDeselect = true; if (SnappingManager.GetIsDragging()) { @@ -230,6 +230,10 @@ export class KeyManager { } } break; + case "e": CurrentUserUtils.SelectedTool = InkTool.Eraser; + break; + case "p": CurrentUserUtils.SelectedTool = InkTool.Pen; + break; case "o": const target = SelectionManager.Docs().lastElement(); target && CollectionDockingView.OpenFullScreen(target); diff --git a/src/client/views/InkControlPtHandles.tsx b/src/client/views/InkControlPtHandles.tsx index f24dab949..24f796105 100644 --- a/src/client/views/InkControlPtHandles.tsx +++ b/src/client/views/InkControlPtHandles.tsx @@ -6,13 +6,14 @@ import { ControlPoint, InkData, PointData, InkField } from "../../fields/InkFiel import { List } from "../../fields/List"; import { listSpec } from "../../fields/Schema"; import { Cast, NumCast } from "../../fields/Types"; -import { setupMoveUpEvents } from "../../Utils"; +import { setupMoveUpEvents, returnFalse } from "../../Utils"; import { Transform } from "../util/Transform"; import { UndoManager } from "../util/UndoManager"; import { Colors } from "./global/globalEnums"; import { InkingStroke } from "./InkingStroke"; import { InkStrokeProperties } from "./InkStrokeProperties"; import { DocumentView } from "./nodes/DocumentView"; +import { SelectionManager } from "../util/SelectionManager"; export interface InkControlProps { inkDoc: Doc; @@ -44,23 +45,24 @@ export class InkControlPtHandles extends React.Component<InkControlProps> { @action onControlDown = (e: React.PointerEvent, controlIndex: number): void => { const ptFromScreen = this.props.inkView.ComponentView?.ptFromScreen; - if (InkStrokeProperties.Instance && ptFromScreen) { + if (ptFromScreen) { const order = controlIndex % 4; const handleIndexA = ((order === 3 ? controlIndex - 1 : controlIndex - 2) + this.props.inkCtrlPoints.length) % this.props.inkCtrlPoints.length; const handleIndexB = (order === 3 ? controlIndex + 2 : controlIndex + 1) % this.props.inkCtrlPoints.length; const brokenIndices = Cast(this.props.inkDoc.brokenInkIndices, listSpec("number")); - const wasSelected = InkStrokeProperties.Instance?._currentPoint === controlIndex; + const wasSelected = InkStrokeProperties.Instance._currentPoint === controlIndex; + const origInk = this.props.inkCtrlPoints.slice(); setupMoveUpEvents(this, e, action((e: PointerEvent, down: number[], delta: number[]) => { if (!this.controlUndo) this.controlUndo = UndoManager.StartBatch("drag ink ctrl pt"); const inkMoveEnd = ptFromScreen({ X: delta[0], Y: delta[1] }); const inkMoveStart = ptFromScreen({ X: 0, Y: 0 }); - InkStrokeProperties.Instance?.moveControlPtHandle(this.props.inkView, inkMoveEnd.X - inkMoveStart.X, inkMoveEnd.Y - inkMoveStart.Y, controlIndex); + InkStrokeProperties.Instance.moveControlPtHandle(this.props.inkView, inkMoveEnd.X - inkMoveStart.X, inkMoveEnd.Y - inkMoveStart.Y, controlIndex, origInk); return false; }), action(() => { if (this.controlUndo) { - InkStrokeProperties.Instance?.snapControl(this.props.inkView, controlIndex); + InkStrokeProperties.Instance.snapControl(this.props.inkView, controlIndex); } this.controlUndo?.end(); this.controlUndo = undefined; @@ -75,11 +77,11 @@ export class InkControlPtHandles extends React.Component<InkControlProps> { } else { if (brokenIndices?.includes(equivIndex)) { if (!this.controlUndo) this.controlUndo = UndoManager.StartBatch("make smooth"); - InkStrokeProperties.Instance?.snapHandleTangent(this.props.inkView, equivIndex, handleIndexA, handleIndexB); + InkStrokeProperties.Instance.snapHandleTangent(this.props.inkView, equivIndex, handleIndexA, handleIndexB); } if (equivIndex !== controlIndex && brokenIndices?.includes(controlIndex)) { if (!this.controlUndo) this.controlUndo = UndoManager.StartBatch("make smooth"); - InkStrokeProperties.Instance?.snapHandleTangent(this.props.inkView, controlIndex, handleIndexA, handleIndexB); + InkStrokeProperties.Instance.snapHandleTangent(this.props.inkView, controlIndex, handleIndexA, handleIndexB); } } this.controlUndo?.end(); @@ -102,7 +104,7 @@ export class InkControlPtHandles extends React.Component<InkControlProps> { @action onDelete = (e: KeyboardEvent) => { if (["-", "Backspace", "Delete"].includes(e.key)) { - InkStrokeProperties.Instance?.deletePoints(this.props.inkView); + InkStrokeProperties.Instance.deletePoints(this.props.inkView, e.shiftKey); e.stopPropagation(); } } @@ -111,11 +113,7 @@ export class InkControlPtHandles extends React.Component<InkControlProps> { * Changes the current selected control point. */ @action - changeCurrPoint = (i: number) => { - if (InkStrokeProperties.Instance) { - InkStrokeProperties.Instance._currentPoint = i; - } - } + changeCurrPoint = (i: number) => InkStrokeProperties.Instance._currentPoint = i render() { // Accessing the current ink's data and extracting all control points. @@ -133,7 +131,6 @@ export class InkControlPtHandles extends React.Component<InkControlProps> { inkCtrlPts.push({ ...inkData[i + 3], I: i + 3 }); } - const screenSpaceLineWidth = this.props.screenSpaceLineWidth; const closed = InkingStroke.IsClosed(inkData); const nearestScreenPt = this.props.nearestScreenPt(); const TagType = (broken?: boolean) => broken ? "rect" : "circle"; @@ -141,18 +138,18 @@ export class InkControlPtHandles extends React.Component<InkControlProps> { const broken = Cast(this.props.inkDoc.brokenInkIndices, listSpec("number"))?.includes(control.I); const Tag = TagType((control.I === 0 || control.I === inkData.length - 1) && !closed) as keyof JSX.IntrinsicElements; return <Tag key={control.I.toString() + scale} - x={control.X - screenSpaceLineWidth * 2 * scale} - y={control.Y - screenSpaceLineWidth * 2 * scale} + x={control.X - this.props.screenSpaceLineWidth * 2 * scale} + y={control.Y - this.props.screenSpaceLineWidth * 2 * scale} cx={control.X} cy={control.Y} - r={screenSpaceLineWidth * 2 * scale} + r={this.props.screenSpaceLineWidth * 2 * scale} opacity={this.controlUndo ? 0.15 : 1} - height={screenSpaceLineWidth * 4 * scale} - width={screenSpaceLineWidth * 4 * scale} - strokeWidth={screenSpaceLineWidth / 2} + height={this.props.screenSpaceLineWidth * 4 * scale} + width={this.props.screenSpaceLineWidth * 4 * scale} + strokeWidth={this.props.screenSpaceLineWidth / 2} stroke={Colors.MEDIUM_BLUE} fill={broken ? Colors.MEDIUM_BLUE : color} - onPointerDown={(e: any) => this.onControlDown(e, control.I)} + onPointerDown={(e: React.PointerEvent) => this.onControlDown(e, control.I)} onMouseEnter={() => this.onEnterControl(control.I)} onMouseLeave={this.onLeaveControl} pointerEvents="all" @@ -164,7 +161,7 @@ export class InkControlPtHandles extends React.Component<InkControlProps> { <circle key={"npt"} cx={nearestScreenPt.X} cy={nearestScreenPt.Y} - r={screenSpaceLineWidth * 2} + r={this.props.screenSpaceLineWidth * 2} fill={"#00007777"} stroke={"#00007777"} strokeWidth={0} @@ -175,4 +172,62 @@ export class InkControlPtHandles extends React.Component<InkControlProps> { </svg> ); } +} + + +export interface InkEndProps { + inkDoc: Doc; + inkView: DocumentView; + screenSpaceLineWidth: number; + startPt: PointData; + endPt: PointData; +} +@observer +export class InkEndPtHandles extends React.Component<InkEndProps> { + @observable controlUndo: UndoManager.Batch | undefined; + @observable _overStart: boolean = false; + @observable _overEnd: boolean = false; + + @action + dragRotate = (e: React.PointerEvent, p1: () => { X: number, Y: number }, p2: () => { X: number, Y: number }) => { + setupMoveUpEvents(this, e, (e) => { + if (!this.controlUndo) this.controlUndo = UndoManager.StartBatch("stretch ink"); + // compute stretch factor by finding scaling along axis between start and end points + const v1 = { X: p1().X - p2().X, Y: p1().Y - p2().Y }; + const v2 = { X: e.clientX - p2().X, Y: e.clientY - p2().Y }; + const v1len = Math.sqrt(v1.X * v1.X + v1.Y * v1.Y); + const v2len = Math.sqrt(v2.X * v2.X + v2.Y * v2.Y); + const scaling = v2len / v1len; + const v1n = { X: v1.X / v1len, Y: v1.Y / v1len }; + const v2n = { X: v2.X / v2len, Y: v2.Y / v2len }; + const angle = Math.acos(v1n.X * v2n.X + v1n.Y * v2n.Y) * Math.sign(v1.X * v2.Y - v2.X * v1.Y); + InkStrokeProperties.Instance.stretchInk(SelectionManager.Views(), scaling, p2(), v1n, e.shiftKey); + InkStrokeProperties.Instance.rotateInk(SelectionManager.Views(), angle, p2()); + return false; + }, action(() => { + this.controlUndo?.end(); + this.controlUndo = undefined; + UndoManager.FilterBatches(["data", "x", "y", "width", "height"]); + }), returnFalse); + } + + render() { + const hdl = (key: string, pt: PointData, dragFunc: (e: React.PointerEvent) => void) => <circle key={key} + cx={pt.X} + cy={pt.Y} + r={this.props.screenSpaceLineWidth * 2} + fill={this._overStart ? "#aaaaaa" : "#99999977"} + stroke={"#00007777"} + strokeWidth={0} + onPointerLeave={action(() => this._overStart = false)} + onPointerEnter={action(() => this._overStart = true)} + onPointerDown={dragFunc} + pointerEvents="all" + />; + return (<svg> + {hdl("start", this.props.startPt, (e: React.PointerEvent) => this.dragRotate(e, () => this.props.startPt, () => this.props.endPt))} + {hdl("end", this.props.endPt, (e: React.PointerEvent) => this.dragRotate(e, () => this.props.endPt, () => this.props.startPt))} + </svg> + ); + } }
\ No newline at end of file diff --git a/src/client/views/InkStroke.scss b/src/client/views/InkStroke.scss index 55e06c6ca..664f2448b 100644 --- a/src/client/views/InkStroke.scss +++ b/src/client/views/InkStroke.scss @@ -13,16 +13,28 @@ } } -.inkStroke { - mix-blend-mode: multiply; - stroke-linejoin: round; - stroke-linecap: round; - overflow: visible !important; - transform-origin: top left; - width: 100%; - height: 100%; +.inkStroke-wrapper { + display: flex; + align-items: center; + height: 100%; + .inkStroke { + mix-blend-mode: multiply; + stroke-linejoin: round; + stroke-linecap: round; + overflow: visible !important; + transform-origin: top left; + width: 100%; + height: 100%; + pointer-events: none; + svg:not(:root) { + overflow: visible !important; + } + } - svg:not(:root) { - overflow: visible !important; - } + .inkStroke-text { + position: absolute; + &:hover { + background: #9f9f9f0a; + } + } } diff --git a/src/client/views/InkStrokeProperties.ts b/src/client/views/InkStrokeProperties.ts index 6687b2bc7..cab4e1216 100644 --- a/src/client/views/InkStrokeProperties.ts +++ b/src/client/views/InkStrokeProperties.ts @@ -1,26 +1,30 @@ import { Bezier } from "bezier-js"; +import { Normalize, Distance } from "../util/bezierFit"; import { action, observable, reaction } from "mobx"; -import { Doc, Opt, DocListCast } from "../../fields/Doc"; +import { Doc, NumListCast, Opt } from "../../fields/Doc"; import { InkData, InkField, InkTool, PointData } from "../../fields/InkField"; import { List } from "../../fields/List"; import { listSpec } from "../../fields/Schema"; import { Cast, NumCast } from "../../fields/Types"; +import { Point } from "../../pen-gestures/ndollar"; import { DocumentType } from "../documents/DocumentTypes"; +import { FitOneCurve } from "../util/bezierFit"; import { CurrentUserUtils } from "../util/CurrentUserUtils"; +import { DocumentManager } from "../util/DocumentManager"; import { undoBatch } from "../util/UndoManager"; import { InkingStroke } from "./InkingStroke"; import { DocumentView } from "./nodes/DocumentView"; -import { DocumentManager } from "../util/DocumentManager"; export class InkStrokeProperties { - static Instance: InkStrokeProperties | undefined; + static _Instance: InkStrokeProperties | undefined; + public static get Instance() { return this._Instance || new InkStrokeProperties(); } @observable _lock = false; @observable _controlButton = false; @observable _currentPoint = -1; constructor() { - InkStrokeProperties.Instance = this; + InkStrokeProperties._Instance = this; reaction(() => this._controlButton, button => button && (CurrentUserUtils.SelectedTool = InkTool.None)); reaction(() => CurrentUserUtils.SelectedTool, tool => (tool !== InkTool.None) && (this._controlButton = false)); } @@ -139,18 +143,35 @@ export class InkStrokeProperties { */ @undoBatch @action - deletePoints = (inkView: DocumentView) => this.applyFunction(inkView, (view: DocumentView, ink: InkData) => { + deletePoints = (inkView: DocumentView, preserve: boolean) => this.applyFunction(inkView, (view: DocumentView, ink: InkData) => { const doc = view.rootDoc; - const newPoints: { X: number, Y: number }[] = []; - const toRemove = Math.floor((this._currentPoint + 2) / 4); - const last = this._currentPoint === ink.length - 1; - for (let i = 0; i < ink.length; i++) { - if (Math.floor((i + 2) / 4) !== toRemove && (toRemove !== 0 || i > 3)) { - newPoints.push({ X: ink[i].X, Y: ink[i].Y }); + const newPoints = ink.slice(); + const brokenIndices = NumListCast(doc.brokenInkIndices); + if (preserve || this._currentPoint === 0 || this._currentPoint === ink.length - 1 || brokenIndices.includes(this._currentPoint)) { + newPoints.splice(this._currentPoint === 0 ? 0 : this._currentPoint === ink.length - 1 ? this._currentPoint - 3 : this._currentPoint - 2, 4); + } else { + const start = this._currentPoint === 0 ? 0 : this._currentPoint - 4; + const splicedPoints = ink.slice(start, start + (this._currentPoint === 0 || this._currentPoint === ink.length - 1 ? 4 : 8)); + const samples: Point[] = []; + var startDir = { x: 0, y: 0 }; + var endDir = { x: 0, y: 0 }; + for (var i = 0; i < splicedPoints.length / 4; i++) { + const bez = new Bezier(splicedPoints.slice(i * 4, i * 4 + 4).map(p => ({ x: p.X, y: p.Y }))); + if (i === 0) startDir = bez.derivative(0); + if (i === splicedPoints.length / 4 - 1) endDir = bez.derivative(1); + for (var t = 0; t < (i === splicedPoints.length / 4 - 1 ? 1 + 1e-7 : 1); t += 0.05) { + const pt = bez.compute(t); + samples.push(new Point(pt.x, pt.y)); + } + } + const { finalCtrls, error } = FitOneCurve(samples, { X: startDir.x, Y: startDir.y }, { X: endDir.x, Y: endDir.y }); + if (error < 100) { + newPoints.splice(this._currentPoint - 4, 8, ...finalCtrls); + } else { + newPoints.splice(this._currentPoint - 2, 4); } } - doc.brokenInkIndices = new List(Cast(doc.brokenInkIndices, listSpec("number"), []).map(control => control >= toRemove * 4 ? control - 4 : control)); - if (last) newPoints.splice(newPoints.length - 3, 2); + doc.brokenInkIndices = new List(brokenIndices.map(control => control >= this._currentPoint ? control - 4 : control)); this._currentPoint = -1; return newPoints.length < 4 ? undefined : newPoints; }, true) @@ -163,10 +184,10 @@ export class InkStrokeProperties { */ @undoBatch @action - rotateInk = (inkStrokes: DocumentView[], angle: number, scrpt: { x: number, y: number }) => { + rotateInk = (inkStrokes: DocumentView[], angle: number, scrpt: PointData) => { this.applyFunction(inkStrokes, (view: DocumentView, ink: InkData, xScale: number, yScale: number, inkStrokeWidth: number) => { view.rootDoc.rotation = NumCast(view.rootDoc.rotation) + angle; - const inkCenterPt = view.ComponentView?.ptFromScreen?.({ X: scrpt.x, Y: scrpt.y }); + const inkCenterPt = view.ComponentView?.ptFromScreen?.(scrpt); return !inkCenterPt ? ink : ink.map(i => { const pt = { X: i.X - inkCenterPt.X, Y: i.Y - inkCenterPt.Y }; @@ -178,19 +199,84 @@ export class InkStrokeProperties { } /** + * Rotates ink stroke(s) about a point + * @param inkStrokes set of ink documentViews to rotate + * @param angle The angle at which to rotate the ink in radians. + * @param scrpt The center point of the rotation in screen coordinates + */ + @undoBatch + @action + stretchInk = (inkStrokes: DocumentView[], scaling: number, scrpt: PointData, scrVec: PointData, scaleUniformly: boolean) => { + this.applyFunction(inkStrokes, (view: DocumentView, ink: InkData, xScale: number, yScale: number, inkStrokeWidth: number) => { + const ptFromScreen = view.ComponentView?.ptFromScreen; + const ptToScreen = view.ComponentView?.ptToScreen; + return !ptToScreen || !ptFromScreen ? ink : + ink.map(ptToScreen).map(i => { + const pvec = { X: i.X - scrpt.X, Y: i.Y - scrpt.Y }; + const svec = pvec.X * scrVec.X * scaling + pvec.Y * scrVec.Y * scaling; + const ovec = -pvec.X * scrVec.Y * (scaleUniformly ? scaling : 1) + pvec.Y * scrVec.X * (scaleUniformly ? scaling : 1); + const newscrpt = { X: scrpt.X + svec * scrVec.X - ovec * scrVec.Y, Y: scrpt.Y + svec * scrVec.Y + ovec * scrVec.X }; + return ptFromScreen(newscrpt); + }); + }); + } + + /** * Handles the movement/scaling of a control point. */ @undoBatch @action - moveControlPtHandle = (inkView: DocumentView, deltaX: number, deltaY: number, controlIndex: number) => + moveControlPtHandle = (inkView: DocumentView, deltaX: number, deltaY: number, controlIndex: number, origInk?: InkData) => this.applyFunction(inkView, (view: DocumentView, ink: InkData, xScale: number, yScale: number) => { const order = controlIndex % 4; const closed = InkingStroke.IsClosed(ink); + if (origInk && this._currentPoint > 0 && this._currentPoint < ink.length - 1) { + const cpt_before = ink[controlIndex]; + const cpt = { X: cpt_before.X + deltaX, Y: cpt_before.Y + deltaY }; + if (true) { + const newink = origInk.slice(); + const start = this._currentPoint === 0 ? 0 : this._currentPoint - 4; + const splicedPoints = origInk.slice(start, start + (this._currentPoint === 0 || this._currentPoint === ink.length - 1 ? 4 : 8)); + const { nearestT, nearestSeg } = InkStrokeProperties.nearestPtToStroke(splicedPoints, cpt); + const samplesLeft: Point[] = []; + const samplesRight: Point[] = []; + var startDir = { x: 0, y: 0 }; + var endDir = { x: 0, y: 0 }; + for (var i = 0; i < nearestSeg / 4 + 1; i++) { + const bez = new Bezier(splicedPoints.slice(i * 4, i * 4 + 4).map(p => ({ x: p.X, y: p.Y }))); + if (i === 0) startDir = bez.derivative(0); + if (i === nearestSeg / 4) endDir = bez.derivative(nearestT); + for (var t = 0; t < (i === nearestSeg / 4 ? nearestT + .05 : 1); t += 0.05) { + const pt = bez.compute(i !== nearestSeg / 4 ? t : Math.min(nearestT, t)); + samplesLeft.push(new Point(pt.x, pt.y)); + } + } + var { finalCtrls, error } = FitOneCurve(samplesLeft, { X: startDir.x, Y: startDir.y }, { X: endDir.x, Y: endDir.y }); + for (var i = nearestSeg / 4; i < splicedPoints.length / 4; i++) { + const bez = new Bezier(splicedPoints.slice(i * 4, i * 4 + 4).map(p => ({ x: p.X, y: p.Y }))); + if (i === nearestSeg / 4) startDir = bez.derivative(nearestT); + if (i === splicedPoints.length / 4 - 1) endDir = bez.derivative(1); + for (var t = i === nearestSeg / 4 ? nearestT : 0; t < (i === nearestSeg / 4 ? 1 + .05 + 1e-7 : 1 + 1e-7); t += 0.05) { + const pt = bez.compute(Math.min(1, t)); + samplesRight.push(new Point(pt.x, pt.y)); + } + } + const { finalCtrls: rightCtrls, error: errorRight } = FitOneCurve(samplesRight, { X: startDir.x, Y: startDir.y }, { X: endDir.x, Y: endDir.y }); + finalCtrls = finalCtrls.concat(rightCtrls); + newink.splice(this._currentPoint - 4, 8, ...finalCtrls); + return newink; + } + } - const newpts = ink.map((pt, i) => { + return ink.map((pt, i) => { const leftHandlePoint = order === 0 && i === controlIndex + 1; const rightHandlePoint = order === 0 && controlIndex !== 0 && i === controlIndex - 2; if (controlIndex === i || + (order === 0 && controlIndex !== 0 && i === controlIndex - 1) || + (order === 3 && i === controlIndex - 1)) { + return ({ X: pt.X + deltaX, Y: pt.Y + deltaY }); + } + if (controlIndex === i || leftHandlePoint || rightHandlePoint || (order === 0 && controlIndex !== 0 && i === controlIndex - 1) || @@ -203,7 +289,6 @@ export class InkStrokeProperties { } return pt; }); - return newpts; }) @@ -243,8 +328,8 @@ export class InkStrokeProperties { if (snapData.distance < 10) { const deltaX = (snapData.nearestPt.X - ink[controlIndex].X); const deltaY = (snapData.nearestPt.Y - ink[controlIndex].Y); - const res = this.moveControlPtHandle(inkView, deltaX, deltaY, controlIndex); - console.log("X= " + snapData.nearestPt.X + " " + snapData.nearestPt.Y); + const res = this.moveControlPtHandle(inkView, deltaX, deltaY, controlIndex, ink.slice()); + console.log("X = " + snapData.nearestPt.X + " " + snapData.nearestPt.Y); return res; } } @@ -296,7 +381,7 @@ export class InkStrokeProperties { brokenIndices.splice(ind, 1); const [controlPoint, handleA, handleB] = [ink[controlIndex], ink[handleIndexA], ink[handleIndexB]]; const oppositeHandleA = this.rotatePoint(handleA, controlPoint, Math.PI); - const angleDifference = this.angleChange(handleB, oppositeHandleA, controlPoint); + const angleDifference = InkStrokeProperties.angleChange(handleB, oppositeHandleA, controlPoint); const inkCopy = ink.slice(); // have to make a new copy of the array to keep from corrupting undo/redo. without slicing, the same array will be stored in each undo step meaning earlier undo steps will be inadvertently updated to store the latest value. inkCopy[handleIndexB] = this.rotatePoint(handleB, controlPoint, angleDifference); return inkCopy; @@ -320,7 +405,7 @@ export class InkStrokeProperties { * * α = arccos(a·b / |a|·|b|), where a and b are both vectors. */ - angleBetweenTwoVectors = (vectorA: PointData, vectorB: PointData) => { + public static angleBetweenTwoVectors(vectorA: PointData, vectorB: PointData) { const magnitudeA = Math.sqrt(vectorA.X * vectorA.X + vectorA.Y * vectorA.Y); const magnitudeB = Math.sqrt(vectorB.X * vectorB.X + vectorB.Y * vectorB.Y); if (magnitudeA === 0 || magnitudeB === 0) return 0; @@ -333,14 +418,14 @@ export class InkStrokeProperties { /** * Finds the angle difference (in radians) between two vectors relative to an arbitrary origin. */ - angleChange = (a: PointData, b: PointData, origin: PointData) => { + public static angleChange(a: PointData, b: PointData, origin: PointData) { // Finding vector representation of inputted points relative to new origin. const vectorA = { X: a.X - origin.X, Y: a.Y - origin.Y }; const vectorB = { X: b.X - origin.X, Y: b.Y - origin.Y }; const crossProduct = vectorB.X * vectorA.Y - vectorB.Y * vectorA.X; // Determining whether rotation is clockwise or counterclockwise. const sign = crossProduct < 0 ? 1 : -1; - const theta = this.angleBetweenTwoVectors(vectorA, vectorB); + const theta = InkStrokeProperties.angleBetweenTwoVectors(vectorA, vectorB); return sign * theta; } @@ -364,7 +449,7 @@ export class InkStrokeProperties { // Rotate opposite handle if user hasn't held 'Alt' key or not first/final control (which have only 1 handle). if ((!brokenIndices || (!brokenIndices?.includes(controlIndex) && !brokenIndices?.includes(equivIndex))) && (closed || (handleIndex !== 1 && handleIndex !== ink.length - 2))) { - const angle = this.angleChange(oldHandlePoint, newHandlePoint, controlPoint); + const angle = InkStrokeProperties.angleChange(oldHandlePoint, newHandlePoint, controlPoint); inkCopy[oppositeHandleIndex] = this.rotatePoint(oppositeHandlePoint, controlPoint, angle); } return inkCopy; diff --git a/src/client/views/InkTangentHandles.tsx b/src/client/views/InkTangentHandles.tsx index f88a20448..ab73e58a4 100644 --- a/src/client/views/InkTangentHandles.tsx +++ b/src/client/views/InkTangentHandles.tsx @@ -29,24 +29,23 @@ export class InkTangentHandles extends React.Component<InkHandlesProps> { * @param handleNum The index of the currently selected handle point. */ onHandleDown = (e: React.PointerEvent, handleIndex: number): void => { - if (InkStrokeProperties.Instance) { - var controlUndo: UndoManager.Batch | undefined; - const screenScale = this.props.ScreenToLocalTransform().Scale; - const order = handleIndex % 4; - const oppositeHandleRawIndex = order === 1 ? handleIndex - 3 : handleIndex + 3; - const oppositeHandleIndex = (oppositeHandleRawIndex < 0 ? this.props.screenCtrlPoints.length + oppositeHandleRawIndex : oppositeHandleRawIndex) % this.props.screenCtrlPoints.length; - const controlIndex = (order === 1 ? handleIndex - 1 : handleIndex + 2) % this.props.screenCtrlPoints.length; - setupMoveUpEvents(this, e, (e: PointerEvent, down: number[], delta: number[]) => { + var controlUndo: UndoManager.Batch | undefined; + const screenScale = this.props.ScreenToLocalTransform().Scale; + const order = handleIndex % 4; + const oppositeHandleRawIndex = order === 1 ? handleIndex - 3 : handleIndex + 3; + const oppositeHandleIndex = (oppositeHandleRawIndex < 0 ? this.props.screenCtrlPoints.length + oppositeHandleRawIndex : oppositeHandleRawIndex) % this.props.screenCtrlPoints.length; + const controlIndex = (order === 1 ? handleIndex - 1 : handleIndex + 2) % this.props.screenCtrlPoints.length; + setupMoveUpEvents(this, e, + (e: PointerEvent, down: number[], delta: number[]) => { if (!controlUndo) controlUndo = UndoManager.StartBatch("DocDecs move tangent"); if (e.altKey) this.onBreakTangent(controlIndex); - InkStrokeProperties.Instance?.moveTangentHandle(this.props.inkView, -delta[0] * screenScale, -delta[1] * screenScale, handleIndex, oppositeHandleIndex, controlIndex); + InkStrokeProperties.Instance.moveTangentHandle(this.props.inkView, -delta[0] * screenScale, -delta[1] * screenScale, handleIndex, oppositeHandleIndex, controlIndex); return false; }, () => { controlUndo?.end(); UndoManager.FilterBatches(["data", "x", "y", "width", "height"]); }, emptyFunction - ); - } + ); } /** @@ -66,9 +65,6 @@ export class InkTangentHandles extends React.Component<InkHandlesProps> { } render() { - const formatInstance = InkStrokeProperties.Instance; - if (!formatInstance) return (null); - // Accessing the current ink's data and extracting all handle points and handle lines. const data = this.props.screenCtrlPoints; const tangentHandles: HandlePoint[] = []; @@ -107,7 +103,7 @@ export class InkTangentHandles extends React.Component<InkHandlesProps> { onPointerDown={e => this.onHandleDown(e, pts.I)} pointerEvents="all" cursor="default" - display={(pts.dot1 === formatInstance._currentPoint || pts.dot2 === formatInstance._currentPoint) ? "inherit" : "none"} /> + display={(pts.dot1 === InkStrokeProperties.Instance._currentPoint || pts.dot2 === InkStrokeProperties.Instance._currentPoint) ? "inherit" : "none"} /> </svg>)} {tangentLines.map((pts, i) => { const tangentLine = (x1: number, y1: number, x2: number, y2: number) => @@ -119,7 +115,7 @@ export class InkTangentHandles extends React.Component<InkHandlesProps> { stroke={Colors.MEDIUM_BLUE} strokeDasharray={"1 1"} strokeWidth={1} - display={(pts.dot1 === formatInstance._currentPoint || pts.dot2 === formatInstance._currentPoint) ? "inherit" : "none"} />; + display={(pts.dot1 === InkStrokeProperties.Instance._currentPoint || pts.dot2 === InkStrokeProperties.Instance._currentPoint) ? "inherit" : "none"} />; return <svg height="100" width="100" key={`line${i}`}> {tangentLine(pts.X1, pts.Y1, pts.X2, pts.Y2)} {tangentLine(pts.X2, pts.Y2, pts.X3, pts.Y3)} diff --git a/src/client/views/InkingStroke.tsx b/src/client/views/InkingStroke.tsx index ecb46a5b3..5c7fc94bd 100644 --- a/src/client/views/InkingStroke.tsx +++ b/src/client/views/InkingStroke.tsx @@ -1,62 +1,82 @@ +/* + InkingStroke - a document that represents an individual vector stroke drawn as a Bezier curve (open or closed) and optionally filled. + + The primary data is: + data - an InkField which is an array of PointData (X,Y values). The data is laid out as a sequence of simple bezier segments: + point 1, tangent pt 1, tangent pt 2, point 2, point 3, tangent pt 3, ... (Note that segment endpoints are duplicated ie Point2 = Point 3) + brokenIndices - an array of indexes into the data field where the incoming and outgoing tangents are not constrained to be equal + text - a text field that will be centered within a closed ink stroke + isInkMask - a flag that makes the ink stroke render as a mask over its collection where the stroke itself is mixBlendMode multiplied by + the underlying collection content, and everything outside the stroke is covered by a semi-opaque dark gray mask. + + The coordinates of the ink data need to be mapped to the screen since ink points are not changed when the DocumentView is translated or scaled. + Thus the mapping can roughly be described by: + the Top/Left of the ink data (minus 1/2 the ink width) maps to the Top/Left of the DocumentView + the Width/Height of the ink data (minus the ink width) is scaled to the PanelWidth/PanelHeight of the documentView + NOTE: use ptToScreen() and ptFromScreen() to transform between ink and screen space + + InkStrokes have a specialized 'componentUI' method that is called by MainView to render all of the interactive editing controls in + screen space (to avoid scaling artifacts) + + Most of the operations that can be performed on an InkStroke (eg delete a point, rotate, stretch) are implemented in the InkStrokeProperties helper class +*/ import React = require("react"); import { action, IReactionDisposer, observable, reaction } from "mobx"; import { observer } from "mobx-react"; -import { Doc } from "../../fields/Doc"; +import { Doc, WidthSym } from "../../fields/Doc"; import { documentSchema } from "../../fields/documentSchemas"; import { InkData, InkField, InkTool } from "../../fields/InkField"; import { makeInterface } from "../../fields/Schema"; import { BoolCast, Cast, NumCast, StrCast } from "../../fields/Types"; import { TraceMobx } from "../../fields/util"; -import { emptyFunction, returnFalse, setupMoveUpEvents } from "../../Utils"; +import { OmitKeys, returnFalse, setupMoveUpEvents } from "../../Utils"; import { CognitiveServices } from "../cognitive_services/CognitiveServices"; -import { CurrentUserUtils } from "../util/CurrentUserUtils"; import { InteractionUtils } from "../util/InteractionUtils"; import { SnappingManager } from "../util/SnappingManager"; +import { Transform } from "../util/Transform"; +import { UndoManager } from "../util/UndoManager"; import { ContextMenu } from "./ContextMenu"; import { ViewBoxBaseComponent } from "./DocComponent"; import { Colors } from "./global/globalEnums"; -import { InkControlPtHandles } from "./InkControlPtHandles"; +import { InkControlPtHandles, InkEndPtHandles } from "./InkControlPtHandles"; import "./InkStroke.scss"; import { InkStrokeProperties } from "./InkStrokeProperties"; import { InkTangentHandles } from "./InkTangentHandles"; import { FieldView, FieldViewProps } from "./nodes/FieldView"; +import { FormattedTextBox } from "./nodes/formattedText/FormattedTextBox"; import Color = require("color"); -import { Transform } from "../util/Transform"; type InkDocument = makeInterface<[typeof documentSchema]>; const InkDocument = makeInterface(documentSchema); @observer export class InkingStroke extends ViewBoxBaseComponent<FieldViewProps, InkDocument>(InkDocument) { + static readonly MaskDim = 50000; // choose a really big number to make sure mask fits over container (which in theory can be arbitrarily big) public static LayoutString(fieldStr: string) { return FieldView.LayoutString(InkingStroke, fieldStr); } - static readonly MaskDim = 50000; public static IsClosed(inkData: InkData) { return inkData && inkData.lastElement().X === inkData[0].X && inkData.lastElement().Y === inkData[0].Y; } - @observable private _properties?: InkStrokeProperties; - _handledClick = false; // flag denoting whether ink stroke has handled a psuedo-click onPointerUp so that the real onClick event can be stopPropagated - _selDisposer: IReactionDisposer | undefined; + private _handledClick = false; // flag denoting whether ink stroke has handled a psuedo-click onPointerUp so that the real onClick event can be stopPropagated + private _selDisposer?: IReactionDisposer; - @observable _nearestT: number | undefined; - @observable _nearestSeg: number | undefined; - @observable _nearestScrPt: { X: number, Y: number } | undefined; - @observable _inkSamplePts: { X: number, Y: number }[] | undefined; - - constructor(props: FieldViewProps & InkDocument) { - super(props); - - this._properties = InkStrokeProperties.Instance; - } + @observable _nearestSeg?: number; // nearest Bezier segment along the ink stroke to the cursor (used for displaying the Add Point highlight) + @observable _nearestT?: number; // nearest t value within the nearest Bezier segment " + @observable _nearestScrPt?: { X: number, Y: number }; // nearst screen point on the ink stroke "" componentDidMount() { this.props.setContentView?.(this); this._selDisposer = reaction(() => this.props.isSelected(), // react to stroke being deselected by turning off ink handles - selected => !selected && this.toggleControlButton()); + selected => !selected && (InkStrokeProperties.Instance._controlButton = false)); } componentWillUnmount() { this._selDisposer?.(); } + /** + * @returns the center of the ink stroke in the ink document's coordinate space (not screen space, and not the ink data coordinate space); + * DocumentDecorations calls getBounds() on DocumentViews which call getCenter() if defined - in the case of ink it needs to be defined since + * the center of the ink stroke changes as the stroke is rotated. + */ getCenter = (xf: Transform) => { const { inkData, inkScaleX, inkScaleY, inkStrokeWidth, inkTop, inkLeft } = this.inkScaledData(); const angle = -NumCast(this.layoutDoc.rotation); @@ -73,11 +93,21 @@ export class InkingStroke extends ViewBoxBaseComponent<FieldViewProps, InkDocume return { X: tc[0], Y: tc[1] }; } + /** + * analyzes the ink stroke and saves the analysis of the stroke to the 'inkAnalysis' field, + * and the recognized words to the 'handwriting' + */ analyzeStrokes() { const data: InkData = Cast(this.dataDoc[this.fieldKey], InkField)?.inkData ?? []; CognitiveServices.Inking.Appliers.ConcatenateHandwriting(this.dataDoc, ["inkAnalysis", "handwriting"], [data]); } + /** + * Toggles whether the ink stroke is displayed as an overlay mask or as a regular stroke. + * When displayed as a mask, the stroke is rendered with mixBlendMode set to multiply so that the stroke will + * appear to illuminate what it covers up. At the same time, all pixels that are not under the stroke will be + * dimmed by a semi-opaque overlay mask. + */ public static toggleMask = action((inkDoc: Doc) => { inkDoc.isInkMask = !inkDoc.isInkMask; inkDoc._backgroundColor = inkDoc.isInkMask ? "rgba(0,0,0,0.7)" : undefined; @@ -86,44 +116,53 @@ export class InkingStroke extends ViewBoxBaseComponent<FieldViewProps, InkDocume inkDoc._stayInCollection = inkDoc.isInkMask ? true : undefined; }); /** - * Handles the movement of the entire ink object when the user clicks and drags. + * Drags the a simple bezier segment of the stroke. + * Also adds a control point when double clicking on the stroke. */ + @action onPointerDown = (e: React.PointerEvent) => { this._handledClick = false; - if (this.props.isSelected(true)) { - setupMoveUpEvents(this, e, returnFalse, emptyFunction, - action((e: PointerEvent, doubleTap: boolean | undefined) => { - doubleTap = doubleTap || this.props.docViewPath().lastElement()?.docView?._pendingDoubleClick; - if (doubleTap && this._properties) { - this._properties._controlButton = true; - InkStrokeProperties.Instance && (InkStrokeProperties.Instance._currentPoint = -1); - this._handledClick = true; // mark the double-click pseudo pointerevent so we can block the real mouse event from propagating to DocumentView - } else if (this._properties?._controlButton) { - this._nearestT && this._nearestSeg !== undefined && InkStrokeProperties.Instance?.addPoints(this.props.docViewPath().lastElement(), this._nearestT, this._nearestSeg, this.inkScaledData().inkData.slice()); + const inkView = this.props.docViewPath().lastElement(); + const { inkData, inkScaleX, inkScaleY, inkStrokeWidth, inkTop, inkLeft } = this.inkScaledData(); + const screenPts = inkData.map(point => this.props.ScreenToLocalTransform().inverse().transformPoint( + (point.X - inkLeft - inkStrokeWidth / 2) * inkScaleX + inkStrokeWidth / 2, + (point.Y - inkTop - inkStrokeWidth / 2) * inkScaleY + inkStrokeWidth / 2)).map(p => ({ X: p[0], Y: p[1] })); + const { nearestSeg } = InkStrokeProperties.nearestPtToStroke(screenPts, { X: e.clientX, Y: e.clientY }); + const controlIndex = nearestSeg; + const wasSelected = InkStrokeProperties.Instance._currentPoint === controlIndex; + var controlUndo: UndoManager.Batch | undefined; + const isEditing = InkStrokeProperties.Instance._controlButton && this.props.isSelected(); + setupMoveUpEvents(this, e, + !isEditing ? returnFalse : action((e: PointerEvent, down: number[], delta: number[]) => { + if (!controlUndo) controlUndo = UndoManager.StartBatch("drag ink ctrl pt"); + const inkMoveEnd = this.ptFromScreen({ X: delta[0], Y: delta[1] }); + const inkMoveStart = this.ptFromScreen({ X: 0, Y: 0 }); + InkStrokeProperties.Instance.moveControlPtHandle(inkView, inkMoveEnd.X - inkMoveStart.X, inkMoveEnd.Y - inkMoveStart.Y, controlIndex); + InkStrokeProperties.Instance.moveControlPtHandle(inkView, inkMoveEnd.X - inkMoveStart.X, inkMoveEnd.Y - inkMoveStart.Y, controlIndex + 3); + return false; + }), + !isEditing ? returnFalse : action(() => { + controlUndo?.end(); + controlUndo = undefined; + UndoManager.FilterBatches(["data", "x", "y", "width", "height"]); + }), + action((e: PointerEvent, doubleTap: boolean | undefined) => { + doubleTap = doubleTap || this.props.docViewPath().lastElement()?.docView?._pendingDoubleClick; + if (doubleTap) { + InkStrokeProperties.Instance._controlButton = true; + InkStrokeProperties.Instance._currentPoint = -1; + this._handledClick = true; // mark the double-click pseudo pointerevent so we can block the real mouse event from propagating to DocumentView + if (isEditing) { + this._nearestT && this._nearestSeg !== undefined && InkStrokeProperties.Instance.addPoints(this.props.docViewPath().lastElement(), this._nearestT, this._nearestSeg, this.inkScaledData().inkData.slice()); } - }), this._properties?._controlButton, this._properties?._controlButton - ); - } + } + }), isEditing, isEditing, action(() => wasSelected && (InkStrokeProperties.Instance._currentPoint = -1))); } /** - * Ensures the ink controls and handles aren't rendered when the current ink stroke is reselected. + * @param scrPt a point in the screen coordinate space + * @returns the point in the ink data's coordinate space. */ - @action - toggleControlButton = () => { - if (!this.props.isSelected() && this._properties) { - this._properties._controlButton = false; - } - } - - @action - checkHighlighter = () => { - if (CurrentUserUtils.SelectedTool === InkTool.Highlighter) { - // this._previousColor = ActiveInkColor(); - SetActiveInkColor("rgba(245, 230, 95, 0.75)"); - } - } - ptFromScreen = (scrPt: { X: number, Y: number }) => { const { inkScaleX, inkScaleY, inkStrokeWidth, inkTop, inkLeft } = this.inkScaledData(); const docPt = this.props.ScreenToLocalTransform().transformPoint(scrPt.X, scrPt.Y); @@ -133,6 +172,11 @@ export class InkingStroke extends ViewBoxBaseComponent<FieldViewProps, InkDocume }; return inkPt; } + + /** + * @param inkPt a point in the ink data's coordinate space + * @returns the screen point corresponding to the ink point + */ ptToScreen = (inkPt: { X: number, Y: number }) => { const { inkScaleX, inkScaleY, inkStrokeWidth, inkTop, inkLeft } = this.inkScaledData(); const docPt = { @@ -143,13 +187,23 @@ export class InkingStroke extends ViewBoxBaseComponent<FieldViewProps, InkDocume return { X: scrPt[0], Y: scrPt[1] }; } + /** + * Snaps a screen space point to this stroke, optionally skipping bezier segments indicated by 'excludeSegs' + * @param scrPt - the point to snap to this stroke + * @param excludeSegs - optional segments in this stroke to skip (this is used when dragging a point on the stroke and not wanting the drag point to snap to its neighboring segments) + * + * @returns the nearest ink space point on this stroke to the screen point AND the screen space distance from the snapped point to the nearest point + */ snapPt = (scrPt: { X: number, Y: number }, excludeSegs?: number[]) => { const { inkData } = this.inkScaledData(); - const inkPt = this.ptFromScreen(scrPt); - const { nearestPt, distance } = InkStrokeProperties.nearestPtToStroke(inkData, inkPt, excludeSegs ?? []); + const { nearestPt, distance } = InkStrokeProperties.nearestPtToStroke(inkData, this.ptFromScreen(scrPt), excludeSegs ?? []); return { nearestPt, distance: distance * this.props.ScreenToLocalTransform().inverse().Scale }; } + /** + * extracts key features from the inkData, including: the data points, the ink width, the ink bounds (top,left, width, height), and the scale + * factor for converting between ink and screen space. + */ inkScaledData = () => { const inkData = Cast(this.dataDoc[this.fieldKey], InkField)?.inkData ?? []; const inkStrokeWidth = NumCast(this.rootDoc.strokeWidth, 1); @@ -184,8 +238,16 @@ export class InkingStroke extends ViewBoxBaseComponent<FieldViewProps, InkDocume this._nearestScrPt = nearestPt; } - + /** + * @returns the nearest screen point to the cursor (to render a highlight for the point to be added) + */ nearestScreenPt = () => this._nearestScrPt; + + /** + * @param boundsLeft the screen space left coordinate of the ink stroke + * @param boundsTop the screen space top coordinate of the ink stroke + * @returns the JSX controls for displaying an editing UI for the stroke (control point & tangent handles) + */ componentUI = (boundsLeft: number, boundsTop: number) => { const inkDoc = this.props.Document; const screenSpaceCenterlineStrokeWidth = 3; // the width of the blue line widget that shows the centerline of the ink stroke @@ -199,14 +261,21 @@ export class InkingStroke extends ViewBoxBaseComponent<FieldViewProps, InkDocume const startMarker = StrCast(this.layoutDoc.strokeStartMarker); const endMarker = StrCast(this.layoutDoc.strokeEndMarker); - return SnappingManager.GetIsDragging() ? (null) : <div className="inkstroke-UI" style={{ - clip: `rect(${boundsTop}px, 10000px, 10000px, ${boundsLeft}px)` - }} > - {!this._properties?._controlButton ? (null) : - <> + const markerScale = NumCast(this.layoutDoc.strokeMarkerScale, 1); + return SnappingManager.GetIsDragging() ? (null) : + !InkStrokeProperties.Instance._controlButton ? + (!this.props.isSelected() || InkingStroke.IsClosed(inkData) ? (null) : + <div className="inkstroke-UI" style={{ clip: `rect(${boundsTop}px, 10000px, 10000px, ${boundsLeft}px)` }}> + <InkEndPtHandles + inkView={this.props.docViewPath().lastElement()} + inkDoc={inkDoc} + startPt={this.ptToScreen(inkData[0])} + endPt={this.ptToScreen(inkData.lastElement())} + screenSpaceLineWidth={screenSpaceCenterlineStrokeWidth} /></div>) : + <div className="inkstroke-UI" style={{ clip: `rect(${boundsTop}px, 10000px, 10000px, ${boundsLeft}px)` }}> {InteractionUtils.CreatePolyline(screenPts, 0, 0, Colors.MEDIUM_BLUE, screenInkWidth[0], screenSpaceCenterlineStrokeWidth, StrCast(inkDoc.strokeLineJoin), StrCast(this.layoutDoc.strokeLineCap), StrCast(inkDoc.strokeBezier), - "none", startMarker, endMarker, StrCast(inkDoc.strokeDash), 1, 1, "", "none", 1.0, false)} + "none", startMarker, endMarker, markerScale, StrCast(inkDoc.strokeDash), 1, 1, "", "none", 1.0, false)} <InkControlPtHandles inkView={this.props.docViewPath().lastElement()} inkDoc={inkDoc} @@ -221,8 +290,7 @@ export class InkingStroke extends ViewBoxBaseComponent<FieldViewProps, InkDocume screenCtrlPoints={screenHdlPts} screenSpaceLineWidth={screenSpaceCenterlineStrokeWidth} ScreenToLocalTransform={this.props.ScreenToLocalTransform} /> - </>} - </div>; + </div>; } render() { @@ -231,6 +299,7 @@ export class InkingStroke extends ViewBoxBaseComponent<FieldViewProps, InkDocume const startMarker = StrCast(this.layoutDoc.strokeStartMarker); const endMarker = StrCast(this.layoutDoc.strokeEndMarker); + const markerScale = NumCast(this.layoutDoc.strokeMarkerScale, 1); const closed = InkingStroke.IsClosed(inkData); const fillColor = StrCast(this.layoutDoc.fillColor, "transparent"); const strokeColor = !closed && fillColor && fillColor !== "transparent" ? fillColor : StrCast(this.layoutDoc.color); @@ -239,43 +308,59 @@ export class InkingStroke extends ViewBoxBaseComponent<FieldViewProps, InkDocume const inkLine = InteractionUtils.CreatePolyline(inkData, inkLeft, inkTop, strokeColor, inkStrokeWidth, inkStrokeWidth, StrCast(this.layoutDoc.strokeLineJoin), StrCast(this.layoutDoc.strokeLineCap), StrCast(this.layoutDoc.strokeBezier), !closed ? "none" : fillColor === "transparent" ? "none" : fillColor, startMarker, endMarker, - StrCast(this.layoutDoc.strokeDash), inkScaleX, inkScaleY, "", "none", 1.0, false); + markerScale, StrCast(this.layoutDoc.strokeDash), inkScaleX, inkScaleY, "", "none", 1.0, false); const highlightIndex = BoolCast(this.props.Document.isLinkButton) && Doc.isBrushedHighlightedDegree(this.props.Document); // bcz: Argh!! need to identify a tree view doc better than a LayoutTemlatString const highlightColor = !highlightIndex ? StrCast(this.layoutDoc.strokeOutlineColor, !closed && fillColor && fillColor !== "transparent" ? StrCast(this.layoutDoc.color, "transparent") : "transparent") : ["transparent", "rgb(68, 118, 247)", "rgb(68, 118, 247)", "yellow", "magenta", "cyan", "orange"][highlightIndex]; // Invisible polygonal line that enables the ink to be selected by the user. - const clickableLine = InteractionUtils.CreatePolyline(inkData, inkLeft, inkTop, highlightColor, - inkStrokeWidth, inkStrokeWidth + (highlightIndex && closed && (new Color(fillColor)).alpha() < 1 ? 6 : 15), + const clickableLine = (downHdlr?: (e: React.PointerEvent) => void) => InteractionUtils.CreatePolyline(inkData, inkLeft, inkTop, highlightColor, + inkStrokeWidth, inkStrokeWidth + (highlightIndex && closed && fillColor && (new Color(fillColor)).alpha() < 1 ? 6 : 15), StrCast(this.layoutDoc.strokeLineJoin), StrCast(this.layoutDoc.strokeLineCap), StrCast(this.layoutDoc.strokeBezier), !closed ? "none" : fillColor === "transparent" ? "none" : fillColor, startMarker, endMarker, - undefined, inkScaleX, inkScaleY, "", this.props.pointerEvents ?? (this.props.layerProvider?.(this.props.Document) === false ? "none" : "visiblepainted"), 0.0, false); - // Set of points rendered upon the ink that can be added if a user clicks on one. + markerScale, undefined, inkScaleX, inkScaleY, "", this.props.pointerEvents ?? (this.props.layerProvider?.(this.props.Document) === false ? "none" : "visiblepainted"), 0.0, + false, downHdlr); - return ( + return <div className="inkStroke-wrapper"> <svg className="inkStroke" style={{ - pointerEvents: "none", transform: this.props.Document.isInkMask ? `translate(${InkingStroke.MaskDim / 2}px, ${InkingStroke.MaskDim / 2}px)` : undefined, mixBlendMode: this.layoutDoc.tool === InkTool.Highlighter ? "multiply" : "unset", - overflow: "visible", cursor: this.props.isSelected() ? "default" : undefined }} onPointerLeave={action(e => this._nearestScrPt = undefined)} onPointerMove={this.props.isSelected() ? this.onPointerMove : undefined} - onPointerDown={this.onPointerDown} onClick={e => this._handledClick && e.stopPropagation()} onContextMenu={() => { const cm = ContextMenu.Instance; !Doc.UserDoc().noviceMode && cm?.addItem({ description: "Recognize Writing", event: this.analyzeStrokes, icon: "paint-brush" }); cm?.addItem({ description: "Toggle Mask", event: () => InkingStroke.toggleMask(this.rootDoc), icon: "paint-brush" }); - cm?.addItem({ description: "Edit Points", event: action(() => { if (this._properties) { this._properties._controlButton = !this._properties._controlButton; } }), icon: "paint-brush" }); + cm?.addItem({ description: "Edit Points", event: action(() => InkStrokeProperties.Instance._controlButton = !InkStrokeProperties.Instance._controlButton), icon: "paint-brush" }); }} > - {clickableLine} + {clickableLine(this.onPointerDown)} {inkLine} </svg> - ); + {!closed ? (null) : + <div className="inkStroke-text" style={{ + color: StrCast(this.layoutDoc.textColor, "black"), + pointerEvents: this.props.isDocumentActive?.() ? "all" : undefined, + width: this.layoutDoc[WidthSym](), + }}> + <FormattedTextBox + {...OmitKeys(this.props, ['children']).omit} + yPadding={10} + xPadding={10} + fieldKey={"text"} + fontSize={12} + dontRegisterView={true} + noSidebar={true} + dontScale={true} + isContentActive={this.isContentActive} + /> + </div> + } + </div>; } } @@ -286,12 +371,14 @@ export function SetActiveInkColor(value: string) { ActiveInkPen() && (ActiveInkP export function SetActiveFillColor(value: string) { ActiveInkPen() && (ActiveInkPen().activeFillColor = value); } export function SetActiveArrowStart(value: string) { ActiveInkPen() && (ActiveInkPen().activeArrowStart = value); } export function SetActiveArrowEnd(value: string) { ActiveInkPen() && (ActiveInkPen().activeArrowEnd = value); } +export function SetActiveArrowScale(value: number) { ActiveInkPen() && (ActiveInkPen().activeArrowScale = value); } export function SetActiveDash(dash: string): void { !isNaN(parseInt(dash)) && ActiveInkPen() && (ActiveInkPen().activeDash = dash); } export function ActiveInkPen(): Doc { return Doc.UserDoc(); } export function ActiveInkColor(): string { return StrCast(ActiveInkPen()?.activeInkColor, "black"); } export function ActiveFillColor(): string { return StrCast(ActiveInkPen()?.activeFillColor, ""); } export function ActiveArrowStart(): string { return StrCast(ActiveInkPen()?.activeArrowStart, ""); } export function ActiveArrowEnd(): string { return StrCast(ActiveInkPen()?.activeArrowEnd, ""); } +export function ActiveArrowScale(): number { return NumCast(ActiveInkPen()?.activeArrowScale, 1); } export function ActiveDash(): string { return StrCast(ActiveInkPen()?.activeDash, "0"); } export function ActiveInkWidth(): number { return Number(ActiveInkPen()?.activeInkWidth); } export function ActiveInkBezierApprox(): string { return StrCast(ActiveInkPen()?.activeInkBezier); } diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index 9a885fbf8..0bd6c9166 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -116,7 +116,6 @@ export class MainView extends React.Component { }, 0); setTimeout(() => ele.outerHTML = '', 1000); } - new InkStrokeProperties(); this._sidebarContent.proto = undefined; if (!MainView.Live) { DocServer.setPlaygroundFields(["dataTransition", "treeViewOpen", "autoHeight", "showSidebar", "sidebarWidthPercent", "viewTransition", @@ -187,7 +186,7 @@ export class MainView extends React.Component { fa.faArrowAltCircleDown, fa.faArrowAltCircleUp, fa.faArrowAltCircleLeft, fa.faArrowAltCircleRight, fa.faStopCircle, fa.faCheckCircle, fa.faGripVertical, fa.faSortUp, fa.faSortDown, fa.faTable, fa.faTh, fa.faThList, fa.faProjectDiagram, fa.faSignature, fa.faColumns, fa.faChevronCircleUp, fa.faUpload, fa.faBorderAll, fa.faBraille, fa.faChalkboard, fa.faPencilAlt, fa.faEyeSlash, fa.faSmile, fa.faIndent, fa.faOutdent, fa.faChartBar, fa.faBan, fa.faPhoneSlash, fa.faGripLines, - fa.faSave, fa.faBookmark, fa.faList, fa.faListOl, fa.faFolderPlus, fa.faLightbulb, fa.faBookOpen); + fa.faSave, fa.faBookmark, fa.faList, fa.faListOl, fa.faFolderPlus, fa.faLightbulb, fa.faBookOpen, fa.faMapMarkerAlt); this.initAuthenticationRouters(); } @@ -511,7 +510,7 @@ export class MainView extends React.Component { bringToFront={emptyFunction} select={emptyFunction} isAnyChildContentActive={returnFalse} - isContentActive={returnFalse} + isContentActive={emptyFunction} isSelected={returnFalse} docViewPath={returnEmptyDoclist} moveDocument={this.moveButtonDoc} @@ -592,7 +591,7 @@ export class MainView extends React.Component { pinToPres={returnFalse} ScreenToLocalTransform={Transform.Identity} bringToFront={returnFalse} - isContentActive={returnFalse} + isContentActive={emptyFunction} whenChildContentsActiveChanged={returnFalse} focus={returnFalse} docViewPath={returnEmptyDoclist} @@ -669,7 +668,7 @@ export class MainView extends React.Component { pinToPres={returnFalse} ScreenToLocalTransform={Transform.Identity} bringToFront={returnFalse} - isContentActive={returnFalse} + isContentActive={emptyFunction} whenChildContentsActiveChanged={returnFalse} focus={returnFalse} PanelWidth={() => 500} diff --git a/src/client/views/OverlayView.tsx b/src/client/views/OverlayView.tsx index af04b967a..7cf388872 100644 --- a/src/client/views/OverlayView.tsx +++ b/src/client/views/OverlayView.tsx @@ -191,7 +191,7 @@ export class OverlayView extends React.Component { ScreenToLocalTransform={Transform.Identity} renderDepth={1} isDocumentActive={returnTrue} - isContentActive={returnFalse} + isContentActive={emptyFunction} whenChildContentsActiveChanged={emptyFunction} focus={DocUtils.DefaultFocus} styleProvider={DefaultStyleProvider} diff --git a/src/client/views/Palette.tsx b/src/client/views/Palette.tsx index 86ab881bb..529697f71 100644 --- a/src/client/views/Palette.tsx +++ b/src/client/views/Palette.tsx @@ -50,7 +50,7 @@ export default class Palette extends React.Component<PaletteProps> { PanelHeight={() => window.screen.height} renderDepth={0} isDocumentActive={returnTrue} - isContentActive={returnFalse} + isContentActive={emptyFunction} focus={emptyFunction} docViewPath={returnEmptyDoclist} styleProvider={returnEmptyString} diff --git a/src/client/views/PropertiesView.tsx b/src/client/views/PropertiesView.tsx index 936003dce..92e6cbb89 100644 --- a/src/client/views/PropertiesView.tsx +++ b/src/client/views/PropertiesView.tsx @@ -300,7 +300,7 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { freezeDimensions={true} dontCenter={"y"} isDocumentActive={returnFalse} - isContentActive={returnFalse} + isContentActive={emptyFunction} NativeWidth={layoutDoc.type === DocumentType.RTF ? this.rtfWidth : undefined} NativeHeight={layoutDoc.type === DocumentType.RTF ? this.rtfHeight : undefined} PanelWidth={panelWidth} @@ -540,16 +540,17 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { @computed get controlPointsButton() { - const formatInstance = InkStrokeProperties.Instance; - return !formatInstance ? (null) : <div className="inking-button"> + return <div className="inking-button"> <Tooltip title={<div className="dash-tooltip">{"Edit points"}</div>}> - <div className="inking-button-points" onPointerDown={action(() => formatInstance._controlButton = !formatInstance._controlButton)} style={{ backgroundColor: formatInstance._controlButton ? "black" : "" }}> + <div className="inking-button-points" + style={{ backgroundColor: InkStrokeProperties.Instance._controlButton ? "black" : "" }} + onPointerDown={action(() => InkStrokeProperties.Instance._controlButton = !InkStrokeProperties.Instance._controlButton)} > <FontAwesomeIcon icon="bezier-curve" color="white" size="lg" /> </div> </Tooltip> - <Tooltip title={<div className="dash-tooltip">{formatInstance._lock ? "Unlock ratio" : "Lock ratio"}</div>}> - <div className="inking-button-lock" onPointerDown={action(() => formatInstance._lock = !formatInstance._lock)} > - <FontAwesomeIcon icon={formatInstance._lock ? "lock" : "unlock"} color="white" size="lg" /> + <Tooltip title={<div className="dash-tooltip">{InkStrokeProperties.Instance._lock ? "Unlock ratio" : "Lock ratio"}</div>}> + <div className="inking-button-lock" onPointerDown={action(() => InkStrokeProperties.Instance._lock = !InkStrokeProperties.Instance._lock)} > + <FontAwesomeIcon icon={InkStrokeProperties.Instance._lock ? "lock" : "unlock"} color="white" size="lg" /> </div> </Tooltip> <Tooltip title={<div className="dash-tooltip">{"Rotate 90˚"}</div>}> @@ -608,7 +609,7 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { const oldX = NumCast(this.selectedDoc?.x); const oldY = NumCast(this.selectedDoc?.y); this.selectedDoc && (this.selectedDoc._width = oldWidth + (dirs === "up" ? 10 : - 10)); - InkStrokeProperties.Instance?._lock && this.selectedDoc && (this.selectedDoc._height = (NumCast(this.selectedDoc?._width) / oldWidth * NumCast(this.selectedDoc?._height))); + InkStrokeProperties.Instance._lock && this.selectedDoc && (this.selectedDoc._height = (NumCast(this.selectedDoc?._width) / oldWidth * NumCast(this.selectedDoc?._height))); const doc = this.selectedDoc; if (doc?.type === DocumentType.INK && doc.x && doc.y && doc._height && doc._width) { const ink = Cast(doc.data, InkField)?.inkData; @@ -630,7 +631,7 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { const oX = NumCast(this.selectedDoc?.x); const oY = NumCast(this.selectedDoc?.y); this.selectedDoc && (this.selectedDoc._height = oHeight + (dirs === "up" ? 10 : - 10)); - InkStrokeProperties.Instance?._lock && this.selectedDoc && (this.selectedDoc._width = (NumCast(this.selectedDoc?._height) / oHeight * NumCast(this.selectedDoc?._width))); + InkStrokeProperties.Instance._lock && this.selectedDoc && (this.selectedDoc._width = (NumCast(this.selectedDoc?._height) / oHeight * NumCast(this.selectedDoc?._width))); const docu = this.selectedDoc; if (docu?.type === DocumentType.INK && docu.x && docu.y && docu._height && docu._width) { const ink = Cast(docu.data, InkField)?.inkData; @@ -668,12 +669,12 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { set shapeWid(value) { const oldWidth = NumCast(this.selectedDoc?._width); this.selectedDoc && (this.selectedDoc._width = Number(value)); - InkStrokeProperties.Instance?._lock && this.selectedDoc && (this.selectedDoc._height = (NumCast(this.selectedDoc?._width) * NumCast(this.selectedDoc?._height)) / oldWidth); + InkStrokeProperties.Instance._lock && this.selectedDoc && (this.selectedDoc._height = (NumCast(this.selectedDoc?._width) * NumCast(this.selectedDoc?._height)) / oldWidth); } set shapeHgt(value) { const oldHeight = NumCast(this.selectedDoc?._height); this.selectedDoc && (this.selectedDoc._height = Number(value)); - InkStrokeProperties.Instance?._lock && this.selectedDoc && (this.selectedDoc._width = (NumCast(this.selectedDoc?._height) * NumCast(this.selectedDoc?._width)) / oldHeight); + InkStrokeProperties.Instance._lock && this.selectedDoc && (this.selectedDoc._width = (NumCast(this.selectedDoc?._height) * NumCast(this.selectedDoc?._width)) / oldHeight); } @computed get hgtInput() { return this.inputBoxDuo("hgt", this.shapeHgt, (val: string) => { if (!isNaN(Number(val))) { this.shapeHgt = val; } return true; }, "H:", "wid", this.shapeWid, (val: string) => { if (!isNaN(Number(val))) { this.shapeWid = val; } return true; }, "W:"); } @@ -760,6 +761,7 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { @computed get dashdStk() { return this.selectedDoc?.strokeDash || ""; } @computed get unStrokd() { return this.selectedDoc?.color ? true : false; } @computed get widthStk() { return this.getField("strokeWidth") || "1"; } + @computed get markScal() { return Number(this.getField("strokeMakerScale") || "1"); } @computed get markHead() { return this.getField("strokeStartMarker") || ""; } @computed get markTail() { return this.getField("strokeEndMarker") || ""; } set solidStk(value) { this.dashdStk = ""; this.unStrokd = !value; } @@ -767,6 +769,7 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { value && (this._lastDash = value) && (this.unStrokd = false); this.selectedDoc && (this.selectedDoc.strokeDash = value ? this._lastDash : undefined); } + set markScal(value) { this.selectedDoc && (this.selectedDoc.strokeMarkerScale = Number(value)); } set widthStk(value) { this.selectedDoc && (this.selectedDoc.strokeWidth = Number(value)); } set unStrokd(value) { this.colorStk = value ? "" : this._lastLine; } set markHead(value) { this.selectedDoc && (this.selectedDoc.strokeStartMarker = value); } @@ -774,6 +777,7 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { @computed get stkInput() { return this.regInput("stk", this.widthStk, (val: string) => this.widthStk = val); } + @computed get markScaleInput() { return this.regInput("scale", this.markScal.toString(), (val: string) => this.markScal = Number(val)); } regInput = (key: string, value: any, setter: (val: string) => {}) => { @@ -811,6 +815,18 @@ export class PropertiesView extends React.Component<PropertiesViewProps> { <div className="arrows"> <div className="arrows-head"> + <div className="width-top"> + <div className="width-title">Arrow Scale:</div> + {/* <div className="width-input">{this.markScalInput}</div> */} + </div> + <input className="width-range" type="range" + defaultValue={this.markScal} min={0} max={10} + onChange={(action(e => this.markScal = +e.target.value))} + onMouseDown={(e) => { this._widthUndo = UndoManager.StartBatch("scale undo"); }} + onMouseUp={(e) => { this._widthUndo?.end(); this._widthUndo = undefined; }} + /> + </div> + <div className="arrows-head"> <div className="arrows-head-title" >Arrow Head: </div> <input key="markHead" className="arrows-head-input" type="checkbox" checked={this.markHead !== ""} diff --git a/src/client/views/SidebarAnnos.tsx b/src/client/views/SidebarAnnos.tsx index 6a5b0a3ae..62f6e388f 100644 --- a/src/client/views/SidebarAnnos.tsx +++ b/src/client/views/SidebarAnnos.tsx @@ -23,6 +23,7 @@ interface ExtraProps { layoutDoc: Doc; rootDoc: Doc; dataDoc: Doc; + // usePanelWidth: boolean; showSidebar: boolean; nativeWidth: number; whenChildContentsActiveChanged: (isActive: boolean) => void; @@ -77,7 +78,10 @@ export class SidebarAnnos extends React.Component<FieldViewProps & ExtraProps> { get sidebarKey() { return this.props.fieldKey + "-sidebar"; } filtersHeight = () => 38; screenToLocalTransform = () => this.props.ScreenToLocalTransform().translate(Doc.NativeWidth(this.props.dataDoc), 0).scale(this.props.scaling?.() || 1); - panelWidth = () => !this.props.showSidebar ? 0 : this.props.layoutDoc.type === DocumentType.RTF ? this.props.PanelWidth() : (NumCast(this.props.nativeWidth) - Doc.NativeWidth(this.props.dataDoc)) * this.props.PanelWidth() / NumCast(this.props.nativeWidth); + // panelWidth = () => !this.props.layoutDoc._showSidebar ? 0 : + // this.props.usePanelWidth ? this.props.PanelWidth() : + // (NumCast(this.props.layoutDoc.nativeWidth) - Doc.NativeWidth(this.props.dataDoc)) * this.props.PanelWidth() / NumCast(this.props.layoutDoc.nativeWidth); + panelWidth = () => !this.props.showSidebar ? 0 : this.props.layoutDoc.type === DocumentType.RTF || this.props.layoutDoc.type === DocumentType.MAP ? this.props.PanelWidth() : (NumCast(this.props.nativeWidth) - Doc.NativeWidth(this.props.dataDoc)) * this.props.PanelWidth() / NumCast(this.props.nativeWidth); panelHeight = () => this.props.PanelHeight() - this.filtersHeight(); addDocument = (doc: Doc | Doc[]) => this.props.sidebarAddDocument(doc, this.sidebarKey); moveDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (doc: Doc | Doc[]) => boolean) => this.props.moveDocument(doc, targetCollection, addDocument, this.sidebarKey); diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx index 3f120d385..8ee673115 100644 --- a/src/client/views/StyleProvider.tsx +++ b/src/client/views/StyleProvider.tsx @@ -46,6 +46,7 @@ export enum StyleProp { JitterRotation = "jitterRotation", // whether documents should be randomly rotated BorderPath = "customBorder", // border path for document view FontSize = "fontSize", // size of text font + FontFamily = "fontFamily", // size of text font } function darkScheme() { return CurrentUserUtils.ActiveDashboard?.colorScheme === ColorScheme.Dark; } @@ -91,7 +92,8 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<DocumentViewProps case StyleProp.WidgetColor: return isAnnotated ? Colors.LIGHT_BLUE : darkScheme() ? "lightgrey" : "dimgrey"; case StyleProp.Opacity: return Cast(doc?._opacity, "number", Cast(doc?.opacity, "number", null)); case StyleProp.HideLinkButton: return props?.hideLinkButton || (!selected && (doc?.isLinkButton || doc?.hideLinkButton)); - case StyleProp.FontSize: return StrCast(doc?.[fieldKey + "fontSize"]); + case StyleProp.FontSize: return StrCast(doc?.[fieldKey + "fontSize"], StrCast(doc?.fontSize, StrCast(Doc.UserDoc().fontSize))); + case StyleProp.FontFamily: return StrCast(doc?.[fieldKey + "fontFamily"], StrCast(doc?.fontFamily, StrCast(Doc.UserDoc().fontFamily))); case StyleProp.ShowTitle: return (doc && !doc.presentationTargetDoc && StrCast(doc._showTitle, props?.showTitle?.() || @@ -131,6 +133,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<DocumentViewProps case DocumentType.IMG: case DocumentType.WEB: case DocumentType.PDF: + case DocumentType.MAP: case DocumentType.SCREENSHOT: case DocumentType.VID: docColor = docColor || (darkScheme() ? Colors.DARK_GRAY : Colors.LIGHT_GRAY); break; case DocumentType.COL: diff --git a/src/client/views/animationtimeline/Timeline.tsx b/src/client/views/animationtimeline/Timeline.tsx index 66afad0ac..c7e62c15d 100644 --- a/src/client/views/animationtimeline/Timeline.tsx +++ b/src/client/views/animationtimeline/Timeline.tsx @@ -76,7 +76,7 @@ export class Timeline extends React.Component<FieldViewProps> { */ @computed private get children(): Doc[] { - const annotatedDoc = [DocumentType.IMG, DocumentType.VID, DocumentType.PDF].includes(StrCast(this.props.Document.type) as any); + const annotatedDoc = [DocumentType.IMG, DocumentType.VID, DocumentType.PDF, DocumentType.MAP].includes(StrCast(this.props.Document.type) as any); if (annotatedDoc) { return DocListCast(this.props.Document[Doc.LayoutFieldKey(this.props.Document) + "-annotations"]); } diff --git a/src/client/views/collections/CollectionMapView.tsx b/src/client/views/collections/CollectionMapView.tsx deleted file mode 100644 index 2d7569d45..000000000 --- a/src/client/views/collections/CollectionMapView.tsx +++ /dev/null @@ -1,269 +0,0 @@ -import { GoogleApiWrapper, IMapProps, Map as GeoMap, Marker } from "google-maps-react"; -import { action, computed, Lambda, runInAction } from "mobx"; -import { observer } from "mobx-react"; -import { Doc, DocListCast, Field, FieldResult, Opt } from "../../../fields/Doc"; -import { documentSchema } from "../../../fields/documentSchemas"; -import { Id } from "../../../fields/FieldSymbols"; -import { makeInterface } from "../../../fields/Schema"; -import { Cast, NumCast, ScriptCast, StrCast } from "../../../fields/Types"; -import { LinkManager } from "../../util/LinkManager"; -import { undoBatch, UndoManager } from "../../util/UndoManager"; -import "./CollectionMapView.scss"; -import { CollectionSubView } from "./CollectionSubView"; -import React = require("react"); -import requestPromise = require("request-promise"); - -type MapSchema = makeInterface<[typeof documentSchema]>; -const MapSchema = makeInterface(documentSchema); - -export type LocationData = google.maps.LatLngLiteral & { - address?: string - resolvedAddress?: string; - zoom?: number; -}; - -interface DocLatLng { - lat: FieldResult<Field>; - lng: FieldResult<Field>; -} - -// Nowhere, Oklahoma -const defaultLocation = { lat: 35.1592238, lng: -98.444512, zoom: 15 }; -const noResults = "ZERO_RESULTS"; - -const query = async (data: string | google.maps.LatLngLiteral) => { - const contents = typeof data === "string" ? `address=${data.replace(/\s+/g, "+")}` : `latlng=${data.lat},${data.lng}`; - const target = `https://maps.googleapis.com/maps/api/geocode/json?${contents}&key=${process.env.GOOGLE_MAPS_GEO}`; - try { - return JSON.parse(await requestPromise.get(target)); - } catch { - return undefined; - } -}; - -@observer -export class CollectionMapView extends CollectionSubView<MapSchema, Partial<IMapProps> & { google: any }>(MapSchema) { - - private _cancelAddrReq = new Map<string, boolean>(); - private _cancelLocReq = new Map<string, boolean>(); - private _initialLookupPending = new Map<string, boolean>(); - private responders: { locationDisposer: Lambda, addressDisposer: Lambda }[] = []; - - /** - * Note that all the uses of runInAction below are not included - * as a way to update observables (documents handle this already - * in their property setters), but rather to create a single bulk - * update and thus prevent uneeded invocations of the location- - * and address–updating reactions. - */ - - private getLocation = (doc: Opt<Doc>, fieldKey: string, returnDefault: boolean = true): Opt<LocationData> => { - if (doc) { - const titleLoc = StrCast(doc.title).startsWith("@") ? StrCast(doc.title).substring(1) : undefined; - const lat = Cast(doc[`${fieldKey}-lat`], "number", null) || (Cast(doc[`${fieldKey}-lat`], "string", null) && Number(Cast(doc[`${fieldKey}-lat`], "string", null))) || undefined; - const lng = Cast(doc[`${fieldKey}-lng`], "number", null) || (Cast(doc[`${fieldKey}-lng`], "string", null) && Number(Cast(doc[`${fieldKey}-lng`], "string", null))) || undefined; - const zoom = Cast(doc[`${fieldKey}-zoom`], "number", null) || (Cast(doc[`${fieldKey}-zoom`], "string", null) && Number(Cast(doc[`${fieldKey}-zoom`], "string", null))) || undefined; - const address = titleLoc || StrCast(doc[`${fieldKey}-address`], StrCast(doc.title).replace(/^-/, "")); - if (titleLoc || (address && (lat === undefined || lng === undefined))) { - const id = doc[Id]; - if (!this._initialLookupPending.get(id)) { - this._initialLookupPending.set(id, true); - setTimeout(() => { - titleLoc && Doc.SetInPlace(doc, `${fieldKey}-address`, titleLoc, true); - this.respondToAddressChange(doc, fieldKey, address).then(() => this._initialLookupPending.delete(id)); - }); - } - } - return (lat === undefined || lng === undefined) ? (returnDefault ? defaultLocation : undefined) : { lat, lng, zoom }; - } - return undefined; - } - - private markerClick = async (layout: Doc, { lat, lng, zoom }: LocationData) => { - const batch = UndoManager.StartBatch("marker click"); - const { fieldKey } = this.props; - runInAction(() => { - this.layoutDoc[`${fieldKey}-mapCenter-lat`] = lat; - this.layoutDoc[`${fieldKey}-mapCenter-lng`] = lng; - zoom && (this.layoutDoc[`${fieldKey}-mapCenter-zoom`] = zoom); - }); - if (layout.isLinkButton && DocListCast(layout.links).length) { - await LinkManager.traverseLink(undefined, layout, (doc: Doc, where: string, finished?: () => void) => { - this.props.addDocTab(doc, where); - finished?.(); - }, false, this.props.ContainingCollectionDoc, batch.end, undefined); - } else { - ScriptCast(layout.onClick)?.script.run({ this: layout, self: Cast(layout.rootDocument, Doc, null) || layout }); - batch.end(); - } - } - - private renderMarkerIcon = (layout: Doc) => { - const { Document } = this.props; - const fieldKey = Doc.LayoutFieldKey(layout); - const iconUrl = StrCast(layout.mapIconUrl, StrCast(Document.mapIconUrl)); - if (iconUrl) { - const iconWidth = NumCast(layout[`${fieldKey}-iconWidth`], 45); - const iconHeight = NumCast(layout[`${fieldKey}-iconHeight`], 45); - const iconSize = new google.maps.Size(iconWidth, iconHeight); - return { - size: iconSize, - scaledSize: iconSize, - url: iconUrl - }; - } - } - - private renderMarker = (layout: Doc, fieldKey?: string) => { - const location = this.getLocation(layout, fieldKey || Doc.LayoutFieldKey(layout)); - return !location ? (null) : - <Marker - key={layout[Id]} - label={StrCast(layout[`${this.props.fieldKey}-address`])} - position={location} - onClick={() => this.markerClick(layout, location)} - icon={this.renderMarkerIcon(layout)} - />; - } - - private respondToAddressChange = async (doc: Doc, fieldKey: string, newAddress: string, oldAddress?: string) => { - if (newAddress === oldAddress) { - return false; - } - const response = await query(newAddress); - const id = doc[Id]; - if (!response || response.status === noResults) { - this._cancelAddrReq.set(id, true); - doc[`${fieldKey}-address`] = oldAddress; - return false; - } - const { geometry, formatted_address } = response.results[0]; - const { lat, lng } = geometry.location; - runInAction(() => { - if (doc[`${fieldKey}-lat`] !== lat || doc[`${fieldKey}-lng`] !== lng) { - this._cancelLocReq.set(id, true); - Doc.SetInPlace(doc, `${fieldKey}-lat`, lat, true); - Doc.SetInPlace(doc, `${fieldKey}-lng`, lng, true); - } - if (formatted_address !== newAddress) { - this._cancelAddrReq.set(id, true); - Doc.SetInPlace(doc, `${fieldKey}-address`, formatted_address, true); - } - }); - return true; - } - - private respondToLocationChange = async (doc: Doc, fieldKey: string, newLatLng: DocLatLng, oldLatLng: Opt<DocLatLng>) => { - if (newLatLng === oldLatLng) { - return false; - } - const response = await query({ lat: NumCast(newLatLng.lat), lng: NumCast(newLatLng.lng) }); - const id = doc[Id]; - if (!response || response.status === noResults) { - this._cancelLocReq.set(id, true); - runInAction(() => { - doc[`${fieldKey}-lat`] = oldLatLng?.lat; - doc[`${fieldKey}-lng`] = oldLatLng?.lng; - }); - return false; - } - const { formatted_address } = response.results[0]; - if (formatted_address !== doc[`${fieldKey}-address`]) { - this._cancelAddrReq.set(doc[Id], true); - Doc.SetInPlace(doc, `${fieldKey}-address`, formatted_address, true); - } - return true; - } - - @computed get reactiveContents() { - this.responders.forEach(({ locationDisposer, addressDisposer }) => { - locationDisposer(); - addressDisposer(); - }); - this.responders = []; - return this.childLayoutPairs.map(({ layout }) => { - const fieldKey = Doc.LayoutFieldKey(layout); - const id = layout[Id]; - this.responders.push({ - locationDisposer: computed(() => ({ lat: layout[`${fieldKey}-lat`], lng: layout[`${fieldKey}-lng`] })) - .observe(({ oldValue, newValue }) => { - if (this._cancelLocReq.get(id)) { - this._cancelLocReq.set(id, false); - } else if (newValue.lat !== undefined && newValue.lng !== undefined) { - this.respondToLocationChange(layout, fieldKey, newValue, oldValue); - } - }), - addressDisposer: computed(() => Cast(layout[`${fieldKey}-address`], "string", null)) - .observe(({ oldValue, newValue }) => { - if (this._cancelAddrReq.get(id)) { - this._cancelAddrReq.set(id, false); - } else if (newValue?.length) { - this.respondToAddressChange(layout, fieldKey, newValue, oldValue); - } - }) - }); - return this.renderMarker(layout); - }); - } - - render() { - const { childLayoutPairs } = this; - const { Document, fieldKey, isContentActive: active, google } = this.props; - const mapLoc = this.getLocation(this.rootDoc, `${fieldKey}-mapCenter`, false); - let center = mapLoc; - if (center === undefined) { - const childLocations = childLayoutPairs.map(({ layout }) => this.getLocation(layout, Doc.LayoutFieldKey(layout), false)); - center = childLocations.find(location => location) || defaultLocation; - } - return <div className="collectionMapView" ref={this.createDashEventsTarget}> - <div className={"collectionMapView-contents"} - style={{ pointerEvents: active() ? undefined : "none" }} - onWheel={e => e.stopPropagation()} - onPointerDown={e => (e.button === 0 && !e.ctrlKey) && e.stopPropagation()} > - <GeoMap - google={google} - zoom={center.zoom || 10} - initialCenter={center} - center={center} - onIdle={(_props?: IMapProps, map?: google.maps.Map) => { - if (this.layoutDoc._lockedTransform) { - // reset zoom (ideally, we could probably can tell the map to disallow zooming somehow instead) - map?.setZoom(center?.zoom || 10); - map?.setCenter({ lat: center?.lat!, lng: center?.lng! }); - } else { - const zoom = map?.getZoom(); - (center?.zoom !== zoom) && undoBatch(action(() => { - Document[`${fieldKey}-mapCenter-zoom`] = zoom; - }))(); - } - }} - onDragend={(_props?: IMapProps, map?: google.maps.Map) => { - if (this.layoutDoc._lockedTransform) { - // reset the drag (ideally, we could probably can tell the map to disallow dragging somehow instead) - map?.setCenter({ lat: center?.lat!, lng: center?.lng! }); - } else { - undoBatch(action(({ lat, lng }) => { - Document[`${fieldKey}-mapCenter-lat`] = lat(); - Document[`${fieldKey}-mapCenter-lng`] = lng(); - }))(map?.getCenter()); - } - }} - > - {this.reactiveContents} - {mapLoc && StrCast(this.rootDoc[`${fieldKey}-mapCenter-address`]) ? this.renderMarker(this.rootDoc, `${fieldKey}-mapCenter`) : undefined} - </GeoMap> - </div> - </div>; - } - -} - -export default GoogleApiWrapper({ - apiKey: process.env.GOOGLE_MAPS!, - LoadingContainer: () => { - console.log(process.env.GOOGLE_MAPS); - return <div className={"loadingWrapper"}> - <img className={"loadingGif"} src={"/assets/loading.gif"} /> - </div>; - } -})(CollectionMapView) as any;
\ No newline at end of file diff --git a/src/client/views/collections/CollectionMenu.tsx b/src/client/views/collections/CollectionMenu.tsx index 2c2d5dc75..131f5ba46 100644 --- a/src/client/views/collections/CollectionMenu.tsx +++ b/src/client/views/collections/CollectionMenu.tsx @@ -346,6 +346,7 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewMenu case DocumentType.WEB: return (<CollectionFreeFormViewChrome key="collchrome" {...this.props} isOverlay={false} isDoc={true} />); case DocumentType.VID: return (<CollectionFreeFormViewChrome key="collchrome" {...this.props} isOverlay={false} isDoc={true} />); case DocumentType.RTF: return (<CollectionFreeFormViewChrome key="collchrome" {...this.props} isOverlay={this.props.type === CollectionViewType.Invalid} isDoc={true} />); + case DocumentType.MAP: return (<CollectionFreeFormViewChrome key="collchrome" {...this.props} isOverlay={false} isDoc={true} />); } } @@ -499,7 +500,7 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewMenu const scroll = targetDoc._scrollTop; activeDoc.presPinView = true; activeDoc.presPinViewScroll = scroll; - } else if ((targetDoc.type === DocumentType.COL && targetDoc._viewType === CollectionViewType.Freeform) || targetDoc.type === DocumentType.IMG) { + } else if ((targetDoc.type === DocumentType.COL && targetDoc._viewType === CollectionViewType.Freeform) || targetDoc.type === DocumentType.IMG || targetDoc.type === DocumentType.MAP) { const x = targetDoc._panX; const y = targetDoc._panY; const scale = targetDoc._viewScale; diff --git a/src/client/views/collections/CollectionStackingView.tsx b/src/client/views/collections/CollectionStackingView.tsx index 648ff5087..bffaf86b1 100644 --- a/src/client/views/collections/CollectionStackingView.tsx +++ b/src/client/views/collections/CollectionStackingView.tsx @@ -226,7 +226,7 @@ export class CollectionStackingView extends CollectionSubView<StackingDocument, layerProvider={this.props.layerProvider} docViewPath={this.props.docViewPath} fitWidth={this.props.childFitWidth} - isContentActive={returnFalse} + isContentActive={emptyFunction} isDocumentActive={this.isContentActive} LayoutTemplate={this.props.childLayoutTemplate} LayoutTemplateString={this.props.childLayoutString} diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index 5dffc65fc..fc1bcb8b9 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -22,7 +22,6 @@ import ReactLoading from 'react-loading'; export interface SubCollectionViewProps extends CollectionViewProps { CollectionView: Opt<CollectionView>; - SetSubView?: (subView: any) => void; isAnyChildContentActive: () => boolean; } @@ -49,10 +48,6 @@ export function CollectionSubView<T, X>(schemaCtor: (doc: Doc) => T, moreProps?: this.createDashEventsTarget(ele); } - componentDidMount() { - this.props.SetSubView?.(this); - } - componentWillUnmount() { this.gestureDisposer?.(); this._multiTouchDisposer?.(); @@ -220,7 +215,7 @@ export function CollectionSubView<T, X>(schemaCtor: (doc: Doc) => T, moreProps?: const movedDocs = docDragData.droppedDocuments.filter((d, i) => docDragData.draggedDocuments[i] === d); const addedDocs = docDragData.droppedDocuments.filter((d, i) => docDragData.draggedDocuments[i] !== d); if (movedDocs.length) { - const canAdd = this.props.Document._viewType === CollectionViewType.Pile || de.embedKey || !this.props.isAnnotationOverlay || + const canAdd = this.props.Document._viewType === CollectionViewType.Pile || de.embedKey || (!this.props.isAnnotationOverlay || this.props.Document.allowOverlayDrop) || Doc.AreProtosEqual(Cast(movedDocs[0].annotationOn, Doc, null), this.props.Document); added = docDragData.moveDocument(movedDocs, this.props.Document, canAdd ? this.addDocument : returnFalse); } else { diff --git a/src/client/views/collections/CollectionTreeView.scss b/src/client/views/collections/CollectionTreeView.scss index d370d21ab..b664d9d82 100644 --- a/src/client/views/collections/CollectionTreeView.scss +++ b/src/client/views/collections/CollectionTreeView.scss @@ -71,6 +71,15 @@ display: none; } +.collectionTreeView-titleBar { + display: inline-block; + width: 100%; + height: max-content; + .contentFittingDocumentView { + display: block; // makes titleBar take up full width of the treeView (flex doesn't for some reason) + } +} + .collectionTreeView-keyHeader:hover { background: #797777; cursor: pointer; diff --git a/src/client/views/collections/CollectionTreeView.tsx b/src/client/views/collections/CollectionTreeView.tsx index 3852987b9..ea077ea40 100644 --- a/src/client/views/collections/CollectionTreeView.tsx +++ b/src/client/views/collections/CollectionTreeView.tsx @@ -1,4 +1,3 @@ -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, computed, IReactionDisposer, observable, reaction } from "mobx"; import { observer } from "mobx-react"; import { DataSym, Doc, DocListCast, HeightSym, Opt, StrListCast, WidthSym } from '../../../fields/Doc'; @@ -8,13 +7,14 @@ import { Document, listSpec } from '../../../fields/Schema'; import { ScriptField } from '../../../fields/ScriptField'; import { BoolCast, Cast, NumCast, ScriptCast, StrCast } from '../../../fields/Types'; import { TraceMobx } from '../../../fields/util'; -import { returnEmptyDoclist, returnEmptyFilter, returnFalse, returnTrue, emptyFunction } from '../../../Utils'; +import { emptyFunction, OmitKeys, returnEmptyDoclist, returnEmptyFilter, returnFalse, returnTrue, returnOne } from '../../../Utils'; import { DocUtils } from '../../documents/Documents'; import { CurrentUserUtils } from '../../util/CurrentUserUtils'; import { DocumentManager } from '../../util/DocumentManager'; import { DragManager, dropActionType } from "../../util/DragManager"; import { SelectionManager } from '../../util/SelectionManager'; import { SnappingManager } from '../../util/SnappingManager'; +import { Transform } from '../../util/Transform'; import { undoBatch, UndoManager } from '../../util/UndoManager'; import { ContextMenu } from '../ContextMenu'; import { ContextMenuProps } from '../ContextMenuItem'; @@ -22,11 +22,11 @@ import { EditableView } from "../EditableView"; import { DocumentView } from '../nodes/DocumentView'; import { FormattedTextBox } from '../nodes/formattedText/FormattedTextBox'; import { StyleProp } from '../StyleProvider'; +import { CollectionFreeFormView } from './collectionFreeForm'; import { CollectionSubView } from "./CollectionSubView"; import "./CollectionTreeView.scss"; import { TreeView } from "./TreeView"; import React = require("react"); -import { Transform } from '../../util/Transform'; const _global = (window /* browser */ || global /* node */) as any; export type collectionTreeViewProps = { @@ -41,10 +41,14 @@ export type collectionTreeViewProps = { @observer export class CollectionTreeView extends CollectionSubView<Document, Partial<collectionTreeViewProps>>(Document) { - private treedropDisposer?: DragManager.DragDropDisposer; + private _treedropDisposer?: DragManager.DragDropDisposer; private _mainEle?: HTMLDivElement; + private _titleRef?: HTMLDivElement | HTMLInputElement | null; private _disposers: { [name: string]: IReactionDisposer } = {}; - MainEle = () => this._mainEle; + private _isDisposing = false; // notes that instance is in process of being disposed + private refList: Set<any> = new Set(); // list of tree view items to monitor for height changes + private observer: any; // observer for monitoring tree view items. + private static expandViewLabelSize = 20; @computed get doc() { return this.props.Document; } @computed get dataDoc() { return this.props.DataDoc || this.doc; } @@ -54,6 +58,10 @@ export class CollectionTreeView extends CollectionSubView<Document, Partial<coll @computed get fileSysMode() { return this.doc.treeViewType === "fileSystem"; } @computed get dashboardMode() { return this.doc === Doc.UserDoc().myDashboards; } + @observable _explainerHeight = 0; // height of the description of the tree view + + MainEle = () => this._mainEle; + // these should stay in synch with counterparts in DocComponent.ts ViewBoxAnnotatableComponent @observable _isAnyChildContentActive = false; whenChildContentsActiveChanged = action((isActive: boolean) => this.props.whenChildContentsActiveChanged(this._isAnyChildContentActive = isActive)); @@ -62,11 +70,10 @@ export class CollectionTreeView extends CollectionSubView<Document, Partial<coll this.props.isSelected(outsideReaction) || this._isAnyChildContentActive || this.props.rootSelected(outsideReaction)) ? true : false) - isDisposing = false; componentWillUnmount() { - this.isDisposing = true; + this._isDisposing = true; super.componentWillUnmount(); - this.treedropDisposer?.(); + this._treedropDisposer?.(); Object.values(this._disposers).forEach(disposer => disposer?.()); } @@ -76,13 +83,13 @@ export class CollectionTreeView extends CollectionSubView<Document, Partial<coll { fireImmediately: true }); } - refList: Set<any> = new Set(); - observer: any; computeHeight = () => { - if (this.isDisposing) return; - const bodyHeight = Array.from(this.refList).reduce((p, r) => p + Number(getComputedStyle(r).height.replace("px", "")), this.paddingTop() + this.paddingBot()); - this.layoutDoc._autoHeightMargins = bodyHeight; - this.props.setHeight(this.documentTitleHeight() + bodyHeight); + if (!this._isDisposing) { + const titleHeight = !this._titleRef ? this.marginTop() : Number(getComputedStyle(this._titleRef).height.replace("px", "")); + const bodyHeight = Array.from(this.refList).reduce((p, r) => p + Number(getComputedStyle(r).height.replace("px", "")), this.marginBot()); + this.layoutDoc._autoHeightMargins = bodyHeight; + this.props.setHeight(bodyHeight + titleHeight); + } } unobserveHeight = (ref: any) => { this.refList.delete(ref); @@ -101,8 +108,8 @@ export class CollectionTreeView extends CollectionSubView<Document, Partial<coll } } protected createTreeDropTarget = (ele: HTMLDivElement) => { - this.treedropDisposer?.(); - if (this._mainEle = ele) this.treedropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.doc, this.onInternalPreDrop.bind(this)); + this._treedropDisposer?.(); + if (this._mainEle = ele) this._treedropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.doc, this.onInternalPreDrop.bind(this)); } protected onInternalPreDrop = (e: Event, de: DragManager.DropEvent, targetAction: dropActionType) => { @@ -165,60 +172,44 @@ export class CollectionTreeView extends CollectionSubView<Document, Partial<coll this.addDoc(TreeView.makeTextBullet(), childDocs.length ? childDocs[0] : undefined, true); } - editableTitle = (childDocs: Doc[]) => { - return !this.dataDoc ? (null) : - <EditableView - contents={this.dataDoc.title} - display={"block"} - maxHeight={72} - height={"auto"} - GetValue={() => StrCast(this.dataDoc.title)} - SetValue={undoBatch((value: string, shift: boolean, enter: boolean) => { - if (enter && this.props.Document.treeViewType === "outline") this.makeTextCollection(childDocs); - this.dataDoc.title = value; - return true; - })} />; + get editableTitle() { + return <EditableView + contents={this.dataDoc.title} + display={"block"} + maxHeight={72} + height={"auto"} + GetValue={() => StrCast(this.dataDoc.title)} + SetValue={undoBatch((value: string, shift: boolean, enter: boolean) => { + if (enter && this.props.Document.treeViewType === "outline") this.makeTextCollection(this.treeChildren); + this.dataDoc.title = value; + return true; + })} />; } - documentTitle = (childDocs: Doc[]) => { - return <div style={{ display: "inline-block", width: "100%", height: this.documentTitleHeight() }} key={this.doc[Id]} - onKeyDown={e => { - e.stopPropagation(); - e.key === "Enter" && this.makeTextCollection(childDocs); - }}> - <DocumentView - Document={this.doc} - DataDoc={undefined} - LayoutTemplateString={FormattedTextBox.LayoutString("text")} - renderDepth={this.props.renderDepth + 1} - isContentActive={this.isContentActive} - isDocumentActive={this.isContentActive} - rootSelected={returnTrue} - docViewPath={this.props.docViewPath} - styleProvider={this.props.styleProvider} - layerProvider={this.props.layerProvider} - PanelWidth={this.documentTitleWidth} - PanelHeight={this.documentTitleHeight} - NativeWidth={this.documentTitleWidth} - NativeHeight={this.documentTitleHeight} - focus={this.props.focus} - treeViewDoc={this.props.Document} - ScreenToLocalTransform={this.titleTransform} - docFilters={returnEmptyFilter} - docRangeFilters={returnEmptyFilter} - searchFilterDocs={returnEmptyDoclist} - ContainingCollectionDoc={this.doc} - ContainingCollectionView={this.props.CollectionView} - addDocument={this.props.addDocument} - moveDocument={returnFalse} - removeDocument={returnFalse} - whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} - addDocTab={this.props.addDocTab} - pinToPres={this.props.pinToPres} - bringToFront={returnFalse} - /> - </div>; + get documentTitle() { + return <FormattedTextBox + {...this.props} + fieldKey={"text"} + renderDepth={this.props.renderDepth + 1} + isContentActive={this.isContentActive} + isDocumentActive={this.isContentActive} + rootSelected={returnTrue} + forceAutoHeight={true} // needed to make the title resize even if the rest of the tree view is not autoHeight + PanelWidth={this.documentTitleWidth} + PanelHeight={this.documentTitleHeight} + scaling={returnOne} + docFilters={returnEmptyFilter} + docRangeFilters={returnEmptyFilter} + searchFilterDocs={returnEmptyDoclist} + ContainingCollectionDoc={this.doc} + ContainingCollectionView={this.props.CollectionView} + addDocument={returnFalse} + moveDocument={returnFalse} + removeDocument={returnFalse} + whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} + bringToFront={returnFalse} + />; } childContextMenuItems = () => { const customScripts = Cast(this.doc.childContextMenuScripts, listSpec(ScriptField), []); @@ -263,21 +254,31 @@ export class CollectionTreeView extends CollectionSubView<Document, Partial<coll ); } @computed get titleBar() { - const hideTitle = this.props.treeViewHideTitle || this.doc.treeViewHideTitle; - return hideTitle ? (null) : (this.outlineMode ? this.documentTitle : this.editableTitle)(this.treeChildren); + return this.dataDoc === null ? (null) : + <div className="collectionTreeView-titleBar" key={this.doc[Id]} + style={!this.outlineMode ? { paddingLeft: this.marginX(), paddingTop: this.marginTop() } : {}} + ref={r => this._titleRef = r} + onKeyDown={e => { + if (this.outlineMode) { + e.stopPropagation(); + e.key === "Enter" && this.makeTextCollection(this.treeChildren); + } + }}> + {this.outlineMode ? this.documentTitle : this.editableTitle} + </div>; + } + + @computed get noviceExplainer() { + return !Doc.UserDoc().noviceMode || !this.rootDoc.explainer ? (null) : + <div className="documentExplanation"> {this.rootDoc.explainer} </div>; } return35 = () => 35; @computed get buttonMenu() { - const menuDoc: Doc = Cast(this.rootDoc.buttonMenuDoc, Doc, null); + const menuDoc = Cast(this.rootDoc.buttonMenuDoc, Doc, null); // To create a multibutton menu add a CollectionLinearView - if (menuDoc) { - - const width: number = NumCast(menuDoc._width, 30); - const height: number = NumCast(menuDoc._height, 30); - console.log(menuDoc.title, width, height); - return (<div className="buttonMenu-docBtn" - style={{ width: width, height: height }}> + return !menuDoc ? null : + (<div className="buttonMenu-docBtn" style={{ width: NumCast(menuDoc._width, 30), height: NumCast(menuDoc._height, 30) }}> <DocumentView Document={menuDoc} DataDoc={menuDoc} @@ -306,11 +307,8 @@ export class CollectionTreeView extends CollectionSubView<Document, Partial<coll ContainingCollectionDoc={undefined} /> </div>); - } } - @observable _explainerHeight: number = 0; - @computed get nativeWidth() { return Doc.NativeWidth(this.Document, undefined, true); } @computed get nativeHeight() { return Doc.NativeHeight(this.Document, undefined, true); } @@ -321,47 +319,81 @@ export class CollectionTreeView extends CollectionSubView<Document, Partial<coll const wscale = nw ? this.props.PanelWidth() / nw : 1; return wscale < hscale ? wscale : hscale; } - paddingX = () => NumCast(this.doc._xPadding, 15); - paddingTop = () => NumCast(this.doc._yPadding, 20); - paddingBot = () => NumCast(this.doc._yPadding, 20); + marginX = () => NumCast(this.doc._xMargin); + marginTop = () => NumCast(this.doc._yMargin); + marginBot = () => NumCast(this.doc._yMargin); documentTitleWidth = () => Math.min(this.layoutDoc?.[WidthSym](), this.panelWidth()); documentTitleHeight = () => (this.layoutDoc?.[HeightSym]() || 0) - NumCast(this.layoutDoc.autoHeightMargins); - titleTransform = () => this.props.ScreenToLocalTransform().translate(-NumCast(this.doc._xPadding, 10), -NumCast(this.doc._yPadding, 20)); truncateTitleWidth = () => this.treeViewtruncateTitleWidth; onChildClick = () => this.props.onChildClick?.() || ScriptCast(this.doc.onChildClick); - panelWidth = () => (this.props.PanelWidth() - 2 * this.paddingX()) * (this.props.scaling?.() || 1); - render() { - TraceMobx(); + panelWidth = () => Math.max(0, this.props.PanelWidth() - this.marginX() - CollectionTreeView.expandViewLabelSize) * (this.props.scaling?.() || 1); + + addAnnotationDocument = (doc: Doc | Doc[]) => this.props.CollectionView?.addDocument(doc, `${this.props.fieldKey}-annotations`) || false; + remAnnotationDocument = (doc: Doc | Doc[]) => this.props.CollectionView?.removeDocument(doc, `${this.props.fieldKey}-annotations`) || false; + moveAnnotationDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (document: Doc | Doc[], annotationKey?: string) => boolean) => + this.props.CollectionView?.moveDocument(doc, targetCollection, addDocument, `${this.props.fieldKey}-annotations`) || false + + contentFunc = () => { const background = () => this.props.styleProvider?.(this.doc, this.props, StyleProp.BackgroundColor); const pointerEvents = () => !this.props.isContentActive() && !SnappingManager.GetIsDragging() ? "none" : undefined; - const buttonMenu = this.rootDoc.buttonMenu; - const noviceExplainer = this.rootDoc.explainer; - - return !(this.doc instanceof Doc) || !this.treeChildren ? (null) : - <> - {this.titleBar} + const titleBar = this.props.treeViewHideTitle || this.doc.treeViewHideTitle ? (null) : this.titleBar; + return [ + <div className="collectionTreeView-contents" key="tree" style={{ + ...(!titleBar ? { paddingLeft: this.marginX(), paddingTop: this.marginTop() } : {}), + overflow: "auto", + height: this.layoutDoc._autoHeight ? "max-content" : "100%" + }} > + {titleBar} <div className="collectionTreeView-container" - style={this.outlineMode ? { transform: `scale(${this.contentScaling})`, width: `calc(${100 / this.contentScaling}%)` } : {}} + style={{ + transform: this.outlineMode ? `scale(${this.contentScaling})` : "", + paddingLeft: `${this.marginX()}px`, + height: "max-content", + width: this.outlineMode ? `calc(${100 / this.contentScaling}%)` : "" + }} onContextMenu={this.onContextMenu}> - {buttonMenu || noviceExplainer ? <div className="documentButtonMenu" ref={action((r: HTMLDivElement) => r && (this._explainerHeight = r.getBoundingClientRect().height))}> - {buttonMenu ? this.buttonMenu : null} - {Doc.UserDoc().noviceMode && noviceExplainer ? - <div className="documentExplanation"> - {noviceExplainer} - </div> - : null - } - </div> : null} + {!this.buttonMenu && !this.noviceExplainer ? (null) : + <div className="documentButtonMenu" ref={action((r: HTMLDivElement) => r && (this._explainerHeight = r.getBoundingClientRect().height))}> + {this.buttonMenu} + {this.noviceExplainer} + </div> + } <div className="collectionTreeView-dropTarget" - style={{ background: background(), height: `calc(100% - ${this._explainerHeight}px)`, paddingLeft: `${this.paddingX()}px`, paddingRight: `${this.paddingX()}px`, paddingBottom: `${this.paddingBot()}px`, paddingTop: `${this.paddingTop()}px`, pointerEvents: pointerEvents() }} + style={{ + background: background(), + height: `calc(100% - ${this._explainerHeight}px)`, + pointerEvents: pointerEvents() + }} onWheel={e => e.stopPropagation()} onDrop={this.onTreeDrop} - ref={this.createTreeDropTarget}> + ref={r => !this.doc.treeViewHasOverlay && r && this.createTreeDropTarget(r)}> <ul className={`no-indent${this.outlineMode ? "-outline" : ""}`} > {this.treeViewElements} </ul> </div > </div> - </>; + </div> + ]; + } + render() { + TraceMobx(); + + return !(this.doc instanceof Doc) || !this.treeChildren ? (null) : + this.doc.treeViewHasOverlay ? + <CollectionFreeFormView {...OmitKeys(this.props, ["NativeWidth", "NativeHeight", "setContentView"]).omit} + isAnnotationOverlay={true} + isAnnotationOverlayScrollable={true} + childDocumentsActive={this.props.isDocumentActive} + fieldKey={this.props.fieldKey + "-annotations"} + dropAction={"move"} + select={emptyFunction} + addDocument={this.addAnnotationDocument} + removeDocument={this.remAnnotationDocument} + moveDocument={this.moveAnnotationDocument} + bringToFront={emptyFunction} + renderDepth={this.props.renderDepth + 1} > + {this.contentFunc} + </CollectionFreeFormView> : + this.contentFunc(); } }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index 38e027fb3..3ad9333de 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -25,7 +25,7 @@ import { CollectionDockingView } from "./CollectionDockingView"; import { CollectionFreeFormView } from './collectionFreeForm/CollectionFreeFormView'; import { CollectionGridView } from './collectionGrid/CollectionGridView'; import { CollectionLinearView } from './collectionLinear'; -import CollectionMapView from './CollectionMapView'; +import CollectionMapView from './MapView/CollectionMapView'; import { CollectionMulticolumnView } from './collectionMulticolumn/CollectionMulticolumnView'; import { CollectionMultirowView } from './collectionMulticolumn/CollectionMultirowView'; import { CollectionPileView } from './CollectionPileView'; @@ -63,6 +63,7 @@ export enum CollectionViewType { } export interface CollectionViewProps extends FieldViewProps { isAnnotationOverlay?: boolean; // is the collection an annotation overlay (eg an overlay on an image/video/etc) + isAnnotationOverlayScrollable?: boolean; // whether the annotation overlay can be vertically scrolled (just for tree views, currently) layoutEngine?: () => string; setPreviewCursor?: (func: (x: number, y: number, drag: boolean, hide: boolean) => void) => void; @@ -125,8 +126,9 @@ export class CollectionView extends ViewBoxAnnotatableComponent<ViewBoxAnnotatab } screenToLocalTransform = () => this.props.renderDepth ? this.props.ScreenToLocalTransform() : this.props.ScreenToLocalTransform().scale(this.props.PanelWidth() / this.bodyPanelWidth()); - private SubView = (type: CollectionViewType, props: SubCollectionViewProps) => { + private renderSubView = (type: CollectionViewType | undefined, props: SubCollectionViewProps) => { TraceMobx(); + if (type === undefined) return null; switch (type) { default: case CollectionViewType.Freeform: return <CollectionFreeFormView key="collview" {...props} />; @@ -246,17 +248,13 @@ export class CollectionView extends ViewBoxAnnotatableComponent<ViewBoxAnnotatab childLayoutTemplate = () => this.props.childLayoutTemplate?.() || Cast(this.rootDoc.childLayoutTemplate, Doc, null); @computed get childLayoutString() { return StrCast(this.rootDoc.childLayoutString); } - - @observable _subView: any = undefined; - isContentActive = (outsideReaction?: boolean) => { - return this.props.isContentActive() ? true : false; + return this.props.isContentActive(); } render() { TraceMobx(); const props: SubCollectionViewProps = { ...this.props, - SetSubView: action((subView: any) => this._subView = subView), addDocument: this.addDocument, moveDocument: this.moveDocument, removeDocument: this.removeDocument, @@ -273,7 +271,7 @@ export class CollectionView extends ViewBoxAnnotatableComponent<ViewBoxAnnotatab return (<div className={"collectionView"} onContextMenu={this.onContextMenu} style={{ pointerEvents: this.props.layerProvider?.(this.rootDoc) === false ? "none" : undefined }}> {this.showIsTagged()} - {this.collectionViewType !== undefined ? this.SubView(this.collectionViewType, props) : (null)} + {this.renderSubView(this.collectionViewType, props)} </div>); } } diff --git a/src/client/views/collections/CollectionMapView.scss b/src/client/views/collections/MapView/CollectionMapView.scss index 870b7fda8..0dc226c04 100644 --- a/src/client/views/collections/CollectionMapView.scss +++ b/src/client/views/collections/MapView/CollectionMapView.scss @@ -1,13 +1,34 @@ .collectionMapView { width: 100%; height: 100%; + overflow: hidden; .collectionMapView-contents { width: 100%; height: 100%; + overflow: hidden; > div { position: unset !important; // when the sidebar filter flys out, this prevents the map from extending outside the document box } + + .map-wrapper { + .searchbox { + box-sizing: border-box; + border: 1px solid transparent; + width: 300px; + height: 32px; + padding: 0 12px; + border-radius: 3px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); + font-size: 14px; + outline: none; + text-overflow: ellipses; + position: absolute; + left: 50%; + margin-left: -120px; + margin-top: 5px; + } + } } } @@ -27,4 +48,4 @@ width: 50px; height: 50px; } -}
\ No newline at end of file +} diff --git a/src/client/views/collections/MapView/CollectionMapView.tsx b/src/client/views/collections/MapView/CollectionMapView.tsx new file mode 100644 index 000000000..885f51bfb --- /dev/null +++ b/src/client/views/collections/MapView/CollectionMapView.tsx @@ -0,0 +1,272 @@ +import { GoogleMap, Marker, InfoWindow, InfoBox, useJsApiLoader, LoadScript, GoogleMapProps, StandaloneSearchBox, Autocomplete } from '@react-google-maps/api'; +import { observable, action, computed, Lambda, runInAction, IReactionDisposer } from "mobx"; +import { observer } from "mobx-react"; +import { Doc, DocListCast, Field, FieldResult, Opt } from "../../../../fields/Doc"; +import { documentSchema } from "../../../../fields/documentSchemas"; +import { Id } from "../../../../fields/FieldSymbols"; +import { makeInterface } from "../../../../fields/Schema"; +import { Cast, NumCast, ScriptCast, StrCast } from "../../../../fields/Types"; +import { LinkManager } from "../../../util/LinkManager"; +import { undoBatch, UndoManager } from "../../../util/UndoManager"; +import "./CollectionMapView.scss"; +import { CollectionSubView } from "../CollectionSubView"; +import React = require("react"); +import requestPromise = require("request-promise"); +import ReactDOM from 'react-dom'; +import { DragManager } from '../../../util/DragManager'; +import { MapMarker } from '../../nodes/MapBox/MapMarker'; + + +/** + * Idea behind storing a marker: + * 1. on the map api, have a button "add marker" that adds the marker on the map & store the marker as a node in the collection + * (but don't render the marker in the collection itself) + * 2. each marker, as a node, has the same feature as all other nodes for linking (the same way one could form a link between a node inside a child collection + * and a node outside the child collection) + * + * /util/LinkManager.ts -- link relations + * + */ + +type MapSchema = makeInterface<[typeof documentSchema]>; +const MapSchema = makeInterface(documentSchema); + +export type Coordinates = { + lat: number, + lng: number, +} + +export type LocationData = { + id: string; + pos: Coordinates; +}; + +const mapContainerStyle = { + height: '100%', +}; + +const defaultCenter = { + lat: 38.685, + lng: -115.234, +}; + +const mapOptions = { + fullscreenControl: false, +} + +// const drawingManager = new google.maps.drawing.DrawingManager({ +// drawingControl: true, +// drawingControlOptions: { +// position: google.maps.ControlPosition.TOP_RIGHT, +// drawingModes: [ +// google.maps.drawing.OverlayType.MARKER, +// // currently we are not supporting the following drawing mode on map, a thought for future development +// // google.maps.drawing.OverlayType.CIRCLE, +// // google.maps.drawing.OverlayType.POLYLINE, +// ], +// }, +// }); + +const options = { + fields: ["formatted_address", "geometry", "name"], // note: level of details is charged by item per retrieval, not recommended to return all fields + strictBounds: false, + types: ["establishment"], // type pf places, subject of change according to user need +} as google.maps.places.AutocompleteOptions; + +@observer +export default class CollectionMapView extends CollectionSubView<MapSchema, Partial<GoogleMapProps>>(MapSchema) { + private _dropDisposer?: DragManager.DragDropDisposer; + private _disposers: { [name: string]: IReactionDisposer } = {}; + + + @observable private _map = null as unknown as google.maps.Map; + @observable private selectedPlace: LocationData | undefined; + @observable private markerMap: { [id: string]: google.maps.Marker } = {}; + @observable private center = defaultCenter; + @observable private zoom = 2.5; + @observable private infoWindowOpen = false; + @observable private bounds = new window.google.maps.LatLngBounds(); + @observable private inputRef = React.createRef<HTMLInputElement>(); + @observable private searchMarkers: google.maps.Marker[] = []; + @observable private searchBox = new window.google.maps.places.Autocomplete(this.inputRef.current!, options); + + @observable private myPlaces: LocationData[] = [ + { id: "id1", pos: { lat: 39.09366509575983, lng: -94.58751660204751 } }, + { id: "id2", pos: { lat: 41.82399, lng: -71.41283 } }, + { id: "id3", pos: { lat: 47.606214, lng: -122.33207 } }, + ]; + + @action + private setSearchBox = (searchBox: any) => { + this.searchBox = searchBox; + } + + // iterate myPlaces to size, center, and zoom map to contain all markers + private fitBounds = (map: google.maps.Map) => { + console.log('map bound is:' + this.bounds); + this.myPlaces ? this.myPlaces.map(place => { + this.bounds.extend(place.pos!); + }) : null; + map.fitBounds(this.bounds); + } + + // store a reference to google map instance; fit map bounds to contain all markers + @action + private loadHandler = (map: google.maps.Map) => { + this._map = map; + drawingManager.setMap(map); + this.fitBounds(map); + } + + @action + private markerClickHandler = (e: MouseEvent, place: any) => { + // set which place was clicked + this.selectedPlace = place; + + console.log(this.selectedPlace); + + // used so clicking a second marker works + if (this.infoWindowOpen) { + this.infoWindowOpen = false; + console.log("closeinfowindow") + } + this.infoWindowOpen = true; + console.log("open infowindow") + } + + @action + private handleInfoWindowClose = () => { + if (this.infoWindowOpen) { + this.infoWindowOpen = false; + } + this.infoWindowOpen = false; + this.selectedPlace = undefined; + } + + @action + private handlePlaceChanged = () => { + console.log(this.searchBox); + const place = this.searchBox.getPlace(); + + if (!place.geometry || !place.geometry.location) { + // user entered the name of a place that wasn't suggested & pressed the enter key, or place details request failed + window.alert("No details available for input: '" + place.name + "'"); + return; + } + + // zoom in on the location of the search result + if (place.geometry.viewport) { + console.log(this._map); + this._map.fitBounds(place.geometry.viewport); + } else { + console.log(this._map); + this._map.setCenter(place.geometry.location); + this._map.setZoom(17); + } + + // customize icon => customized icon for the nature of the location selected + const icon = { + url: place.icon as string, + size: new google.maps.Size(71, 71), + origin: new google.maps.Point(0, 0), + anchor: new google.maps.Point(17, 34), + scaledSize: new google.maps.Size(25, 25), + }; + + // put temporary cutomized marker on searched location + this.searchMarkers.forEach((marker) => { + marker.setMap(null); + }); + this.searchMarkers = []; + this.searchMarkers.push( + new window.google.maps.Marker({ + map: this._map, + icon, + title: place.name, + position: place.geometry.location, + }) + ) + } + + + @action + private addMarker = (location: google.maps.LatLng | undefined, map: google.maps.Map) => { + new window.google.maps.Marker({ + position: location, + map: map + }); + } + + @action + private markerLoadHandler = (marker: google.maps.Marker, place: LocationData) => { + place.id ? this.markerMap[place.id] = marker : null; + } + + render() { + const { Document, fieldKey, isContentActive: active } = this.props; + + return <div className="collectionMapView" ref={this.createDashEventsTarget}> + + <div className={"collectionMapView-contents"} + style={{ pointerEvents: active() ? undefined : "none", overflow: 'hidden' }} + onWheel={e => e.stopPropagation()} + onPointerDown={e => (e.button === 0 && !e.ctrlKey) && e.stopPropagation()} > + {/* <LoadScript + googleMapsApiKey={process.env.GOOGLE_MAPS!} + libraries={['places', 'drawing']} + > */} + <div className="map-wrapper"> + <GoogleMap + mapContainerStyle={mapContainerStyle} + zoom={this.zoom} + center={this.center} + onLoad={map => this.loadHandler(map)} + options={mapOptions} + > + <Autocomplete + onLoad={this.setSearchBox} + onPlaceChanged={this.handlePlaceChanged}> + <input ref={this.inputRef} className="searchbox" type="text" placeholder="Search anywhere:" /> + </Autocomplete> + + {this.myPlaces ? this.myPlaces.map(place => + <Marker + position={place.pos} + onLoad={marker => this.markerLoadHandler(marker, place)} + onClick={e => this.markerClickHandler(e, place)} + draggable={false} + /> + ) : null} + {this.infoWindowOpen && this.selectedPlace && ( + <InfoWindow + anchor={this.markerMap[this.selectedPlace.id!]} + onCloseClick={this.handleInfoWindowClose} + > + <div style={{ backgroundColor: 'white', opacity: 0.75, padding: 12 }}> + <div style={{ fontSize: 16 }}> + <div> + <img src="http://placekitten.com/200/300" /> + <hr /> + <form> + <label>Title: </label><br /> + <input type="text" id="fname" name="fname"></input><br /> + <label>Desription: </label><br /> + <textarea style={{ height: 150 }} id="lname" name="lname" placeholder="Notes, a short description of this location, a brief comment, etc."></textarea> + </form> + <hr /> + <div> + <button>New link+</button> + </div> + </div> + </div> + </div> + </InfoWindow> + )} + </GoogleMap> + </div> + {/* </LoadScript > */} + </div > + </div >; + } + +}
\ No newline at end of file diff --git a/src/client/views/collections/TabDocView.scss b/src/client/views/collections/TabDocView.scss index 7f62ecaa0..0d045bada 100644 --- a/src/client/views/collections/TabDocView.scss +++ b/src/client/views/collections/TabDocView.scss @@ -66,7 +66,6 @@ input.lm_title { right: 15; bottom: 15; border: solid 1px; - box-shadow: black 0.4vw 0.4vw 0.8vw; width: 100%; height: 100%; transition: all 0.5s; @@ -101,4 +100,4 @@ input.lm_title { &:hover { box-shadow: none; } -}
\ No newline at end of file +} diff --git a/src/client/views/collections/TabDocView.tsx b/src/client/views/collections/TabDocView.tsx index eb95bb913..7e57d0e89 100644 --- a/src/client/views/collections/TabDocView.tsx +++ b/src/client/views/collections/TabDocView.tsx @@ -437,8 +437,8 @@ export class TabMinimapView extends React.Component<TabMinimapViewProps> { case StyleProp.PointerEvents: return "none"; case StyleProp.DocContents: const background = doc.type === DocumentType.PDF ? "red" : doc.type === DocumentType.IMG ? "blue" : doc.type === DocumentType.RTF ? "orange" : - doc.type === DocumentType.VID ? "purple" : doc.type === DocumentType.WEB ? "yellow" : "gray"; - return doc.type === DocumentType.COL || doc.type === DocumentType.INK ? + doc.type === DocumentType.VID ? "purple" : doc.type === DocumentType.WEB ? "yellow" : doc.type === DocumentType.MAP ? "blue" : "gray"; + return doc.type === DocumentType.COL ? undefined : <div style={{ width: doc[WidthSym](), height: doc[HeightSym](), position: "absolute", display: "block", background }} />; } @@ -476,7 +476,6 @@ export class TabMinimapView extends React.Component<TabMinimapViewProps> { <div className="miniMap" style={{ width: miniSize, height: miniSize, background: this.props.background() }}> <CollectionFreeFormView Document={this.props.document} - SetSubView={() => this} CollectionView={undefined} ContainingCollectionView={undefined} ContainingCollectionDoc={undefined} @@ -484,7 +483,7 @@ export class TabMinimapView extends React.Component<TabMinimapViewProps> { childLayoutTemplate={this.childLayoutTemplate} // bcz: Ugh .. should probably be rendering a CollectionView or the minimap should be part of the collectionFreeFormView to avoid having to set stuff like this. noOverlay={true} // don't render overlay Docs since they won't scale setHeight={returnFalse} - isContentActive={returnFalse} + isContentActive={emptyFunction} isAnyChildContentActive={returnFalse} select={emptyFunction} dropAction={undefined} diff --git a/src/client/views/collections/TreeView.scss b/src/client/views/collections/TreeView.scss index 1ebc5873e..2e33d3564 100644 --- a/src/client/views/collections/TreeView.scss +++ b/src/client/views/collections/TreeView.scss @@ -53,14 +53,11 @@ } } +.treeView-container-outline-active .treeView-container-active { z-index: 100; position: relative; - - .formattedTextbox-sidebar { - background-color: #ffff001f !important; - height: 500px !important; - } + pointer-events: all; } .treeView-openRight { diff --git a/src/client/views/collections/TreeView.tsx b/src/client/views/collections/TreeView.tsx index 7f2128230..eedb353e3 100644 --- a/src/client/views/collections/TreeView.tsx +++ b/src/client/views/collections/TreeView.tsx @@ -81,7 +81,7 @@ export class TreeView extends React.Component<TreeViewProps> { static _openLevelScript: Opt<ScriptField | undefined>; private _header: React.RefObject<HTMLDivElement> = React.createRef(); private _tref = React.createRef<HTMLDivElement>(); - private _docRef: Opt<DocumentView>; + @observable _docRef: Opt<DocumentView>; private _selDisposer: Opt<IReactionDisposer>; private _editTitleScript: (() => ScriptField) | undefined; private _openScript: (() => ScriptField) | undefined; @@ -116,7 +116,8 @@ export class TreeView extends React.Component<TreeViewProps> { @computed get childLinks() { return this.childDocList("links"); } @computed get childAliases() { return this.childDocList("aliases"); } @computed get childAnnos() { return this.childDocList(this.fieldKey + "-annotations"); } - @computed get selected() { return SelectionManager.Views().lastElement()?.props.Document === this.props.document; } + @computed get selected() { return SelectionManager.IsSelected(this._docRef); } + // SelectionManager.Views().lastElement()?.props.Document === this.props.document; } childDocList(field: string) { const layout = Cast(Doc.LayoutField(this.doc), Doc, null); @@ -125,7 +126,12 @@ export class TreeView extends React.Component<TreeViewProps> { DocListCastOrNull(this.doc[field]); // otherwise use the document's data field } @undoBatch move = (doc: Doc | Doc[], target: Doc | undefined, addDoc: (doc: Doc | Doc[]) => boolean) => { - return this.doc !== target && this.props.removeDoc?.(doc) === true && addDoc(doc); + if (this.doc !== target && addDoc !== returnFalse) { // bcz: this should all be running in a Temp undo batch instead of hackily testing for returnFalse + if (this.props.removeDoc?.(doc) === true) { + return addDoc(doc); + } + } + return false; } @undoBatch @action remove = (doc: Doc | Doc[], key: string) => { this.props.treeView.props.select(false); @@ -141,8 +147,10 @@ export class TreeView extends React.Component<TreeViewProps> { this._editTitle = false; } else if (docView.isSelected()) { + const doc = docView.Document; + SelectionManager.SelectSchemaViewDoc(doc); this._editTitle = true; - this._selDisposer = reaction(() => docView.isSelected(), sel => !sel && this.setEditTitle(undefined)); + this._selDisposer = reaction(() => SelectionManager.SelectedSchemaDoc(), seldoc => seldoc !== doc && this.setEditTitle(undefined)); } else { docView.select(false); } @@ -213,16 +221,18 @@ export class TreeView extends React.Component<TreeViewProps> { const before = pt[1] < rect.top + rect.height / 2; const inside = pt[0] > Math.min(rect.left + 75, rect.left + rect.width * .75) || (!before && this.treeViewOpen && this.childDocList.length); this._header.current!.className = "treeView-header"; - if (inside) this._header.current!.className += " treeView-header-inside"; - else if (before) this._header.current!.className += " treeView-header-above"; - else if (!before) this._header.current!.className += " treeView-header-below"; + if (!this.props.treeView.outlineMode || DragManager.DocDragData?.treeViewDoc === this.props.treeView.rootDoc) { + if (inside) this._header.current!.className += " treeView-header-inside"; + else if (before) this._header.current!.className += " treeView-header-above"; + else if (!before) this._header.current!.className += " treeView-header-below"; + } e.stopPropagation(); } public static makeTextBullet() { const bullet = Docs.Create.TextDocument("-text-", { layout: CollectionView.LayoutString("data"), - title: "-title-", "sidebarColor": "transparent", "sidebarViewType": CollectionViewType.Freeform, + title: "-title-", treeViewExpandedViewLock: true, treeViewExpandedView: "data", _viewType: CollectionViewType.Tree, hideLinkButton: true, _showSidebar: true, treeViewType: "outline", x: 0, y: 0, _xMargin: 0, _yMargin: 0, _autoHeight: true, _singleLine: true, backgroundColor: "transparent", _width: 1000, _height: 10 @@ -244,9 +254,7 @@ export class TreeView extends React.Component<TreeViewProps> { TreeView._editTitleOnLoad = { id: folder[Id], parent: this.props.parentTreeView }; return this.props.addDocument(folder); } - deleteFolder = () => { - return this.props.removeDoc?.(this.doc); - } + deleteItem = () => this.props.removeDoc?.(this.doc); preTreeDrop = (e: Event, de: DragManager.DropEvent, targetAction: dropActionType) => { const dragData = de.complete.docDragData; @@ -266,23 +274,25 @@ export class TreeView extends React.Component<TreeViewProps> { e.stopPropagation(); } const docDragData = de.complete.docDragData; - if (docDragData) { - e.stopPropagation(); + if (docDragData && pt[0] < rect.left + rect.width) { if (docDragData.draggedDocuments[0] === this.doc) return true; - this.dropDocuments(docDragData.droppedDocuments, before, inside, docDragData.dropAction, docDragData.moveDocument, docDragData.treeViewDoc === this.props.treeView.props.Document); + if (this.dropDocuments(docDragData.droppedDocuments, before, inside, docDragData.dropAction, docDragData.moveDocument, docDragData.treeViewDoc === this.props.treeView.props.Document)) { + e.stopPropagation(); + } } } dropDocuments(droppedDocuments: Doc[], before: boolean, inside: number | boolean, dropAction: dropActionType, moveDocument: DragManager.MoveFunction | undefined, forceAdd: boolean) { const parentAddDoc = (doc: Doc | Doc[]) => this.props.addDocument(doc, undefined, before); - const canAdd = !StrCast((inside ? this.props.document : this.props.containerCollection)?.freezeChildren).includes("add") || forceAdd; + const canAdd = (!this.props.treeView.outlineMode && !StrCast((inside ? this.props.document : this.props.containerCollection)?.freezeChildren).includes("add")) || forceAdd; const localAdd = (doc: Doc) => Doc.AddDocToList(this.dataDoc, this.fieldKey, doc) && ((doc.context = this.doc.context) || true) ? true : false; const addDoc = !inside ? parentAddDoc : (doc: Doc | Doc[]) => (doc instanceof Doc ? [doc] : doc).reduce((flg, doc) => flg && localAdd(doc), true as boolean); const move = (!dropAction || dropAction === "proto" || dropAction === "move" || dropAction === "same") && moveDocument; if (canAdd) { - UndoManager.RunInTempBatch(() => droppedDocuments.reduce((added, d) => (move ? move(d, undefined, addDoc) || (dropAction === "proto" ? addDoc(d) : false) : addDoc(d)) || added, false)); + return UndoManager.RunInTempBatch(() => droppedDocuments.reduce((added, d) => (move ? move(d, undefined, addDoc) || (dropAction === "proto" ? addDoc(d) : false) : addDoc(d)) || added, false)); } + return false; } refTransform = (ref: HTMLDivElement | undefined | null) => { @@ -432,7 +442,7 @@ export class TreeView extends React.Component<TreeViewProps> { </div> </ul>; } - return <ul>{this.renderEmbeddedDocument(false)}</ul>; // "layout" + return <ul onPointerDown={e => { e.preventDefault(); e.stopPropagation(); }}>{this.renderEmbeddedDocument(false, returnFalse)}</ul>; // "layout" } get onCheckedClick() { return this.doc.type === DocumentType.COL ? undefined : this.props.onCheckedClick?.() ?? ScriptCast(this.doc.onCheckedClick); } @@ -519,16 +529,16 @@ export class TreeView extends React.Component<TreeViewProps> { } contextMenuItems = () => { const makeFolder = { script: ScriptField.MakeFunction(`scriptContext.makeFolder()`, { scriptContext: "any" })!, icon: "folder-plus", label: "New Folder" }; - const deleteFolder = { script: ScriptField.MakeFunction(`scriptContext.deleteFolder()`, { scriptContext: "any" })!, icon: "folder-plus", label: "Delete Folder" }; - const folderOp = this.childDocs?.length ? makeFolder : deleteFolder; + const deleteItem = { script: ScriptField.MakeFunction(`scriptContext.deleteItem()`, { scriptContext: "any" })!, icon: "folder-plus", label: "Delete" }; + const folderOp = this.childDocs?.length ? [makeFolder] : []; const openAlias = { script: ScriptField.MakeFunction(`openOnRight(getAlias(self))`)!, icon: "copy", label: "Open Alias" }; const focusDoc = { script: ScriptField.MakeFunction(`DocFocusOrOpen(self)`)!, icon: "eye", label: "Focus or Open" }; - return [...this.props.contextMenuItems.filter(mi => !mi.filter ? true : mi.filter.script.run({ doc: this.doc })?.result), ... (this.doc.isFolder ? [folderOp] : + return [...this.props.contextMenuItems.filter(mi => !mi.filter ? true : mi.filter.script.run({ doc: this.doc })?.result), ... (this.doc.isFolder ? folderOp : Doc.IsSystem(this.doc) ? [] : this.props.treeView.fileSysMode && this.doc === Doc.GetProto(this.doc) ? [openAlias, makeFolder] : this.doc.viewType === CollectionViewType.Docking ? [] : - [openAlias, focusDoc])]; + [deleteItem, openAlias, focusDoc])]; } childContextMenuItems = () => { const customScripts = Cast(this.doc.childContextMenuScripts, listSpec(ScriptField), []); @@ -581,6 +591,7 @@ export class TreeView extends React.Component<TreeViewProps> { } titleWidth = () => Math.max(20, Math.min(this.props.treeView.truncateTitleWidth(), this.props.panelWidth() - 2 * treeBulletWidth())); + return18 = () => 18; /** * Renders the EditableView title element for placement into the tree. */ @@ -636,10 +647,10 @@ export class TreeView extends React.Component<TreeViewProps> { moveDocument={this.move} removeDocument={this.props.removeDoc} ScreenToLocalTransform={this.getTransform} - NativeHeight={() => 18} + NativeHeight={this.return18} NativeWidth={this.titleWidth} PanelWidth={this.titleWidth} - PanelHeight={() => 18} + PanelHeight={this.return18} contextMenuItems={this.contextMenuItems} renderDepth={1} isContentActive={this.props.isContentActive} @@ -679,6 +690,7 @@ export class TreeView extends React.Component<TreeViewProps> { renderBulletHeader = (contents: JSX.Element, editing: boolean) => { return <> <div className={`treeView-header` + (editing ? "-editing" : "")} key="titleheader" + style={{ width: "max-content" }} ref={this._header} onClick={this.ignoreEvent} onPointerDown={this.ignoreEvent} @@ -691,7 +703,7 @@ export class TreeView extends React.Component<TreeViewProps> { } - renderEmbeddedDocument = (asText: boolean) => { + renderEmbeddedDocument = (asText: boolean, isActive: () => boolean | undefined) => { const layout = StrCast(Doc.LayoutField(this.layoutDoc)); const isExpandable = layout.includes(FormattedTextBox.name) || layout.includes(SliderBox.name); const panelWidth = asText || isExpandable ? this.rtfWidth : this.expandPanelWidth; @@ -704,8 +716,8 @@ export class TreeView extends React.Component<TreeViewProps> { NativeWidth={!asText && (this.layoutDoc.type === DocumentType.RTF || this.layoutDoc.type === DocumentType.SLIDER) ? this.rtfWidth : undefined} NativeHeight={!asText && (this.layoutDoc.type === DocumentType.RTF || this.layoutDoc.type === DocumentType.SLIDER) ? this.rtfHeight : undefined} LayoutTemplateString={asText ? FormattedTextBox.LayoutString("text") : undefined} - isContentActive={asText ? this.props.isContentActive : returnFalse} - isDocumentActive={asText ? this.props.isContentActive : returnFalse} + isContentActive={isActive} + isDocumentActive={isActive} styleProvider={asText ? this.titleStyleProvider : this.embeddedStyleProvider} hideTitle={asText} fitContentsToDoc={returnTrue} @@ -749,7 +761,7 @@ export class TreeView extends React.Component<TreeViewProps> { @computed get renderDocumentAsHeader() { return <> {this.renderBullet} - {this.renderEmbeddedDocument(true)} + {this.renderEmbeddedDocument(true, this.props.isContentActive)} </>; } @@ -770,19 +782,19 @@ export class TreeView extends React.Component<TreeViewProps> { const docs = this.props.treeView.onTreeDrop(de, (docs: Doc[]) => this.dropDocuments(docs, before, inside, "copy", undefined, false)); } + render() { TraceMobx(); const hideTitle = this.doc.treeViewHideHeader || this.props.treeView.outlineMode; return this.props.renderedIds.indexOf(this.doc[Id]) !== -1 ? "<" + this.doc.title + ">" : // just print the title of documents we've previously rendered in this hierarchical path to avoid cycles <div className={`treeView-container${this.props.isContentActive() ? "-active" : ""}`} ref={this.createTreeDropTarget} - onDrop={this.onTreeDrop} //onPointerDown={e => this.props.isContentActive(true) && SelectionManager.DeselectAll()} // bcz: this breaks entering a text filter in a filterBox since it deselects the filter's target document onKeyDown={this.onKeyDown}> <li className="collection-child"> {hideTitle && this.doc.type !== DocumentType.RTF ? - this.renderEmbeddedDocument(false) : + this.renderEmbeddedDocument(false, returnFalse) : this.renderBulletHeader(hideTitle ? this.renderDocumentAsHeader : this.renderTitleAsHeader, this._editTitle)} </li> </div>; diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx index a9a997853..c35bb3581 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx @@ -188,28 +188,44 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo const atop = this.visibleY(adiv); const btop = this.visibleY(bdiv); if (!a.width || !b.width) return undefined; + const aDocBounds = (A.props as any).DocumentView?.().getBounds() || { left: 0, right: 0, top: 0, bottom: 0 }; + const bDocBounds = (B.props as any).DocumentView?.().getBounds() || { left: 0, right: 0, top: 0, bottom: 0 }; + const acentX = (a.left + a.right) / 2; + const acentY = (a.top + a.bottom) / 2; + const bcentX = (b.left + b.right) / 2; + const bcentY = (b.top + b.bottom) / 2; + const pt1Arc = ((acentX - aDocBounds.left) > 0.1 && (aDocBounds.right - acentX) > 0.1) || + ((acentY - aDocBounds.top) > 0.1 && (aDocBounds.bottom - acentY) > 0.1); + const pt2Arc = ((bcentX - bDocBounds.left) > 0.1 && (bDocBounds.right - bcentX) > 0.1) || + ((bcentY - bDocBounds.top) > 0.1 && (bDocBounds.bottom - bcentY) > 0.1); const atop2 = this.visibleY(adiv); const btop2 = this.visibleY(bdiv); const aleft = this.visibleX(adiv); const bleft = this.visibleX(bdiv); const clipped = aleft !== a.left || atop !== a.top || bleft !== b.left || btop !== b.top; - const apt = Utils.closestPtBetweenRectangles(aleft, atop, a.width, a.height, bleft, btop, b.width, b.height, a.left + a.width / 2, a.top + a.height / 2); - const bpt = Utils.closestPtBetweenRectangles(bleft, btop, b.width, b.height, aleft, atop, a.width, a.height, apt.point.x, apt.point.y); - const pt1 = [apt.point.x, apt.point.y]; - const pt2 = [bpt.point.x, bpt.point.y]; - const pt1vec = [pt1[0] - (aleft + a.width / 2), pt1[1] - (atop + a.height / 2)]; - const pt2vec = [pt2[0] - (bleft + b.width / 2), pt2[1] - (btop + b.height / 2)]; + const pt1 = [aleft + a.width / 2, atop + a.height / 2]; + const pt2 = [bleft + b.width / 2, btop + b.width / 2]; + const pt1vec = [pt1[0] - (aDocBounds.left + aDocBounds.right) / 2, pt1[1] - (aDocBounds.top + aDocBounds.bottom) / 2]; + const pt2vec = [pt2[0] - (bDocBounds.left + bDocBounds.right) / 2, pt2[1] - (bDocBounds.top + bDocBounds.bottom) / 2]; const pt1len = Math.sqrt((pt1vec[0] * pt1vec[0]) + (pt1vec[1] * pt1vec[1])); const pt2len = Math.sqrt((pt2vec[0] * pt2vec[0]) + (pt2vec[1] * pt2vec[1])); const ptlen = Math.sqrt((pt1[0] - pt2[0]) * (pt1[0] - pt2[0]) + (pt1[1] - pt2[1]) * (pt1[1] - pt2[1])) / 2; - const pt1norm = clipped ? [0, 0] : [pt1vec[0] / pt1len * ptlen, pt1vec[1] / pt1len * ptlen]; - const pt2norm = clipped ? [0, 0] : [pt2vec[0] / pt2len * ptlen, pt2vec[1] / pt2len * ptlen]; + const pt1norm = clipped ? [0, 0] : !pt1Arc ? [pt1vec[0] / pt1len * ptlen, pt1vec[1] / pt1len * ptlen] : + Math.abs(acentY - aDocBounds.top) < 0.01 || + Math.abs(acentY - aDocBounds.bottom) < 0.01 ? [0, (pt2[1] - pt1[1]) / 2] : [(pt2[0] - pt1[0]) / 2, 0]; + const pt2norm = clipped ? [0, 0] : !pt2Arc ? [pt2vec[0] / pt2len * ptlen, pt2vec[1] / pt2len * ptlen] : + Math.abs(bcentY - bDocBounds.top) < 0.01 || + Math.abs(bcentY - bDocBounds.bottom) < 0.01 ? [0, (pt1[1] - pt2[1]) / 2] : [(pt1[0] - pt2[0]) / 2, 0]; + const pt1normlen = Math.sqrt(pt1norm[0] * pt1norm[0] + pt1norm[1] * pt1norm[1]) || 1; + const pt2normlen = Math.sqrt(pt2norm[0] * pt2norm[0] + pt2norm[1] * pt2norm[1]) || 1; + const pt1normalized = [pt1norm[0] / pt1normlen, pt1norm[1] / pt1normlen]; + const pt2normalized = [pt2norm[0] / pt2normlen, pt2norm[1] / pt2normlen]; const aActive = A.isSelected() || Doc.IsBrushed(A.rootDoc); const bActive = B.isSelected() || Doc.IsBrushed(B.rootDoc); const textX = (Math.min(pt1[0], pt2[0]) + Math.max(pt1[0], pt2[0])) / 2 + NumCast(LinkDocs[0].linkOffsetX); const textY = (pt1[1] + pt2[1]) / 2 + NumCast(LinkDocs[0].linkOffsetY); - return { a, b, pt1norm, pt2norm, aActive, bActive, textX, textY, pt1, pt2 }; + return { a, b, pt1norm, pt2norm, aActive, bActive, textX, textY, pt1: [pt1[0] + pt1normalized[0] * 13, pt1[1] + pt1normalized[1] * 13], pt2: [pt2[0] + pt2normalized[0] * 13, pt2[1] + pt2normalized[1] * 13] }; } render() { diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index febccbfcc..aeda71d01 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -1,11 +1,12 @@ -import { action, computed, IReactionDisposer, observable, reaction, runInAction, ObservableMap } from "mobx"; +import { Bezier } from "bezier-js"; +import { action, computed, IReactionDisposer, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; import { computedFn } from "mobx-utils"; import { DateField } from "../../../../fields/DateField"; import { Doc, HeightSym, Opt, StrListCast, WidthSym } from "../../../../fields/Doc"; import { collectionSchema, documentSchema } from "../../../../fields/documentSchemas"; import { Id } from "../../../../fields/FieldSymbols"; -import { InkData, InkField, InkTool } from "../../../../fields/InkField"; +import { InkData, InkField, InkTool, PointData, Segment } from "../../../../fields/InkField"; import { List } from "../../../../fields/List"; import { ObjectField } from "../../../../fields/ObjectField"; import { RichTextField } from "../../../../fields/RichTextField"; @@ -27,14 +28,15 @@ import { InteractionUtils } from "../../../util/InteractionUtils"; import { LinkManager } from "../../../util/LinkManager"; import { SearchUtil } from "../../../util/SearchUtil"; import { SelectionManager } from "../../../util/SelectionManager"; +import { ColorScheme } from "../../../util/SettingsManager"; import { SnappingManager } from "../../../util/SnappingManager"; import { Transform } from "../../../util/Transform"; -import { undoBatch } from "../../../util/UndoManager"; +import { undoBatch, UndoManager } from "../../../util/UndoManager"; import { COLLECTION_BORDER_WIDTH } from "../../../views/global/globalCssVariables.scss"; import { Timeline } from "../../animationtimeline/Timeline"; import { ContextMenu } from "../../ContextMenu"; -import { DocumentDecorations } from "../../DocumentDecorations"; -import { ActiveArrowEnd, ActiveArrowStart, ActiveDash, ActiveFillColor, ActiveInkBezierApprox, ActiveInkColor, ActiveInkWidth } from "../../InkingStroke"; +import { GestureOverlay } from "../../GestureOverlay"; +import { ActiveArrowEnd, ActiveArrowStart, ActiveDash, ActiveFillColor, ActiveInkBezierApprox, ActiveInkColor, ActiveInkWidth, InkingStroke, SetActiveInkColor, SetActiveInkWidth } from "../../InkingStroke"; import { LightboxView } from "../../LightboxView"; import { CollectionFreeFormDocumentView } from "../../nodes/CollectionFreeFormDocumentView"; import { DocFocusOptions, DocumentView, DocumentViewProps, ViewAdjustment, ViewSpecPrefix } from "../../nodes/DocumentView"; @@ -50,7 +52,6 @@ import { CollectionFreeFormRemoteCursors } from "./CollectionFreeFormRemoteCurso import "./CollectionFreeFormView.scss"; import { MarqueeView } from "./MarqueeView"; import React = require("react"); -import { ColorScheme } from "../../../util/SettingsManager"; export const panZoomSchema = createSchema({ _panX: "number", @@ -98,6 +99,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P private _layoutSizeData = observable.map<string, { width?: number, height?: number }>(); private _cachedPool: Map<string, PoolData> = new Map(); private _lastTap = 0; + private _batch: UndoManager.Batch | undefined = undefined; private get isAnnotationOverlay() { return this.props.isAnnotationOverlay; } private get scaleFieldKey() { return this.props.scaleField || "_viewScale"; } @@ -111,6 +113,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P @observable _pullDirection: string = ""; @observable _showAnimTimeline = false; @observable _clusterSets: (Doc[])[] = []; + @observable _deleteList: DocumentView[] = []; @observable _timelineRef = React.createRef<Timeline>(); @observable _marqueeRef = React.createRef<HTMLDivElement>(); @observable _keyframeEditing = false; @@ -146,7 +149,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P return this.props.ScreenToLocalTransform().translate(-this.borderWidth, -this.borderWidth); } @computed get cachedGetTransform(): Transform { - return this.getTransformOverlay().translate(- this.cachedCenteringShiftX, - this.cachedCenteringShiftY).transform(this.cachedGetLocalTransform); + return this.getContainerTransform().translate(- this.cachedCenteringShiftX, - this.cachedCenteringShiftY).transform(this.cachedGetLocalTransform); } @action setKeyFrameEditing = (set: boolean) => this._keyframeEditing = set; @@ -165,11 +168,10 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P panX = () => this.freeformData()?.bounds.cx ?? NumCast(this.Document._panX); panY = () => this.freeformData()?.bounds.cy ?? NumCast(this.Document._panY); zoomScaling = () => (this.freeformData()?.scale ?? NumCast(this.Document[this.scaleFieldKey], 1)); - contentTransform = () => `translate(${this.cachedCenteringShiftX}px, ${this.cachedCenteringShiftY}px) scale(${this.zoomScaling()}) translate(${-this.panX()}px, ${-this.panY()}px)`; + contentTransform = () => !this.cachedCenteringShiftX && !this.cachedCenteringShiftY && this.zoomScaling() === 1 ? "" : `translate(${this.cachedCenteringShiftX}px, ${this.cachedCenteringShiftY}px) scale(${this.zoomScaling()}) translate(${-this.panX()}px, ${-this.panY()}px)`; getTransform = () => this.cachedGetTransform.copy(); getLocalTransform = () => this.cachedGetLocalTransform.copy(); getContainerTransform = () => this.cachedGetContainerTransform.copy(); - getTransformOverlay = () => this.getContainerTransform().translate(1, 1); getActiveDocuments = () => this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map(pair => pair.layout); isAnyChildContentActive = () => this.props.isAnyChildContentActive(); addLiveTextBox = (newBox: Doc) => { @@ -221,7 +223,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P if (!de.embedKey && !this.ChildDrag && this.props.layerProvider?.(this.props.Document) !== false && this.props.Document._isGroup) return false; if (!super.onInternalDrop(e, de)) return false; const refDoc = docDragData.droppedDocuments[0]; - const [xpo, ypo] = this.getTransformOverlay().transformPoint(de.x, de.y); + const [xpo, ypo] = this.getContainerTransform().transformPoint(de.x, de.y); const z = NumCast(refDoc.z); const x = (z ? xpo : xp) - docDragData.offset[0]; const y = (z ? ypo : yp) - docDragData.offset[1]; @@ -433,25 +435,29 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P @action onPointerDown = (e: React.PointerEvent): void => { - if (e.nativeEvent.cancelBubble || InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE) || InteractionUtils.IsType(e, InteractionUtils.PENTYPE) || - ([InkTool.Pen, InkTool.Highlighter].includes(CurrentUserUtils.SelectedTool))) { - return; - } - this._hitCluster = this.pickCluster(this.getTransform().transformPoint(e.clientX, e.clientY)); + this._downX = this._lastX = e.pageX; + this._downY = this._lastY = e.pageY; if (e.button === 0 && !e.altKey && !e.ctrlKey && this.props.isContentActive(true)) { - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); - document.addEventListener("pointermove", this.onPointerMove); - document.addEventListener("pointerup", this.onPointerUp); - // if not using a pen and in no ink mode - if (CurrentUserUtils.SelectedTool === InkTool.None) { - this._downX = this._lastX = e.pageX; - this._downY = this._lastY = e.pageY; - } - // eraser plus anything else mode - else { - e.stopPropagation(); - e.preventDefault(); + if (!e.nativeEvent.cancelBubble && + !this.props.Document._isGroup && // group freeforms don't pan when dragged -- instead let the event go through to allow the group itself to drag + !InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE) && + !InteractionUtils.IsType(e, InteractionUtils.PENTYPE)) { + switch (CurrentUserUtils.SelectedTool) { + case InkTool.Highlighter: + case InkTool.Pen: break; // the GestureOverlay handles ink stroke input -- either as gestures, or drying as ink strokes that are added to document views + case InkTool.Eraser: + document.addEventListener("pointermove", this.onEraserMove); + document.addEventListener("pointerup", this.onEraserUp); + this._batch = UndoManager.StartBatch("collectionErase"); + e.stopPropagation(); + e.preventDefault(); + break; + case InkTool.None: + this._hitCluster = this.pickCluster(this.getTransform().transformPoint(e.clientX, e.clientY)); + document.addEventListener("pointermove", this.onPointerMove); + document.addEventListener("pointerup", this.onPointerUp); + break; + } } } } @@ -592,7 +598,18 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P } } } + @action + onEraserUp = (e: PointerEvent): void => { + if (!InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) { + document.removeEventListener("pointermove", this.onEraserMove); + document.removeEventListener("pointerup", this.onEraserUp); + this._deleteList.forEach(ink => ink.props.removeDocument?.(ink.rootDoc)); + this._deleteList = []; + this._batch?.end(); + } + } + @action onPointerUp = (e: PointerEvent): void => { if (!InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) { document.removeEventListener("pointermove", this.onPointerMove); @@ -623,25 +640,159 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P this._lastY = e.clientY; } + /** + * Erases strokes by intersecting them with an invisible "eraser stroke". + * By default this iterates through all intersected ink strokes, determines their segmentation, draws back the non-intersected segments, + * and deletes the original stroke. + * However, if Shift is held, then no segmentation is done -- instead any intersected stroke is deleted in its entirety. + */ + @action + onEraserMove = (e: PointerEvent) => { + const currPoint = { X: e.clientX, Y: e.clientY }; + this.getEraserIntersections({ X: this._lastX, Y: this._lastY }, currPoint).forEach(intersect => { + if (!this._deleteList.includes(intersect.inkView)) { + this._deleteList.push(intersect.inkView); + SetActiveInkWidth(StrCast(intersect.inkView.rootDoc.strokeWidth?.toString()) || "1"); + SetActiveInkColor(StrCast(intersect.inkView.rootDoc.color?.toString()) || "black"); + // create a new curve by appending all curves of the current segment together in order to render a single new stroke. + !e.shiftKey && this.segmentInkStroke(intersect.inkView, intersect.t).forEach(segment => + GestureOverlay.Instance.dispatchGesture(GestureUtils.Gestures.Stroke, + segment.reduce((data, curve) => [...data, ...curve.points + .map(p => intersect.inkView.ComponentView?.ptToScreen?.({ X: p.x, Y: p.y }) ?? { X: 0, Y: 0 }) + ], [] as PointData[]))); + // Lower ink opacity to give the user a visual indicator of deletion. + intersect.inkView.layoutDoc.opacity = 0.5; + } + }); + this._lastX = currPoint.X; + this._lastY = currPoint.Y; + + 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(); + } + @action onPointerMove = (e: PointerEvent): void => { - if (this.props.Document._isGroup) return; // groups don't pan when dragged -- instead let the event go through to allow the group itself to drag if (InteractionUtils.IsType(e, InteractionUtils.PENTYPE)) return; if (InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) { if (this.props.isContentActive(true)) e.stopPropagation(); } else if (!e.cancelBubble) { - if (CurrentUserUtils.SelectedTool === InkTool.None) { - if (this.tryDragCluster(e, this._hitCluster)) { - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); - } - else this.pan(e); + if (this.tryDragCluster(e, this._hitCluster)) { + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); } + else this.pan(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(); } } + /** + * Determines if the Eraser tool has intersected with an ink stroke in the current freeform collection. + * @returns an array of tuples containing the intersected ink DocumentView and the t-value where it was intersected + */ + getEraserIntersections = (lastPoint: { X: number, Y: number }, currPoint: { X: number, Y: number }) => { + const eraserMin = { X: Math.min(lastPoint.X, currPoint.X), Y: Math.min(lastPoint.Y, currPoint.Y) }; + const eraserMax = { X: Math.max(lastPoint.X, currPoint.X), Y: Math.max(lastPoint.Y, currPoint.Y) }; + return this.childDocs + .map(doc => DocumentManager.Instance.getDocumentView(doc, this.props.CollectionView)) + .filter(inkView => inkView?.ComponentView instanceof InkingStroke) + .map(inkView => ({ inkViewBounds: inkView!.getBounds(), inkStroke: inkView!.ComponentView as InkingStroke, inkView: inkView! })) + .filter(({ inkViewBounds }) => inkViewBounds && // bounding box of eraser segment and ink stroke overlap + eraserMin.X <= inkViewBounds.right && eraserMin.Y <= inkViewBounds.bottom && + eraserMax.X >= inkViewBounds.left && eraserMax.Y >= inkViewBounds.top) + .reduce((intersections, { inkStroke, inkView }) => { + const { inkData } = inkStroke.inkScaledData(); + // Convert from screen space to ink space for the intersection. + const prevPointInkSpace = inkStroke.ptFromScreen(lastPoint); + const currPointInkSpace = inkStroke.ptFromScreen(currPoint); + for (var i = 0; i < inkData.length - 3; i += 4) { + const intersects = Array.from(new Set(InkField.Segment(inkData, i).intersects({ // compute all unique intersections + p1: { x: prevPointInkSpace.X, y: prevPointInkSpace.Y }, + p2: { x: currPointInkSpace.X, y: currPointInkSpace.Y } + }) as (number | string)[])); // convert to more manageable union array type + // return tuples of the inkingStroke intersected, and the t value of the intersection + intersections.push(...intersects.map(t => ({ inkView, t: (+t) + Math.floor(i / 4) })));// convert string t's to numbers and add start of curve segment to convert from local t value to t value along complete curve + } + return intersections; + }, [] as { t: number, inkView: DocumentView }[]); + } + + /** + * Performs segmentation of the ink stroke - creates "segments" or subsections of the current ink stroke at points in which the + * ink stroke intersects any other ink stroke (including itself). + * @param ink The ink DocumentView intersected by the eraser. + * @param excludeT The index of the curve in the ink document that the eraser intersection occurred. + * @returns The ink stroke represented as a list of segments, excluding the segment in which the eraser intersection occurred. + */ + @action + segmentInkStroke = (ink: DocumentView, excludeT: number): Segment[] => { + const segments: Segment[] = []; + var segment: Segment = []; + var startSegmentT = 0; + const { inkData } = (ink?.ComponentView as InkingStroke).inkScaledData(); + // This iterates through all segments of the curve and splits them where they intersect another curve. + // if 'excludeT' is specified, then any segment containing excludeT will be skipped (ie, deleted) + for (var i = 0; i < inkData.length - 3; i += 4) { + const inkSegment = InkField.Segment(inkData, i); + // Getting all t-value intersections of the current curve with all other curves. + const tVals = this.getInkIntersections(i, ink, inkSegment).sort(); + if (tVals.length) { + tVals.forEach((t, index) => { + const docCurveTVal = t + Math.floor(i / 4); + if (excludeT < startSegmentT || excludeT > docCurveTVal) { + const localStartTVal = startSegmentT - Math.floor(i / 4); + segment.push(inkSegment.split(localStartTVal < 0 ? 0 : localStartTVal, t)); + segment.length && segments.push(segment); + } + // start a new segment from the intersection t value + segment = tVals.length - 1 === index ? [inkSegment.split(t).right] : []; + startSegmentT = docCurveTVal; + }); + } else { + segment.push(inkSegment); + } + } + if (excludeT < startSegmentT || excludeT > (inkData.length / 4)) { + segment.length && segments.push(segment); + } + return segments; + } + + /** + * Determines all possible intersections of the current curve of the intersected ink stroke with all other curves of all + * ink strokes in the current collection. + * @param i The index of the current curve within the inkData of the intersected ink stroke. + * @param ink The intersected DocumentView of the ink stroke. + * @param curve The current curve of the intersected ink stroke. + * @returns A list of all t-values at which intersections occur at the current curve of the intersected ink stroke. + */ + getInkIntersections = (i: number, ink: DocumentView, curve: Bezier): number[] => { + const tVals: number[] = []; + // Iterating through all ink strokes in the current freeform collection. + this.childDocs + .filter(doc => doc.type === DocumentType.INK) + .forEach(doc => { + const otherInk = DocumentManager.Instance.getDocumentView(doc, this.props.CollectionView)?.ComponentView as InkingStroke; + const { inkData: otherInkData } = otherInk?.inkScaledData() ?? { inkData: [] }; + const otherScreenPts = otherInkData.map(point => otherInk.ptToScreen(point)); + const otherCtrlPts = otherScreenPts.map(spt => (ink.ComponentView as InkingStroke).ptFromScreen(spt)); + for (var j = 0; j < otherCtrlPts.length - 3; j += 4) { + const neighboringSegment = i === j || i === j - 4 || i === j + 4; + // Ensuring that the curve intersected by the eraser is not checked for further ink intersections. + if (ink?.Document === otherInk.props.Document && neighboringSegment) continue; + + const otherCurve = new Bezier(otherCtrlPts.slice(j, j + 4).map(p => ({ x: p.X, y: p.Y }))); + curve.intersects(otherCurve).forEach((val: string | number, i: number) => { + // Converting the Bezier.js Split type to a t-value number. + const t = +val.toString().split("/")[0]; + if (i % 2 === 0 && !tVals.includes(t)) tVals.push(t); // bcz: Hack! don't know why but intersection points are doubled from bezier.js (but not identical). + }); + } + }); + return tVals; + } + handle1PointerMove = (e: TouchEvent, me: InteractionUtils.MultiTouchEvent<TouchEvent>) => { if (!e.cancelBubble) { const myTouches = InteractionUtils.GetMyTargetTouches(me, this.prevPoints, true); @@ -803,7 +954,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P else if (this.props.isContentActive(true) && !this.Document._isGroup) { e.stopPropagation(); e.preventDefault(); - this.zoom(e.clientX, e.clientY, e.deltaY); // if (!this.props.isAnnotationOverlay) // bcz: do we want to zoom in on images/videos/etc? + !this.props.isAnnotationOverlayScrollable && this.zoom(e.clientX, e.clientY, e.deltaY); // if (!this.props.isAnnotationOverlay) // bcz: do we want to zoom in on images/videos/etc? } } @@ -1010,13 +1161,13 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P rootSelected={childData ? this.rootSelected : returnFalse} onClick={this.onChildClickHandler} onDoubleClick={this.onChildDoubleClickHandler} - ScreenToLocalTransform={childLayout.z ? this.getTransformOverlay : this.getTransform} + ScreenToLocalTransform={childLayout.z ? this.getContainerTransform : this.getTransform} PanelWidth={childLayout[WidthSym]} PanelHeight={childLayout[HeightSym]} docFilters={this.childDocFilters} docRangeFilters={this.childDocRangeFilters} searchFilterDocs={this.searchFilterDocs} - isContentActive={returnFalse} + isContentActive={emptyFunction} isDocumentActive={this.props.childDocumentsActive ? this.props.isDocumentActive : this.isContentActive} focus={this.focusDocument} addDocTab={this.addDocTab} @@ -1035,7 +1186,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P bringToFront={this.bringToFront} showTitle={this.props.childShowTitle} dontRegisterView={this.props.dontRenderDocuments || this.props.dontRegisterView} - pointerEvents={this.backgroundActive || this.props.childPointerEvents ? "all" : + pointerEvents={this.props.isContentActive() === false ? "none" : this.backgroundActive || this.props.childPointerEvents ? "all" : (this.props.viewDefDivClick || (engine === "pass" && !this.props.isSelected(true))) ? "none" : this.props.pointerEvents} jitterRotation={this.props.styleProvider?.(childLayout, this.props, StyleProp.JitterRotation) || 0} //fitToBox={this.props.fitToBox || BoolCast(this.props.freezeChildDimensions)} // bcz: check this @@ -1403,7 +1554,6 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P } onPointerOver = (e: React.PointerEvent) => { - (DocumentDecorations.Instance.Interacting || (this.props.layerProvider?.(this.props.Document) !== false && SnappingManager.GetIsDragging())) && this.setupDragLines(e.ctrlKey || e.shiftKey); e.stopPropagation(); } @@ -1476,6 +1626,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P {this.layoutDoc._backgroundGridShow ? this.backgroundGrid : (null)} <CollectionFreeFormViewPannableContents isAnnotationOverlay={this.isAnnotationOverlay} + isAnnotationOverlayScrollable={this.props.isAnnotationOverlayScrollable} transform={this.contentTransform} zoomScaling={this.zoomScaling} presPaths={BoolCast(this.Document.presPathView)} @@ -1584,6 +1735,7 @@ interface CollectionFreeFormViewPannableContentsProps { progressivize?: boolean; presPinView?: boolean; isAnnotationOverlay: boolean | undefined; + isAnnotationOverlayScrollable: boolean | undefined; } @observer diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx index 65c345547..ec1cbadd5 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx +++ b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx @@ -6,7 +6,7 @@ import { documentSchema } from '../../../../fields/documentSchemas'; import { List } from '../../../../fields/List'; import { makeInterface } from '../../../../fields/Schema'; import { BoolCast, NumCast, ScriptCast, StrCast } from '../../../../fields/Types'; -import { returnFalse, emptyPath, returnEmptyDoclist } from '../../../../Utils'; +import { returnFalse, emptyPath, returnEmptyDoclist, emptyFunction } from '../../../../Utils'; import { DragManager, dropActionType } from '../../../util/DragManager'; import { Transform } from '../../../util/Transform'; import { undoBatch } from '../../../util/UndoManager'; @@ -228,7 +228,7 @@ export class CollectionMulticolumnView extends CollectionSubView(MulticolumnDocu LayoutTemplateString={this.props.childLayoutString} freezeDimensions={this.props.childFreezeDimensions} renderDepth={this.props.renderDepth + 1} - isContentActive={returnFalse} + isContentActive={emptyFunction} PanelWidth={width} PanelHeight={height} rootSelected={this.rootSelected} diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx index 30836854a..a2d51e2e7 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx +++ b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx @@ -6,7 +6,7 @@ import { documentSchema } from '../../../../fields/documentSchemas'; import { List } from '../../../../fields/List'; import { makeInterface } from '../../../../fields/Schema'; import { BoolCast, NumCast, ScriptCast, StrCast } from '../../../../fields/Types'; -import { returnFalse, emptyPath, returnEmptyDoclist } from '../../../../Utils'; +import { returnFalse, emptyPath, returnEmptyDoclist, emptyFunction } from '../../../../Utils'; import { DragManager, dropActionType } from '../../../util/DragManager'; import { Transform } from '../../../util/Transform'; import { undoBatch } from '../../../util/UndoManager'; @@ -237,7 +237,7 @@ export class CollectionMultirowView extends CollectionSubView(MultirowDocument) ScreenToLocalTransform={dxf} focus={this.props.focus} docFilters={this.childDocFilters} - isContentActive={returnFalse} + isContentActive={emptyFunction} docRangeFilters={this.childDocRangeFilters} searchFilterDocs={this.searchFilterDocs} ContainingCollectionDoc={this.props.CollectionView?.props.Document} diff --git a/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx b/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx index 9fe18d118..273e609ca 100644 --- a/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx +++ b/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx @@ -11,7 +11,7 @@ import { StyleProp } from "../../StyleProvider"; interface ResizerProps { width: number; styleProvider?: StyleProviderFunc; - isContentActive?: () => boolean; + isContentActive?: () => boolean | undefined; columnUnitLength(): number | undefined; toLeft?: Doc; toRight?: Doc; diff --git a/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx b/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx index 5478bf709..006ef4df6 100644 --- a/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx +++ b/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx @@ -11,7 +11,7 @@ import { StyleProviderFunc } from "../../nodes/DocumentView"; interface ResizerProps { height: number; styleProvider?: StyleProviderFunc; - isContentActive?: () => boolean; + isContentActive?: () => boolean | undefined; columnUnitLength(): number | undefined; toTop?: Doc; toBottom?: Doc; diff --git a/src/client/views/collections/collectionSchema/CollectionSchemaHeaders.tsx b/src/client/views/collections/collectionSchema/CollectionSchemaHeaders.tsx index 1306b79cb..dc35b5749 100644 --- a/src/client/views/collections/collectionSchema/CollectionSchemaHeaders.tsx +++ b/src/client/views/collections/collectionSchema/CollectionSchemaHeaders.tsx @@ -225,7 +225,7 @@ export interface KeysDropdownProps { fieldKey: string; ContainingCollectionDoc: Doc | undefined; ContainingCollectionView: Opt<CollectionView>; - active?: (outsideReaction?: boolean) => boolean; + active?: (outsideReaction?: boolean) => boolean | undefined; openHeader: (column: any, screenx: number, screeny: number) => void; col: SchemaHeaderField; icon: IconProp; diff --git a/src/client/views/collections/collectionSchema/SchemaTable.tsx b/src/client/views/collections/collectionSchema/SchemaTable.tsx index bc5a9559f..2219345f6 100644 --- a/src/client/views/collections/collectionSchema/SchemaTable.tsx +++ b/src/client/views/collections/collectionSchema/SchemaTable.tsx @@ -68,7 +68,7 @@ export interface SchemaTableProps { addDocument?: (document: Doc | Doc[]) => boolean; moveDocument?: (document: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (document: Doc | Doc[]) => boolean) => boolean; ScreenToLocalTransform: () => Transform; - active: (outsideReaction: boolean | undefined) => boolean; + active: (outsideReaction: boolean | undefined) => boolean | undefined; onDrop: (e: React.DragEvent<Element>, options: DocumentOptions, completed?: (() => void) | undefined) => void; addDocTab: (document: Doc, where: string) => boolean; pinToPres: (document: Doc) => void; diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx index dbab5e762..005133eb0 100644 --- a/src/client/views/nodes/DocumentContentsView.tsx +++ b/src/client/views/nodes/DocumentContentsView.tsx @@ -40,6 +40,7 @@ import { VideoBox } from "./VideoBox"; import { WebBox } from "./WebBox"; import React = require("react"); import XRegExp = require("xregexp"); +import { MapBox } from "./MapBox/MapBox"; const JsxParser = require('react-jsx-parser').default; //TODO Why does this need to be imported like this? @@ -225,7 +226,7 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & Fo FormattedTextBox, ImageBox, DirectoryImportBox, FontIconBox, LabelBox, EquationBox, SliderBox, FieldView, CollectionFreeFormView, CollectionDockingView, CollectionSchemaView, CollectionView, WebBox, KeyValueBox, PDFBox, VideoBox, AudioBox, PresBox, YoutubeBox, PresElementBox, SearchBox, FilterBox, FunctionPlotBox, - ColorBox, DashWebRTCVideo, LinkAnchorBox, InkingStroke, LinkBox, ScriptingBox, + ColorBox, DashWebRTCVideo, LinkAnchorBox, InkingStroke, LinkBox, ScriptingBox, MapBox, ScreenshotBox, HTMLtag, ComparisonBox }} diff --git a/src/client/views/nodes/DocumentView.scss b/src/client/views/nodes/DocumentView.scss index 1ec7bf72a..9fcd45e72 100644 --- a/src/client/views/nodes/DocumentView.scss +++ b/src/client/views/nodes/DocumentView.scss @@ -175,7 +175,7 @@ position: absolute; bottom: 0; width: 100%; - overflow-y: scroll; + overflow-y: auto; transform-origin: bottom left; opacity: 0.1; transition: opacity 0.5s; diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index c9b246c10..138bad9b8 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -138,6 +138,7 @@ export interface DocumentViewSharedProps { hideLinkButton?: boolean; hideCaptions?: boolean; ignoreAutoHeight?: boolean; + forceAutoHeight?: boolean; disableDocBrushing?: boolean; // should highlighting for this view be disabled when same document in another view is hovered over. pointerEvents?: string; scriptContext?: any; // can be assigned anything and will be passed as 'scriptContext' to any OnClick script that executes on this document @@ -224,7 +225,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps componentWillUnmount() { this.cleanupHandlers(true); } componentDidMount() { this.setupHandlers(); } - componentDidUpdate() { this.setupHandlers(); } + //componentDidUpdate() { this.setupHandlers(); } setupHandlers() { this.cleanupHandlers(false); if (this._mainCont.current) { @@ -415,6 +416,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps ffview && runInAction(() => (ffview.ChildDrag = this.props.DocumentView())); DragManager.StartDocumentDrag([this._mainCont.current], dragData, x, y, { hideSource: hideSource || (!dropAction && !this.layoutDoc.onDragStart) }, () => setTimeout(action(() => ffview && (ffview.ChildDrag = undefined)))); // this needs to happen after the drop event is processed. + ffview?.setupDragLines(false); } } @@ -824,13 +826,14 @@ export class DocumentViewInternal extends DocComponent<DocumentViewInternalProps } setContentView = action((view: { getAnchor?: () => Doc, forward?: () => boolean, back?: () => boolean }) => this._componentView = view); isContentActive = (outsideReaction?: boolean) => { - return CurrentUserUtils.SelectedTool !== InkTool.None || + return this.props.isContentActive() === false ? false : ( + CurrentUserUtils.SelectedTool !== InkTool.None || SnappingManager.GetIsDragging() || - this.props.rootSelected() || + this.rootSelected() || this.props.Document.forceActive || this.props.isSelected(outsideReaction) || this._componentView?.isAnyChildContentActive?.() || - this.props.isContentActive() ? true : false; + this.props.isContentActive()) ? true : undefined; } @computed get contents() { TraceMobx(); @@ -1255,7 +1258,7 @@ export class DocumentView extends React.Component<DocumentViewProps> { position: this.props.Document.isInkMask ? "absolute" : undefined, transform: isButton ? undefined : `translate(${this.centeringX}px, ${this.centeringY}px)`, width: isButton ? "100%" : xshift() ?? `${100 * (this.props.PanelWidth() - this.Xshift * 2) / this.props.PanelWidth()}%`, - height: isButton ? undefined : yshift() ?? (this.fitWidth ? `${this.panelHeight}px` : + height: isButton || this.props.forceAutoHeight ? undefined : yshift() ?? (this.fitWidth ? `${this.panelHeight}px` : `${100 * this.effectiveNativeHeight / this.effectiveNativeWidth * this.props.PanelWidth() / this.props.PanelHeight()}%`), }}> <DocumentViewInternal {...this.props} diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx index ee81e106a..943b9f153 100644 --- a/src/client/views/nodes/FieldView.tsx +++ b/src/client/views/nodes/FieldView.tsx @@ -18,7 +18,7 @@ export interface FieldViewProps extends DocumentViewSharedProps { scrollOverflow?: boolean; // bcz: would like to think this can be avoided -- need to look at further select: (isCtrlPressed: boolean) => void; - isContentActive: (outsideReaction?: boolean) => boolean; + isContentActive: (outsideReaction?: boolean) => boolean | undefined; isDocumentActive?: () => boolean; isSelected: (outsideReaction?: boolean) => boolean; scaling?: () => number; diff --git a/src/client/views/nodes/FilterBox.tsx b/src/client/views/nodes/FilterBox.tsx index 2041c7399..fb8e89da9 100644 --- a/src/client/views/nodes/FilterBox.tsx +++ b/src/client/views/nodes/FilterBox.tsx @@ -225,7 +225,7 @@ export class FilterBox extends ViewBoxBaseComponent<FieldViewProps, FilterBoxDoc treeViewExpandedView: "layout", _treeViewOpen: true, _forceActive: true, ignoreClick: true }); Doc.GetProto(newFacet).type = DocumentType.COL; // forces item to show an open/close button instead ofa checkbox - newFacet._textBoxPadding = 4; + newFacet._textBoxPaddingX = newFacet._textBoxPaddingY = 4; const scriptText = `setDocFilter(this?.target, "${facetHeader}", text, "match")`; newFacet.onTextChanged = ScriptField.MakeScript(scriptText, { this: Doc.name, text: "string" }); } else if (facetHeader !== "tags" && nonNumbers / facetValues.strings.length < .1) { diff --git a/src/client/views/nodes/LinkBox.tsx b/src/client/views/nodes/LinkBox.tsx index b82d16677..879a63248 100644 --- a/src/client/views/nodes/LinkBox.tsx +++ b/src/client/views/nodes/LinkBox.tsx @@ -15,7 +15,7 @@ const LinkDocument = makeInterface(documentSchema); @observer export class LinkBox extends ViewBoxBaseComponent<FieldViewProps, LinkDocument>(LinkDocument) { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(LinkBox, fieldKey); } - isContentActiveFunc = () => this.isContentActive() ? true : false; + isContentActiveFunc = () => this.isContentActive(); render() { if (this.dataDoc.treeViewOpen === undefined) setTimeout(() => this.dataDoc.treeViewOpen = true); return <div className={`linkBox-container${this.isContentActive() ? "-interactive" : ""}`} diff --git a/src/client/views/nodes/LinkDocPreview.tsx b/src/client/views/nodes/LinkDocPreview.tsx index 424083dac..2e29c0656 100644 --- a/src/client/views/nodes/LinkDocPreview.tsx +++ b/src/client/views/nodes/LinkDocPreview.tsx @@ -166,7 +166,7 @@ export class LinkDocPreview extends React.Component<LinkDocPreviewProps> { docViewPath={returnEmptyDoclist} ScreenToLocalTransform={Transform.Identity} isDocumentActive={returnFalse} - isContentActive={returnFalse} + isContentActive={emptyFunction} addDocument={returnFalse} removeDocument={returnFalse} addDocTab={returnFalse} diff --git a/src/client/views/nodes/MapBox/MapBox.scss b/src/client/views/nodes/MapBox/MapBox.scss new file mode 100644 index 000000000..f275bed54 --- /dev/null +++ b/src/client/views/nodes/MapBox/MapBox.scss @@ -0,0 +1,62 @@ +@import "../../global/globalCssVariables.scss"; +.mapBox { + width: 100%; + height: 100%; + overflow: hidden; + display: flex; + > div { + position: unset !important; // when the sidebar filter flys out, this prevents the map from extending outside the document box + } + + .mapBox-overlayButton-sidebar { + background: #121721; + height: 25px; + width: 25px; + right: 5px; + display: flex; + position: absolute; + align-items: center; + justify-content: center; + border-radius: 3px; + pointer-events: all; + z-index: 1; // so it appears on top of the document's title, if shown + + box-shadow: $standard-box-shadow; + transition: 0.2s; + + &:hover{ + filter: brightness(0.85); + } + } + + .mapBox-wrapper { + width: 100%; + .searchbox { + box-sizing: border-box; + border: 1px solid transparent; + width: 240px; + height: 32px; + padding: 0 12px; + border-radius: 3px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); + font-size: 14px; + outline: none; + text-overflow: ellipses; + position: absolute; + left: 50%; + margin-left: -120px; + } + } + + .mapBox-sidebar-handle { + position: absolute !important; + top: 0; + //top: calc(50% - 17.5px); // use this to center vertically -- make sure it looks okay for slide views + width: 10px; + height: 100%; + max-height: 35px; + background: lightgray; + border-radius: 20px; + cursor:grabbing; + } +} diff --git a/src/client/views/nodes/MapBox/MapBox.tsx b/src/client/views/nodes/MapBox/MapBox.tsx new file mode 100644 index 000000000..7875060e2 --- /dev/null +++ b/src/client/views/nodes/MapBox/MapBox.tsx @@ -0,0 +1,691 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Autocomplete, GoogleMap, GoogleMapProps, InfoWindow, LoadScript, Marker } from '@react-google-maps/api'; +import { action, computed, IReactionDisposer, observable, ObservableMap } from 'mobx'; +import { observer } from "mobx-react"; +import * as React from "react"; +import { Doc, DocListCast, Opt, WidthSym } from '../../../../fields/Doc'; +import { documentSchema } from '../../../../fields/documentSchemas'; +import { Id } from '../../../../fields/FieldSymbols'; +import { InkTool } from '../../../../fields/InkField'; +import { makeInterface } from '../../../../fields/Schema'; +import { NumCast, StrCast } from '../../../../fields/Types'; +import { TraceMobx } from '../../../../fields/util'; +import { emptyFunction, OmitKeys, returnEmptyFilter, returnFalse, returnOne, returnTrue, returnZero, setupMoveUpEvents, Utils } from '../../../../Utils'; +import { Docs } from '../../../documents/Documents'; +import { CurrentUserUtils } from '../../../util/CurrentUserUtils'; +import { DragManager } from '../../../util/DragManager'; +import { SelectionManager } from '../../../util/SelectionManager'; +import { SnappingManager } from '../../../util/SnappingManager'; +import { undoBatch } from '../../../util/UndoManager'; +import { CollectionFreeFormView, MarqueeOptionsMenu } from '../../collections/collectionFreeForm'; +import { CollectionStackingView } from '../../collections/CollectionStackingView'; +import { CollectionViewType } from '../../collections/CollectionView'; +import { ViewBoxAnnotatableComponent, ViewBoxAnnotatableProps } from '../../DocComponent'; +import { Colors } from '../../global/globalEnums'; +import { MarqueeAnnotator } from '../../MarqueeAnnotator'; +import { AnchorMenu } from '../../pdf/AnchorMenu'; +import { Annotation } from '../../pdf/Annotation'; +import { SidebarAnnos } from '../../SidebarAnnos'; +import { StyleProp } from '../../StyleProvider'; +import { FieldView, FieldViewProps } from '../FieldView'; +import * as dotenv from 'dotenv'; +import "./MapBox.scss"; + +/** + * MapBox architecture: + * Main component: MapBox.tsx + * Supporting Components: SidebarAnnos, CollectionStackingView + * + * MapBox is a node that extends the ViewBoxAnnotatableComponent. Similar to PDFBox and WebBox, it supports interaction between sidebar content and document content. + * The main body of MapBox uses Google Maps API to allow location retrieval, adding map markers, pan and zoom, and open street view. + * Dash Document architecture is integrated with Maps API: When drag and dropping documents with ExifData (gps Latitude and Longitude information) available, + * sidebarAddDocument function checks if the document contains lat & lng information, if it does, then the document is added to both the sidebar and the infowindow (a pop up corresponding to a map marker--pin on map). + * The lat and lng field of the document is filled when importing (spec see ConvertDMSToDD method and processFileUpload method in Documents.ts). + * A map marker is considered a document that contains a collection with stacking view of documents, it has a lat, lng location, which is passed to Maps API's custom marker (red pin) to be rendered on the google maps + */ + +// const _global = (window /* browser */ || global /* node */) as any; + +type MapDocument = makeInterface<[typeof documentSchema]>; +const MapDocument = makeInterface(documentSchema); + +const mapContainerStyle = { + height: '100%', +}; + +const defaultCenter = { + lat: 38.685, + lng: -115.234, +}; + +const mapOptions = { + fullscreenControl: false, +} + +dotenv.config({ path: __dirname + '/.env' }) +const apiKey = process.env.GOOGLE_MAPS; + +const script = document.createElement('script'); +script.defer = true; +script.async = true; +script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places,drawing`; +document.head.appendChild(script); + +/** + * Consider integrating later: allows for drawing, circling, making shapes on map + */ +// const drawingManager = new window.google.maps.drawing.DrawingManager({ +// drawingControl: true, +// drawingControlOptions: { +// position: google.maps.ControlPosition.TOP_RIGHT, +// drawingModes: [ +// google.maps.drawing.OverlayType.MARKER, +// // currently we are not supporting the following drawing mode on map, a thought for future development +// google.maps.drawing.OverlayType.CIRCLE, +// google.maps.drawing.OverlayType.POLYLINE, +// ], +// }, +// }); + + +// options for searchbox in Google Maps Places Autocomplete API +const options = { + fields: ["formatted_address", "geometry", "name"], // note: level of details is charged by item per retrieval, not recommended to return all fields + strictBounds: false, + types: ["establishment"], // type pf places, subject of change according to user need +} as google.maps.places.AutocompleteOptions; + +@observer +export class MapBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps & FieldViewProps & Partial<GoogleMapProps>, MapDocument>(MapDocument) { + + private _dropDisposer?: DragManager.DragDropDisposer; + private _disposers: { [name: string]: IReactionDisposer } = {}; + private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef(); + @observable private _overlayAnnoInfo: Opt<Doc>; + showInfo = action((anno: Opt<Doc>) => this._overlayAnnoInfo = anno); + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(MapBox, fieldKey); } + public get SidebarKey() { return this.fieldKey + "-sidebar"; } + private _setPreviewCursor: undefined | ((x: number, y: number, drag: boolean, hide: boolean) => void); + @computed get inlineTextAnnotations() { return this.allMapMarkers.filter(a => a.textInlineAnnotations); } + + @observable private _map: google.maps.Map = null as unknown as google.maps.Map; + @observable private selectedPlace: Doc | undefined; + @observable private markerMap: { [id: string]: google.maps.Marker } = {}; + // @observable private markerIdToMapMarker: { [id: string]: Doc | MapMarker | undefined } = {}; + @observable private center = navigator.geolocation ? navigator.geolocation.getCurrentPosition : defaultCenter; + @observable private zoom = 2.5; + @observable private _marqueeing: number[] | undefined; + @observable private _isAnnotating = false; + @observable private bounds = new window.google.maps.LatLngBounds(); + @observable private inputRef = React.createRef<HTMLInputElement>(); + @observable private searchMarkers: google.maps.Marker[] = []; + @observable private searchBox = new window.google.maps.places.Autocomplete(this.inputRef.current!, options); + @observable private _savedAnnotations = new ObservableMap<number, HTMLDivElement[]>(); + @computed get allSidebarDocs() { return DocListCast(this.dataDoc[this.SidebarKey]); }; + @computed get allMapMarkers() { return DocListCast(this.dataDoc[this.annotationKey]); }; + @observable private toggleAddMarker = false; + private _mainCont: React.RefObject<HTMLDivElement> = React.createRef(); + + + @observable _showSidebar = false; + @computed get SidebarShown() { return this._showSidebar || this.layoutDoc._showSidebar ? true : false; } + + static _canAnnotate = true; + static _hadSelection: boolean = false; + private _sidebarRef = React.createRef<SidebarAnnos>(); + private _ref: React.RefObject<HTMLDivElement> = React.createRef(); + + constructor(props: any) { + super(props); + } + + @action + private setSearchBox = (searchBox: any) => { + this.searchBox = searchBox; + } + + // iterate allMarkers to size, center, and zoom map to contain all markers + private fitBounds = (map: google.maps.Map) => { + console.log('map bound is:' + this.bounds); + this.allMapMarkers.map(place => { + this.bounds.extend({ lat: NumCast(place.lat), lng: NumCast(place.lng) }); + }); + map.fitBounds(this.bounds) + } + + /** + * Custom control for add marker button + * @param controlDiv + * @param map + */ + private CenterControl = (controlDiv: Element) => { + // Set CSS for the control border. + const controlUI = document.createElement("div"); + + controlUI.style.backgroundColor = "#fff"; + controlUI.style.border = "2px solid #fff"; + controlUI.style.borderRadius = "3px"; + controlUI.style.cursor = "pointer"; + controlUI.style.marginTop = "8px"; + controlUI.style.marginBottom = "22px"; + controlUI.style.textAlign = "center"; + controlUI.title = "Click to add marker to the location your pointer is at"; + controlDiv.appendChild(controlUI); + + // Set CSS for the control interior. + const controlText = document.createElement("div"); + + controlText.style.color = "rgb(25,25,25)"; + controlText.style.fontFamily = "Roboto,Arial,sans-serif"; + controlText.style.fontSize = "16px"; + controlText.style.lineHeight = "38px"; + controlText.style.paddingLeft = "5px"; + controlText.style.paddingRight = "5px"; + controlText.innerHTML = "Add Marker"; + controlUI.appendChild(controlText); + + // Setup the click event listeners + controlUI.addEventListener("click", () => { + if (this.toggleAddMarker == true) { + this.toggleAddMarker = false; + console.log("add marker button status:" + this.toggleAddMarker); + controlUI.style.backgroundColor = "#fff"; + controlText.style.color = "rgb(25,25,25)"; + } else { + this.toggleAddMarker = true; + console.log("add marker button status:" + this.toggleAddMarker); + controlUI.style.backgroundColor = "#4476f7"; + controlText.style.color = "rgb(255,255,255)"; + }; + }); + } + + /** + * Place the marker on google maps & store the empty marker as a MapMarker Document in allMarkers list + * @param position - the LatLng position where the marker is placed + * @param map + */ + @action + private placeMarker = (position: google.maps.LatLng, map: google.maps.Map) => { + const marker = new google.maps.Marker({ + position: position, + map: map + }); + map.panTo(position); + const mapMarker = Docs.Create.MapMarkerDocument(NumCast(position.lat()), NumCast(position.lng()), false, [], {}); + this.addDocument(mapMarker, this.annotationKey); + } + + + /** + * store a reference to google map instance + * setup the drawing manager on the top right corner of map + * fit map bounds to contain all markers + * @param map + */ + @action + private loadHandler = (map: google.maps.Map) => { + this._map = map; + const centerControlDiv = document.createElement("div"); + this.CenterControl(centerControlDiv); + map.controls[google.maps.ControlPosition.TOP_RIGHT].push(centerControlDiv); + //drawingManager.setMap(map); + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition( + (position: GeolocationPosition) => { + const pos = { + lat: position.coords.latitude, + lng: position.coords.longitude, + }; + this._map.setCenter(pos); + } + ); + } else { + alert("Your geolocation is not supported by browser.") + }; + console.log("all sidebar docs during map loading is:") + console.log(this.allSidebarDocs); + this.fitBounds(map); + + + // listener to addmarker event + this._map.addListener('click', (e) => { + console.log("add marker map status:" + this.toggleAddMarker); + if (this.toggleAddMarker == true) { + this.placeMarker(e.latLng, map) + console.log(this.allMapMarkers) + } + }) + } + + /** + * Load and render all map markers + * @param marker + * @param place + */ + @action + private markerLoadHandler = (marker: google.maps.Marker, place: Doc) => { + place[Id] ? this.markerMap[place[Id]] = marker : null; + + console.log("the following is a markerMap from id to Marker:") + console.log(this.markerMap); + } + + /** + * on clicking the map marker, set the selected place to the marker document & set infowindowopen to be true + * @param e + * @param place + */ + @action + private markerClickHandler = (e: MouseEvent, place: Doc) => { + // set which place was clicked + this.selectedPlace = place; + + console.log("you have selected this location:"); + console.log(this.selectedPlace); + + place.infoWindowOpen = true; + console.log("open infowindow") + } + + /** + * Called when dragging documents into map sidebar or directly into infowindow; to create a map marker, ref to MapMarkerDocument in Documents.ts + * @param doc + * @param sidebarKey + * @returns + */ + sidebarAddDocument = (doc: Doc | Doc[], sidebarKey?: string) => { + console.log("print all sidebar Docs"); + console.log(this.allSidebarDocs); + if (!this.layoutDoc._showSidebar) this.toggleSidebar(); + const docs = doc instanceof Doc ? [doc] : doc + docs.forEach(doc => { + if (doc.lat !== undefined && doc.lng !== undefined) { + const existingMarker = this.allMapMarkers.find(marker => marker.lat === doc.lat && marker.lng == doc.lng); + doc.onClickBehavior = "enterPortal"; + if (existingMarker) { + Doc.AddDocToList(existingMarker, "data", doc); + } else { + const marker = Docs.Create.MapMarkerDocument(NumCast(doc.lat), NumCast(doc.lng), false, [doc], {}); + this.addDocument(marker, this.annotationKey); + } + } + }) //add to annotation list + console.log("sidebaraddDocument"); + console.log(doc); + + return this.addDocument(doc, sidebarKey); // add to sidebar list + } + + /** + * Removing documents from the sidebar + * @param doc + * @param sidebarKey + * @returns + */ + sidebarRemoveDocument = (doc: Doc | Doc[], sidebarKey?: string) => { + if (this.layoutDoc._showSidebar) this.toggleSidebar(); + const docs = doc instanceof Doc ? [doc] : doc; + docs.forEach(doc => { + console.log(this.allMapMarkers); + console.log(this.allSidebarDocs); + }) + return this.removeDocument(doc, sidebarKey); + } + + /** + * Toggle sidebar onclick the tiny comment button on the top right corner + * @param e + */ + sidebarBtnDown = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, (e, down, delta) => { + const localDelta = this.props.ScreenToLocalTransform().scale(this.props.scaling?.() || 1).transformDirection(delta[0], delta[1]); + const nativeWidth = NumCast(this.layoutDoc[this.fieldKey + "-nativeWidth"]); + const curNativeWidth = NumCast(this.layoutDoc.nativeWidth, nativeWidth); + const ratio = (curNativeWidth + localDelta[0] / (this.props.scaling?.() || 1)) / nativeWidth; + if (ratio >= 1) { + this.layoutDoc.nativeWidth = nativeWidth * ratio; + this.layoutDoc._width = this.layoutDoc[WidthSym]() + localDelta[0]; + this.layoutDoc._showSidebar = nativeWidth !== this.layoutDoc._nativeWidth; + } + return false; + }, emptyFunction, this.toggleSidebar); + } + + sidebarWidth = () => Number(this.sidebarWidthPercent.substring(0, this.sidebarWidthPercent.length - 1)) / 100 * this.props.PanelWidth(); + @computed get sidebarWidthPercent() { return StrCast(this.layoutDoc._sidebarWidthPercent, "0%"); } + @computed get sidebarColor() { return StrCast(this.layoutDoc.sidebarColor, StrCast(this.layoutDoc[this.props.fieldKey + "-backgroundColor"], "#e4e4e4")); } + + + /** + * function that reads the place inputed from searchbox, then zoom in on the location that's been autocompleted; + * add a customized temporary marker on the map + */ + @action + private handlePlaceChanged = () => { + console.log(this.searchBox); + const place = this.searchBox.getPlace(); + + if (!place.geometry || !place.geometry.location) { + // user entered the name of a place that wasn't suggested & pressed the enter key, or place details request failed + window.alert("No details available for input: '" + place.name + "'"); + return; + } + + // zoom in on the location of the search result + if (place.geometry.viewport) { + console.log(this._map); + this._map.fitBounds(place.geometry.viewport); + } else { + console.log(this._map); + this._map.setCenter(place.geometry.location); + this._map.setZoom(17); + } + + // customize icon => customized icon for the nature of the location selected + const icon = { + url: place.icon as string, + size: new google.maps.Size(71, 71), + origin: new google.maps.Point(0, 0), + anchor: new google.maps.Point(17, 34), + scaledSize: new google.maps.Size(25, 25), + }; + + // put temporary cutomized marker on searched location + this.searchMarkers.forEach((marker) => { + marker.setMap(null); + }); + this.searchMarkers = []; + this.searchMarkers.push( + new window.google.maps.Marker({ + map: this._map, + icon, + title: place.name, + position: place.geometry.location, + }) + ) + } + + + @action + private handleInfoWindowClose = (place: Doc) => { + if (place.infoWindowOpen) { + place.infoWindowOpen = false; + } + place.infoWindowOpen = false; + } + + /** + * Handles toggle of sidebar on click the little comment button + */ + @computed get sidebarHandle() { + TraceMobx(); + const annotated = DocListCast(this.dataDoc[this.SidebarKey]).filter(d => d?.author).length; + const color = !annotated ? Colors.WHITE : Colors.BLACK; + const backgroundColor = !annotated ? this.sidebarWidth() ? Colors.MEDIUM_BLUE : Colors.BLACK : this.props.styleProvider?.(this.rootDoc, this.props as any, StyleProp.WidgetColor + (annotated ? ":annotated" : "")); + return (!annotated) ? (null) : + <div className="formattedTextBox-sidebar-handle" onPointerDown={this.sidebarDown} + style={{ + left: `max(0px, calc(100% - ${this.sidebarWidthPercent} - 17px))`, + backgroundColor: backgroundColor, + color: color, + opacity: annotated ? 1 : undefined + }} > + <FontAwesomeIcon icon={"comment-alt"} /> + </div>; + } + + // TODO: Adding highlight box layer to Maps + @action + toggleSidebar = () => { + const prevWidth = this.sidebarWidth(); + this.layoutDoc._showSidebar = ((this.layoutDoc._sidebarWidthPercent = StrCast(this.layoutDoc._sidebarWidthPercent, "0%") === "0%" ? "50%" : "0%")) !== "0%"; + this.layoutDoc._width = this.layoutDoc._showSidebar ? NumCast(this.layoutDoc._width) * 2 : Math.max(20, NumCast(this.layoutDoc._width) - prevWidth); + } + + sidebarDown = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, this.sidebarMove, emptyFunction, () => setTimeout(this.toggleSidebar), false); + } + sidebarMove = (e: PointerEvent, down: number[], delta: number[]) => { + const bounds = this._ref.current!.getBoundingClientRect(); + this.layoutDoc._sidebarWidthPercent = "" + 100 * Math.max(0, (1 - (e.clientX - bounds.left) / bounds.width)) + "%"; + this.layoutDoc._showSidebar = this.layoutDoc._sidebarWidthPercent !== "0%"; + e.preventDefault(); + return false; + } + + setPreviewCursor = (func?: (x: number, y: number, drag: boolean, hide: boolean) => void) => this._setPreviewCursor = func; + + @action + onMarqueeDown = (e: React.PointerEvent) => { + if (!e.altKey && e.button === 0 && this.props.isContentActive(true) && ![InkTool.Highlighter, InkTool.Pen].includes(CurrentUserUtils.SelectedTool)) { + setupMoveUpEvents(this, e, action(e => { + MarqueeAnnotator.clearAnnotations(this._savedAnnotations); + this._marqueeing = [e.clientX, e.clientY]; + return true; + }), returnFalse, () => MarqueeAnnotator.clearAnnotations(this._savedAnnotations), false); + } + } + @action finishMarquee = (x?: number, y?: number) => { + this._marqueeing = undefined; + this._isAnnotating = false; + x !== undefined && y !== undefined && this._setPreviewCursor?.(x, y, false, false); + } + + addDocumentWrapper = (doc: Doc | Doc[], annotationKey?: string) => { + return this.addDocument(doc, annotationKey); + } + + pointerEvents = () => this.props.isContentActive() && this.props.pointerEvents !== "none" && !MarqueeOptionsMenu.Instance.isShown() ? "all" : SnappingManager.GetIsDragging() ? undefined : "none"; + + @computed get annotationLayer() { + TraceMobx(); + const pe = this.pointerEvents(); + return <div className="mapBox-annotationLayer" style={{ height: Doc.NativeHeight(this.Document) || undefined }} ref={this._annotationLayer}> + {this.inlineTextAnnotations.sort((a, b) => NumCast(a.y) - NumCast(b.y)).map(anno => + <Annotation {...this.props} fieldKey={this.annotationKey} pointerEvents={pe} showInfo={this.showInfo} dataDoc={this.dataDoc} anno={anno} key={`${anno[Id]}-annotation`} />)} + </div>; + + } + + + getAnchor = () => { + const anchor = + AnchorMenu.Instance?.GetAnchor(this._savedAnnotations) ?? + this.rootDoc + return anchor; + } + + infoWidth = () => this.props.PanelWidth() / 5; + infoHeight = () => this.props.PanelWidth() / 5; + + // Collection stacking view for documents in the infowindow of a map marker + private renderChildDocs = (selectedDoc: Doc) => { + return <div style={{ width: this.infoWidth(), height: this.infoHeight() }}> + <CollectionStackingView { + ...OmitKeys(this.props, ["NativeWidth", "NativeHeight", "setContentView"]).omit} + Document={selectedDoc} + DataDoc={undefined} + NativeWidth={returnZero} + NativeHeight={returnZero} + PanelHeight={this.infoHeight} + PanelWidth={this.infoWidth} + docFilters={returnEmptyFilter} + setHeight={emptyFunction} + isAnnotationOverlay={false} + select={emptyFunction} + scaling={returnOne} + isContentActive={returnTrue} + chromeHidden={true} + rootSelected={returnFalse} + whenChildContentsActiveChanged={this.props.whenChildContentsActiveChanged} + childHideDecorationTitle={returnTrue} + // childDocumentsActive={returnFalse} + removeDocument={this.removeDocument} + moveDocument={this.moveDocument} + addDocument={this.addDocument} + CollectionView={undefined} + ScreenToLocalTransform={this.props.ScreenToLocalTransform} + renderDepth={this.props.renderDepth + 1} + viewType={CollectionViewType.Stacking} + fieldKey={"data"} + pointerEvents={"all"} + /></div>; + } + + /** + * render contents in allMapMarkers (e.g. images with exifData) into google maps as map marker + * @returns + */ + private renderMarkers = () => { + return this.allMapMarkers.map(place => ( + <Marker + key={place[Id]} + position={{ lat: NumCast(place.lat), lng: NumCast(place.lng) }} + onLoad={marker => this.markerLoadHandler(marker, place)} + onClick={e => this.markerClickHandler(e, place)} + /> + )) + } + + /** + * Renders infowindow corresponding to a map marker document + * @param place + * @returns + */ + private renderInfoWindow = (place: Doc) => { + + return place.infoWindowOpen && ( + <InfoWindow + key={place[Id]} + anchor={this.markerMap[place[Id]]} + onCloseClick={() => this.handleInfoWindowClose(place)} + > + <div className="mapbox-infowindow" style={{ backgroundColor: 'white', opacity: 0.75, padding: 12, fontSize: 17 }}> + {this.renderChildDocs(place)} + <hr /> + <div> + <button>New link+</button> + </div> + </div> + </InfoWindow> + ) + } + + // TODO: auto center on select a document in the sidebar + private handleMapCenter = (map: google.maps.Map) => { + console.log("print the selected views in selectionManager:") + if (SelectionManager.Views().lastElement()) { + console.log(SelectionManager.Views().lastElement()); + } + } + + panelWidth = () => this.props.PanelWidth() / (this.props.scaling?.() || 1) - this.sidebarWidth(); // (this.Document.scrollHeight || Doc.NativeHeight(this.Document) || 0); + panelHeight = () => this.props.PanelHeight() / (this.props.scaling?.() || 1); // () => this._pageSizes.length && this._pageSizes[0] ? this._pageSizes[0].width : Doc.NativeWidth(this.Document); + scrollXf = () => this.props.ScreenToLocalTransform().translate(0, NumCast(this.layoutDoc._scrollTop)); + transparentFilter = () => [...this.props.docFilters(), Utils.IsTransparentFilter()]; + opaqueFilter = () => [...this.props.docFilters(), Utils.IsOpaqueFilter()]; + + anchorMenuClick = () => this._sidebarRef.current?.anchorMenuClick; + + render() { + const renderAnnotations = (docFilters?: () => string[]) => + <CollectionFreeFormView {...OmitKeys(this.props, ["NativeWidth", "NativeHeight", "setContentView"]).omit} + renderDepth={this.props.renderDepth + 1} + isAnnotationOverlay={true} + fieldKey={this.annotationKey} + CollectionView={undefined} + setPreviewCursor={this.setPreviewCursor} + PanelWidth={this.panelWidth} + PanelHeight={this.panelHeight} + ScreenToLocalTransform={this.scrollXf} + scaling={returnOne} + dropAction={"alias"} + docFilters={docFilters || this.props.docFilters} + dontRenderDocuments={docFilters ? false : true} + select={emptyFunction} + ContentScaling={returnOne} + bringToFront={emptyFunction} + whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} + removeDocument={this.removeDocument} + moveDocument={this.moveDocument} + addDocument={this.sidebarAddDocument} + childPointerEvents={true} + pointerEvents={CurrentUserUtils.SelectedTool !== InkTool.None || this._isAnnotating || SnappingManager.GetIsDragging() ? "all" : "none"} />; + return <div className="mapBox" ref={this._ref}> + {console.log(apiKey)} + {/* <LoadScript + googleMapsApiKey={apiKey!} + libraries={['places', 'drawing']} + > */} + <div className="mapBox-wrapper" + onWheel={e => e.stopPropagation()} + onPointerDown={e => (e.button === 0 && !e.ctrlKey) && e.stopPropagation()} + style={{ width: `calc(100% - ${this.sidebarWidthPercent})` }}> + + <div style={{ mixBlendMode: "multiply" }}> + {renderAnnotations(this.transparentFilter)} + </div> + {renderAnnotations(this.opaqueFilter)} + {SnappingManager.GetIsDragging() ? (null) : renderAnnotations()} + {this.annotationLayer} + <GoogleMap + mapContainerStyle={mapContainerStyle} + zoom={this.zoom} + onLoad={map => this.loadHandler(map)} + options={mapOptions} + > + <Autocomplete + onLoad={this.setSearchBox} + onPlaceChanged={this.handlePlaceChanged}> + <input ref={this.inputRef} className="searchbox" type="text" placeholder="Search anywhere:" /> + </Autocomplete> + + {this.renderMarkers()} + {this.allMapMarkers.map(place => ( + this.renderInfoWindow(place) + ))} + {this.handleMapCenter(this._map)} + </GoogleMap> + {!this._marqueeing || !this._mainCont.current || !this._annotationLayer.current ? (null) : + <MarqueeAnnotator rootDoc={this.rootDoc} + anchorMenuClick={this.anchorMenuClick} + scrollTop={0} + down={this._marqueeing} scaling={returnOne} + addDocument={this.addDocumentWrapper} + docView={this.props.docViewPath().lastElement()} + finishMarquee={this.finishMarquee} + savedAnnotations={this._savedAnnotations} + annotationLayer={this._annotationLayer.current} + mainCont={this._mainCont.current} />} + </div> + {/* </LoadScript > */} + <div className="mapBox-sidebar" + style={{ width: `${this.sidebarWidthPercent}`, backgroundColor: `${this.sidebarColor}` }}> + <SidebarAnnos ref={this._sidebarRef} + {...this.props} + fieldKey={this.fieldKey} + rootDoc={this.rootDoc} + layoutDoc={this.layoutDoc} + dataDoc={this.dataDoc} + showSidebar={this.SidebarShown} + nativeWidth={NumCast(this.layoutDoc._nativeWidth)} + whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} + PanelWidth={this.sidebarWidth} + sidebarAddDocument={this.sidebarAddDocument} + moveDocument={this.moveDocument} + removeDocument={this.sidebarRemoveDocument} + /> + </div> + <div className="mapBox-overlayButton-sidebar" key="sidebar" title="Toggle Sidebar" + style={{ + display: !this.props.isContentActive() ? "none" : undefined, + top: StrCast(this.rootDoc._showTitle) === "title" ? 20 : 5, + backgroundColor: this.SidebarShown ? Colors.MEDIUM_BLUE : Colors.BLACK + }} + onPointerDown={this.sidebarBtnDown} > + <FontAwesomeIcon style={{ color: Colors.WHITE }} icon={"comment-alt"} size="sm" /> + </div> + </div>; + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx index 30b4dc92a..d54b65d92 100644 --- a/src/client/views/nodes/PDFBox.tsx +++ b/src/client/views/nodes/PDFBox.tsx @@ -136,6 +136,8 @@ export class PDFBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps } searchStringChanged = (e: React.ChangeEvent<HTMLInputElement>) => this._searchString = e.currentTarget.value; + // adding external documents; to sidebar key + // if (doc.Geolocation) this.addDocument(doc, this.fieldkey+"-annotation") sidebarAddDocument = (doc: Doc | Doc[], sidebarKey?: string) => { if (!this.layoutDoc._showSidebar) this.toggleSidebar(); return this.addDocument(doc, sidebarKey); diff --git a/src/client/views/nodes/ScreenshotBox.tsx b/src/client/views/nodes/ScreenshotBox.tsx index 7ad96bf05..0c631e5f9 100644 --- a/src/client/views/nodes/ScreenshotBox.tsx +++ b/src/client/views/nodes/ScreenshotBox.tsx @@ -313,7 +313,7 @@ export class ScreenshotBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatabl PanelHeight={this.formattedPanelHeight} isAnnotationOverlay={true} select={emptyFunction} - isContentActive={returnFalse} + isContentActive={emptyFunction} scaling={returnOne} xPadding={25} yPadding={10} diff --git a/src/client/views/nodes/VideoBox.scss b/src/client/views/nodes/VideoBox.scss index 4871599b8..f0d7bd2f3 100644 --- a/src/client/views/nodes/VideoBox.scss +++ b/src/client/views/nodes/VideoBox.scss @@ -48,10 +48,18 @@ width: 100%; z-index: -1; // 0; // logically this should be 0 (or unset) which would give us transparent brush strokes over videos. However, this makes Chrome crawl to a halt position: absolute; + video { + width: auto; + height: 100%; + display: flex; + margin: auto; + } } .videoBox-content, .videoBox-content-interactive, .videoBox-content-fullScreen { - height: Auto; + width: 100%; + height: 100%; + left: 0px; } .videoBox-content-YouTube, .videoBox-content-YouTube-fullScreen { diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index 440ccf638..615d595c0 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -307,12 +307,11 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp @computed get content() { const field = Cast(this.dataDoc[this.fieldKey], VideoField); const interactive = CurrentUserUtils.SelectedTool !== InkTool.None || !this.props.isSelected() ? "" : "-interactive"; - const style = "videoBox-content" + (this._fullScreen ? "-fullScreen" : "") + interactive; + const classname = "videoBox-content" + (this._fullScreen ? "-fullScreen" : "") + interactive; return !field ? <div key="loading">Loading</div> : - <div className="container" key="container" style={{ mixBlendMode: "multiply", pointerEvents: this.props.isContentActive() ? "all" : "none" }}> - <div className={`${style}`} style={{ width: "100%", height: "100%", left: "0px" }}> + <div className="videoBox-contentContainer" key="container" style={{ mixBlendMode: "multiply" }}> + <div className={classname}> <video key="video" autoPlay={this._screenCapture} ref={this.setVideoRef} - style={{ height: "100%", width: "auto", display: "flex", margin: "auto" }} onCanPlay={this.videoLoad} controls={VideoBox._nativeControls} onPlay={() => this.Play()} @@ -457,11 +456,11 @@ export class VideoBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp @computed get youtubeContent() { this._youtubeIframeId = VideoBox._youtubeIframeCounter++; this._youtubeContentCreated = this._forceCreateYouTubeIFrame ? true : true; - const style = "videoBox-content-YouTube" + (this._fullScreen ? "-fullScreen" : ""); + const classname = "videoBox-content-YouTube" + (this._fullScreen ? "-fullScreen" : ""); const start = untracked(() => Math.round((this.layoutDoc._currentTimecode || 0))); return <iframe key={this._youtubeIframeId} id={`${this.youtubeVideoId + this._youtubeIframeId}-player`} onPointerLeave={this.updateTimecode} - onLoad={this.youtubeIframeLoaded} className={`${style}`} width={Doc.NativeWidth(this.layoutDoc) || 640} height={Doc.NativeHeight(this.layoutDoc) || 390} + onLoad={this.youtubeIframeLoaded} className={classname} width={Doc.NativeWidth(this.layoutDoc) || 640} height={Doc.NativeHeight(this.layoutDoc) || 390} src={`https://www.youtube.com/embed/${this.youtubeVideoId}?enablejsapi=1&rel=0&showinfo=1&autoplay=0&mute=1&start=${start}&modestbranding=1&controls=${VideoBox._nativeControls ? 1 : 0}`} />; } diff --git a/src/client/views/nodes/button/FontIconBox.tsx b/src/client/views/nodes/button/FontIconBox.tsx index 33fa23805..14b1cbb5d 100644 --- a/src/client/views/nodes/button/FontIconBox.tsx +++ b/src/client/views/nodes/button/FontIconBox.tsx @@ -766,7 +766,7 @@ Scripting.addGlobal(function setActiveInkTool(tool: string, checkResult?: boolea Doc.UserDoc().activeInkTool = InkTool.Pen; GestureOverlay.Instance.InkShape = tool; } - } else if (tool) { // pen + } else if (tool) { // pen or eraser if (Doc.UserDoc().activeInkTool === tool && !GestureOverlay.Instance.InkShape) { Doc.UserDoc().activeInkTool = InkTool.None; } else { diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index aa53f751d..e61f96852 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -120,10 +120,11 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp public ProseRef?: HTMLDivElement; public get EditorView() { return this._editorView; } public get SidebarKey() { return this.fieldKey + "-sidebar"; } + @computed get allSidebarDocs() { return DocListCast(this.dataDoc[this.SidebarKey]); }; @computed get sidebarWidthPercent() { return this._showSidebar ? "20%" : StrCast(this.layoutDoc._sidebarWidthPercent, "0%"); } @computed get sidebarColor() { return StrCast(this.layoutDoc.sidebarColor, StrCast(this.layoutDoc[this.props.fieldKey + "-backgroundColor"], "#e4e4e4")); } - @computed get autoHeight() { return this.layoutDoc._autoHeight && !this.props.ignoreAutoHeight; } + @computed get autoHeight() { return (this.props.forceAutoHeight || this.layoutDoc._autoHeight) && !this.props.ignoreAutoHeight; } @computed get textHeight() { return NumCast(this.rootDoc[this.fieldKey + "-height"]); } @computed get scrollHeight() { return NumCast(this.rootDoc[this.fieldKey + "-scrollHeight"]); } @computed get sidebarHeight() { return !this.sidebarWidth() ? 0 : NumCast(this.rootDoc[this.SidebarKey + "-height"]); } @@ -1140,10 +1141,16 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp 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 { state, dispatch } = this._editorView; + if (!rtfField) { + const startupText = Field.toString(this.dataDoc[fieldKey] as Field); + if (startupText) { + dispatch(state.tr.insertText(startupText)); + } else if (!FormattedTextBox.LiveTextUndo) { + selectAll(this._editorView.state, (tr) => { + this._editorView!.dispatch(tr.replaceSelectionWith(state.schema.nodes.paragraph.create({ align: "center" }))); + }); + } } (this._editorView as any).TextView = this; } @@ -1174,8 +1181,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp ...(Doc.UserDoc().fontColor !== "transparent" && Doc.UserDoc().fontColor ? [schema.mark(schema.marks.pFontColor, { color: StrCast(Doc.UserDoc().fontColor) })] : []), ...(Doc.UserDoc().fontStyle === "italics" ? [schema.mark(schema.marks.em)] : []), ...(Doc.UserDoc().textDecoration === "underline" ? [schema.mark(schema.marks.underline)] : []), - ...(Doc.UserDoc().fontFamily ? [schema.mark(schema.marks.pFontFamily, { family: StrCast(Doc.UserDoc().fontFamily) })] : []), - ...(Doc.UserDoc().fontSize ? [schema.mark(schema.marks.pFontSize, { fontSize: StrCast(Doc.UserDoc().fontSize, "") })] : []), + ...(Doc.UserDoc().fontFamily ? [schema.mark(schema.marks.pFontFamily, { family: this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.FontFamily) })] : []), + ...(Doc.UserDoc().fontSize ? [schema.mark(schema.marks.pFontSize, { fontSize: this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.FontSize) })] : []), ...(Doc.UserDoc().fontWeight === "bold" ? [schema.mark(schema.marks.strong)] : [])]; } } @@ -1496,6 +1503,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp sidebarContentScaling = () => (this.props.scaling?.() || 1) * NumCast(this.layoutDoc._viewScale, 1); sidebarAddDocument = (doc: Doc | Doc[], sidebarKey?: string) => { if (!this.layoutDoc._showSidebar) this.toggleSidebar(); + // console.log("printting allSideBarDocs"); + // console.log(this.allSidebarDocs); return this.addDocument(doc, sidebarKey); } sidebarMoveDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (doc: Doc | Doc[]) => boolean) => this.moveDocument(doc, targetCollection, addDocument, this.SidebarKey); @@ -1514,6 +1523,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp const annotated = DocListCast(this.dataDoc[this.SidebarKey]).filter(d => d?.author).length; const color = !annotated ? Colors.WHITE : Colors.BLACK; const backgroundColor = !annotated ? this.sidebarWidth() ? Colors.MEDIUM_BLUE : Colors.BLACK : this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.WidgetColor + (annotated ? ":annotated" : "")); + return (!annotated && (!this.props.isContentActive() || SnappingManager.GetIsDragging())) ? (null) : <div className="formattedTextBox-sidebar-handle" onPointerDown={this.sidebarDown} style={{ @@ -1535,6 +1545,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp rootDoc={this.rootDoc} layoutDoc={this.layoutDoc} dataDoc={this.dataDoc} + // usePanelWidth={true} nativeWidth={NumCast(this.layoutDoc._nativeWidth)} showSidebar={this.SidebarShown} PanelWidth={this.sidebarWidth} @@ -1565,7 +1576,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp setHeight={this.setSidebarHeight} fitContentsToDoc={this.fitToBox} noSidebar={true} - fieldKey={this.layoutDoc.sidebarViewType === "translation" ? `${this.fieldKey}-translation` : this.SidebarKey} />; + fieldKey={this.layoutDoc.sidebarViewType === "translation" ? `${this.fieldKey}-translation` : `${this.fieldKey}-annotations`} />; }; return <div className={"formattedTextBox-sidebar" + (CurrentUserUtils.SelectedTool !== InkTool.None ? "-inking" : "")} style={{ width: `${this.sidebarWidthPercent}`, backgroundColor: `${this.sidebarColor}` }}> @@ -1581,10 +1592,10 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp const interactive = (CurrentUserUtils.SelectedTool === InkTool.None || SnappingManager.GetIsDragging()) && (this.layoutDoc.z || this.props.layerProvider?.(this.layoutDoc) !== false); if (!selected && FormattedTextBoxComment.textBox === this) setTimeout(FormattedTextBoxComment.Hide); const minimal = this.props.ignoreAutoHeight; - const margins = NumCast(this.layoutDoc._yMargin, this.props.yPadding || 0); - const selPad = Math.min(margins, 10); - const padding = Math.max(margins + ((selected && !this.layoutDoc._singleLine) || minimal ? -selPad : 0), 0); - const selPaddingClass = selected && !this.layoutDoc._singleLine && margins >= 10 ? "-selected" : ""; + const paddingX = NumCast(this.layoutDoc._xMargin, this.props.xPadding || 0); + const paddingY = NumCast(this.layoutDoc._yMargin, this.props.yPadding || 0); + const selPad = ((selected && !this.layoutDoc._singleLine) || minimal ? Math.min(paddingY, Math.min(paddingX, 10)) : 0); + const selPaddingClass = selected && !this.layoutDoc._singleLine && paddingY >= 10 ? "-selected" : ""; const styleFromString = this.styleFromLayoutString(scale); // this converts any expressions in the format string to style props. e.g., <FormattedTextBox height='{this._headerHeight}px' > return (styleFromString?.height === "0px" ? (null) : <div className="formattedTextBox-cont" @@ -1628,7 +1639,11 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProp onScroll={this.onScroll} onDrop={this.ondrop} > <div className={minimal ? "formattedTextBox-minimal" : `formattedTextBox-inner${rounded}${selPaddingClass}`} ref={this.createDropTarget} style={{ - padding: StrCast(this.layoutDoc._textBoxPadding, `${padding}px`), + padding: StrCast(this.layoutDoc._textBoxPadding), + paddingLeft: StrCast(this.layoutDoc._textBoxPaddingX, `${paddingX - selPad}px`), + paddingRight: StrCast(this.layoutDoc._textBoxPaddingX, `${paddingX - selPad}px`), + paddingTop: StrCast(this.layoutDoc._textBoxPaddingY, `${paddingY - selPad}px`), + paddingBottom: StrCast(this.layoutDoc._textBoxPaddingY, `${paddingY - selPad}px`), pointerEvents: !active && !SnappingManager.GetIsDragging() ? (this.layoutDoc.isLinkButton ? "none" : undefined) : undefined }} /> diff --git a/src/client/views/nodes/trails/PresBox.tsx b/src/client/views/nodes/trails/PresBox.tsx index 2a153f256..14d6e8be6 100644 --- a/src/client/views/nodes/trails/PresBox.tsx +++ b/src/client/views/nodes/trails/PresBox.tsx @@ -46,6 +46,11 @@ const PresBoxDocument = makeInterface(documentSchema); export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema>(PresBoxDocument) { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(PresBox, fieldKey); } + /** + * transitions & effects for documents + * @param renderDoc + * @param layoutDoc + */ static renderEffectsDoc(renderDoc: any, layoutDoc: Doc) { const effectProps = { left: layoutDoc.presEffectDirection === PresEffect.Left, @@ -223,6 +228,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> } } + //TODO: al: it seems currently that tempMedia doesn't stop onslidechange after clicking the button; the time the tempmedia stop depends on the start & end time // No more frames in current doc and next slide is defined, therefore move to next slide nextSlide = (activeNext: Doc) => { const targetNext = Cast(activeNext.presentationTargetDoc, Doc, null); @@ -416,7 +422,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> /** * Uses the viewfinder to progressivize through the different views of a single collection. - * @param presTargetDoc: document for which internal zoom is used + * @param activeItem: document for which internal zoom is used */ zoomProgressivizeNext = (activeItem: Doc) => { const targetDoc: Doc = this.targetDoc; @@ -532,6 +538,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> load(); } + // The function pauses the auto presentation @action pauseAutoPres = () => { if (this.layoutDoc.presStatus === PresStatus.Autoplay) { @@ -555,6 +562,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> }); } + // The function allows for viewing the pres path on toggle @action togglePath = (srcContext: Doc, off?: boolean) => { if (off) { this._pathBoolean = false; @@ -565,6 +573,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> } } + // The function allows for expanding the view of pres on toggle @action toggleExpandMode = () => { runInAction(() => this._expandBoolean = !this._expandBoolean); this.rootDoc.expandBoolean = this._expandBoolean; @@ -1844,6 +1853,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema> case DocumentType.VID: type = "Video"; break; case DocumentType.IMG: type = "Image"; break; case DocumentType.WEB: type = "Web page"; break; + case DocumentType.MAP: type = "Map"; break; default: type = "Other node"; break; } } diff --git a/src/client/views/nodes/trails/PresElementBox.tsx b/src/client/views/nodes/trails/PresElementBox.tsx index 5e713c3cf..238d025dc 100644 --- a/src/client/views/nodes/trails/PresElementBox.tsx +++ b/src/client/views/nodes/trails/PresElementBox.tsx @@ -166,6 +166,9 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps, PresDoc e.preventDefault(); } + /** + * Function to drag and drop the pres element to a diferent location + */ startDrag = (e: PointerEvent) => { const miniView: boolean = this.toolbarWidth <= 100; const activeItem = this.rootDoc; @@ -244,6 +247,7 @@ export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps, PresDoc e.stopPropagation(); }); + // set the value/title of the individual pres element @undoBatch @action onSetValue = (value: string) => { diff --git a/src/client/views/search/IconBar.tsx b/src/client/views/search/IconBar.tsx index f1dd106a7..6103b245c 100644 --- a/src/client/views/search/IconBar.tsx +++ b/src/client/views/search/IconBar.tsx @@ -14,7 +14,7 @@ export interface IconBarProps { @observer export class IconBar extends React.Component<IconBarProps> { - public _allIcons: string[] = [DocumentType.AUDIO, DocumentType.COL, DocumentType.IMG, DocumentType.LINK, DocumentType.PDF, DocumentType.RTF, DocumentType.VID, DocumentType.WEB]; + public _allIcons: string[] = [DocumentType.AUDIO, DocumentType.COL, DocumentType.IMG, DocumentType.LINK, DocumentType.PDF, DocumentType.RTF, DocumentType.VID, DocumentType.WEB, DocumentType.MAP]; @observable private _icons: string[] = this._allIcons; diff --git a/src/client/views/search/IconButton.tsx b/src/client/views/search/IconButton.tsx index 2dd6b1b79..6cf3a5f72 100644 --- a/src/client/views/search/IconButton.tsx +++ b/src/client/views/search/IconButton.tsx @@ -70,6 +70,7 @@ export class IconButton extends React.Component<IconButtonProps>{ case (DocumentType.RTF): return "sticky-note"; case (DocumentType.VID): return "video"; case (DocumentType.WEB): return "globe-asia"; + case (DocumentType.MAP): return "map-marker-alt"; default: return "caret-down"; } } diff --git a/src/client/views/search/SearchBox.tsx b/src/client/views/search/SearchBox.tsx index 3612bd7c4..09cfb2077 100644 --- a/src/client/views/search/SearchBox.tsx +++ b/src/client/views/search/SearchBox.tsx @@ -1,7 +1,7 @@ import { action, computed, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { Doc, DocListCast, DocListCastAsync, Field } from '../../../fields/Doc'; +import { DirectLinksSym, Doc, DocListCast, DocListCastAsync, Field } from '../../../fields/Doc'; import { documentSchema } from "../../../fields/documentSchemas"; import { Id } from '../../../fields/FieldSymbols'; import { createSchema, makeInterface } from '../../../fields/Schema'; @@ -14,6 +14,8 @@ import "./SearchBox.scss"; import { DocumentManager } from '../../util/DocumentManager'; import { DocUtils } from '../../documents/Documents'; import { Tooltip } from "@material-ui/core"; +import { DictationOverlay } from '../DictationOverlay'; +import { CollectionSchemaBooleanCell } from '../collections/collectionSchema/CollectionSchemaCells'; export const searchSchema = createSchema({ Document: Doc @@ -22,6 +24,10 @@ export const searchSchema = createSchema({ type SearchBoxDocument = makeInterface<[typeof documentSchema, typeof searchSchema]>; const SearchBoxDocument = makeInterface(documentSchema, searchSchema); +const DAMPENING_FACTOR = 0.9; +const MAX_ITERATIONS = 25; +const ERROR = 0.03; + export interface SearchBoxProps extends FieldViewProps { linkSearch: boolean; linkFrom?: (() => Doc | undefined) | undefined; @@ -40,7 +46,10 @@ export class SearchBox extends ViewBoxBaseComponent<SearchBoxProps, SearchBoxDoc @observable _searchString = ""; @observable _docTypeString = "all"; - @observable _results: [Doc, string[]][] = []; + @observable _results: Map<Doc, string[]> = new Map<Doc, string[]>(); + @observable _pageRanks: Map<Doc, number> = new Map<Doc, number>(); + @observable _linkedDocsOut: Map<Doc, Set<Doc>> = new Map<Doc, Set<Doc>>(); + @observable _linkedDocsIn: Map<Doc, Set<Doc>> = new Map<Doc, Set<Doc>>(); @observable _selectedResult: Doc | undefined = undefined; @observable _deletedDocsStatus: boolean = false; @observable _onlyAliases: boolean = true; @@ -110,11 +119,9 @@ export class SearchBox extends ViewBoxBaseComponent<SearchBoxProps, SearchBoxDoc }); makeLink = action((linkTo: Doc) => { - console.log(linkTo.title); if (this.props.linkFrom) { const linkFrom = this.props.linkFrom(); if (linkFrom) { - console.log(linkFrom.title); DocUtils.MakeLink({ doc: linkFrom }, { doc: linkTo }, "Link"); } } @@ -204,7 +211,7 @@ export class SearchBox extends ViewBoxBaseComponent<SearchBoxProps, SearchBoxDoc const collection = CollectionDockingView.Instance; query = query.toLowerCase(); - this._results = []; + this._results.clear(); this._selectedResult = undefined; if (collection !== undefined) { @@ -216,16 +223,114 @@ export class SearchBox extends ViewBoxBaseComponent<SearchBoxProps, SearchBoxDoc const hlights = new Set<string>(); SearchBox.documentKeys(doc).forEach(key => Field.toString(doc[key] as Field).toLowerCase().includes(query) && hlights.add(key)); blockedKeys.forEach(key => hlights.delete(key)); - Array.from(hlights.keys()).length > 0 && this._results.push([doc, Array.from(hlights.keys())]); + + if (Array.from(hlights.keys()).length > 0) { + this._results.set(doc, Array.from(hlights.keys())); + } } docIDs.push(doc[Id]); }); } + + this.computePageRanks(); + } + + /** + * This method initializes the page rank of every document to the reciprocal + * of the number of documents in the collection. + */ + @action + initializePageRanks() { + this._pageRanks.clear(); + this._linkedDocsOut.clear(); + + this._results.forEach((_, doc) => { + this._linkedDocsIn.set(doc, new Set()); + }); + + this._results.forEach((_, doc) => { + this._pageRanks.set(doc, 1.0 / this._results.size); + + if (Doc.GetProto(doc)[DirectLinksSym].size === 0) { + this._linkedDocsOut.set(doc, new Set(this._results.keys())); + + this._results.forEach((_, linkedDoc) => { + this._linkedDocsIn.get(linkedDoc)?.add(doc); + }); + } + else { + const linkedDocSet = new Set<Doc>(); + + Doc.GetProto(doc)[DirectLinksSym].forEach((link) => { + const d1 = link?.anchor1 as Doc; + const d2 = link?.anchor2 as Doc; + if (doc === d1 && this._results.has(d2)) { + linkedDocSet.add(d2); + this._linkedDocsIn.get(d2)?.add(doc); + } + else if (doc === d2 && this._results.has(d1)) { + linkedDocSet.add(d1); + this._linkedDocsIn.get(d1)?.add(doc); + } + }); + + this._linkedDocsOut.set(doc, linkedDocSet); + } + }); + } + + /** + * This method runs one complete iteration of the page rank algorithm. It + * returns true iff all page ranks have converged (i.e. changed by less than + * the _error value), which means that the algorithm should terminate. + * + * @return true if page ranks have converged; false otherwise + */ + @action + pageRankIteration(): boolean { + let converged = true; + const pageRankFromAll = (1 - DAMPENING_FACTOR) / this._results.size; + + const nextPageRanks = new Map<Doc, number>(); + + this._results.forEach((_, doc) => { + let nextPageRank = pageRankFromAll; + + this._linkedDocsIn.get(doc)?.forEach((linkedDoc) => { + nextPageRank += DAMPENING_FACTOR * (this._pageRanks.get(linkedDoc) ?? 0) / (this._linkedDocsOut.get(linkedDoc)?.size ?? 1); + }); + + nextPageRanks.set(doc, nextPageRank); + + if (Math.abs(nextPageRank - (this._pageRanks.get(doc) ?? 0)) > ERROR) { + converged = false; + } + }); + + this._pageRanks = nextPageRanks; + + return converged; + } + + /** + * This method performs the page rank algorithm on the graph of documents + * that match the search query. Vertices are documents and edges are links + * between documents. + */ + @action + computePageRanks() { + this.initializePageRanks(); + + for (let i = 0; i < MAX_ITERATIONS; i++) { + if (this.pageRankIteration()) { + break; + } + } } /** * @param {Doc} doc - doc for which keys are returned - * + * * This method returns a list of a document doc's keys. */ static documentKeys(doc: Doc) { @@ -244,7 +349,7 @@ export class SearchBox extends ViewBoxBaseComponent<SearchBoxProps, SearchBoxDoc const query = StrCast(this._searchString); Doc.SetSearchQuery(query); - this._results = []; + this._results.clear(); if (query) { this.searchCollection(query); @@ -256,16 +361,16 @@ export class SearchBox extends ViewBoxBaseComponent<SearchBoxProps, SearchBoxDoc * brushes and highlights. All search matches are cleared as well. */ resetSearch = action(() => { - this._results.forEach(result => { - Doc.UnBrushDoc(result[0]); - Doc.UnHighlightDoc(result[0]); + this._results.forEach((_, doc) => { + Doc.UnBrushDoc(doc); + Doc.UnHighlightDoc(doc); Doc.ClearSearchMatches(); }); }); /** * @param {Doc} doc - doc to be selected - * + * * This method selects a doc by either jumping to it (centering/zooming in on it) * or opening it in a new tab. */ @@ -292,8 +397,11 @@ export class SearchBox extends ViewBoxBaseComponent<SearchBoxProps, SearchBoxDoc const isLinkSearch: boolean = this.props.linkSearch; + const sortedResults = Array.from(this._results.entries()).sort((a, b) => (this._pageRanks.get(b[0]) ?? 0) - (this._pageRanks.get(a[0]) ?? 0)); // sorted by page rank + + const resultsJSX = Array(); - const results = this._results.map(result => { + sortedResults.forEach((result) => { var className = "searchBox-results-scroll-view-result"; if (this._selectedResult === result[0]) { @@ -305,7 +413,7 @@ export class SearchBox extends ViewBoxBaseComponent<SearchBoxProps, SearchBoxDoc if (this._docTypeString === "all" || this._docTypeString === result[0].type) { validResults++; - return ( + resultsJSX.push( <Tooltip key={result[0][Id]} placement={"right"} title={<><div className="dash-tooltip">{title}</div></>}> <div onClick={isLinkSearch ? () => this.makeLink(result[0]) : @@ -326,12 +434,8 @@ export class SearchBox extends ViewBoxBaseComponent<SearchBoxProps, SearchBoxDoc </Tooltip> ); } - - return null; }); - results.filter(result => result); - return ( <div style={{ pointerEvents: "all" }} className="searchBox-container"> <div className="searchBox-bar" > @@ -345,7 +449,7 @@ export class SearchBox extends ViewBoxBaseComponent<SearchBoxProps, SearchBoxDoc {`${validResults}` + " result" + (validResults === 1 ? "" : "s")} </div> <div className="searchBox-results-scroll-view"> - {results} + {resultsJSX} </div> </div> </div > |