diff options
Diffstat (limited to 'src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx')
| -rw-r--r-- | src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx | 232 |
1 files changed, 192 insertions, 40 deletions
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 |
