diff options
-rw-r--r-- | src/client/util/CurrentUserUtils.ts | 1 | ||||
-rw-r--r-- | src/client/views/GestureOverlay.tsx | 2 | ||||
-rw-r--r-- | src/client/views/InkStrokeProperties.ts | 2 | ||||
-rw-r--r-- | src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx | 170 | ||||
-rw-r--r-- | src/client/views/nodes/button/FontIconBox.tsx | 2 | ||||
-rw-r--r-- | src/fields/InkField.ts | 11 |
6 files changed, 182 insertions, 6 deletions
diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts index c9bef5924..b9e62b303 100644 --- a/src/client/util/CurrentUserUtils.ts +++ b/src/client/util/CurrentUserUtils.ts @@ -1005,6 +1005,7 @@ export class CurrentUserUtils { static inkTools(doc: Doc) { const tools: Button[] = [ { title: "Pen", toolTip: "Pen (Ctrl+P)", btnType: ButtonType.ToggleButton, icon: "pen", click: 'setActiveInkTool("pen")', checkResult: 'setActiveInkTool("pen" , true)' }, + { title: "Eraser", toolTip: "Eraser (Ctrl+E)", btnType: ButtonType.ToggleButton, icon: "eraser", click: 'setActiveInkTool("eraser")', checkResult: 'setActiveInkTool("eraser" , true)' }, // { title: "Highlighter", toolTip: "Highlighter (Ctrl+H)", btnType: ButtonType.ToggleButton, icon: "highlighter", click: 'setActiveInkTool("highlighter")', checkResult: 'setActiveInkTool("highlighter", true)' }, { title: "Circle", toolTip: "Circle (Ctrl+Shift+C)", btnType: ButtonType.ToggleButton, icon: "circle", click: 'setActiveInkTool("circle")', checkResult: 'setActiveInkTool("circle" , true)' }, // { title: "Square", toolTip: "Square (Ctrl+Shift+S)", btnType: ButtonType.ToggleButton, icon: "square", click: 'setActiveInkTool("square")', checkResult: 'setActiveInkTool("square" , true)' }, diff --git a/src/client/views/GestureOverlay.tsx b/src/client/views/GestureOverlay.tsx index f28485e43..e2193c9ac 100644 --- a/src/client/views/GestureOverlay.tsx +++ b/src/client/views/GestureOverlay.tsx @@ -3,7 +3,7 @@ import * as fitCurve from 'fit-curve'; import { action, computed, observable, runInAction } from "mobx"; import { observer } from "mobx-react"; import { Doc } from "../../fields/Doc"; -import { InkData, InkTool } from "../../fields/InkField"; +import { InkData, InkTool, PointData } from "../../fields/InkField"; import { Cast, FieldValue, NumCast } from "../../fields/Types"; import MobileInkOverlay from "../../mobile/MobileInkOverlay"; import { GestureUtils } from "../../pen-gestures/GestureUtils"; diff --git a/src/client/views/InkStrokeProperties.ts b/src/client/views/InkStrokeProperties.ts index 6687b2bc7..02288bbb5 100644 --- a/src/client/views/InkStrokeProperties.ts +++ b/src/client/views/InkStrokeProperties.ts @@ -244,7 +244,7 @@ export class InkStrokeProperties { 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); + console.log("X = " + snapData.nearestPt.X + " " + snapData.nearestPt.Y); return res; } } diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 190d0e76a..ceee4051b 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -5,7 +5,7 @@ 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, Intersection, Segment } from "../../../../fields/InkField"; import { List } from "../../../../fields/List"; import { ObjectField } from "../../../../fields/ObjectField"; import { RichTextField } from "../../../../fields/RichTextField"; @@ -29,12 +29,12 @@ import { SearchUtil } from "../../../util/SearchUtil"; import { SelectionManager } from "../../../util/SelectionManager"; 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 { ActiveArrowEnd, ActiveArrowStart, ActiveDash, ActiveFillColor, ActiveInkBezierApprox, ActiveInkColor, ActiveInkWidth, InkingStroke, SetActiveInkWidth, SetActiveFillColor, SetActiveInkColor } from "../../InkingStroke"; import { LightboxView } from "../../LightboxView"; import { CollectionFreeFormDocumentView } from "../../nodes/CollectionFreeFormDocumentView"; import { DocFocusOptions, DocumentView, DocumentViewProps, ViewAdjustment, ViewSpecPrefix } from "../../nodes/DocumentView"; @@ -51,6 +51,9 @@ import "./CollectionFreeFormView.scss"; import { MarqueeView } from "./MarqueeView"; import React = require("react"); import { ColorScheme } from "../../../util/SettingsManager"; +import { Bezier } from "bezier-js"; +import { GestureOverlay } from "../../GestureOverlay"; +import { constants } from "perf_hooks"; export const panZoomSchema = createSchema({ _panX: "number", @@ -98,6 +101,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 +115,9 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P @observable _pullDirection: string = ""; @observable _showAnimTimeline = false; @observable _clusterSets: (Doc[])[] = []; + @observable _prevPoint: PointData = { X: -1, Y: -1 }; + @observable _currPoint: PointData = { X: -1, Y: -1 }; + @observable _deleteList: DocumentView[] = []; @observable _timelineRef = React.createRef<Timeline>(); @observable _marqueeRef = React.createRef<HTMLDivElement>(); @observable _keyframeEditing = false; @@ -449,6 +456,8 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P } // eraser plus anything else mode else { + this._batch = UndoManager.StartBatch("collectionErase"); + this._prevPoint = { X: e.clientX, Y: e.clientY }; e.stopPropagation(); e.preventDefault(); } @@ -592,12 +601,19 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P } } + @action onPointerUp = (e: PointerEvent): void => { if (!InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) { document.removeEventListener("pointermove", this.onPointerMove); document.removeEventListener("pointerup", this.onPointerUp); this.removeMoveListeners(); this.removeEndListeners(); + if (CurrentUserUtils.SelectedTool !== InkTool.None) { + this._deleteList.forEach(ink => ink.props.removeDocument?.(ink.rootDoc)); + this._prevPoint = this._currPoint = { X: -1, Y: -1 }; + this._deleteList = []; + this._batch?.end(); + } } } @@ -626,6 +642,11 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P 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 (CurrentUserUtils.SelectedTool !== InkTool.None) { + this._currPoint = { X: e.clientX, Y: e.clientY }; + // Erasing ink strokes if intersections occur. + this.eraseInkStrokes(this.getEraserIntersections()); + } if (InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) { if (this.props.isContentActive(true)) e.stopPropagation(); } else if (!e.cancelBubble) { @@ -641,6 +662,149 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P } } + /** + * Iterates through all intersected ink strokes, determines their segmentation, draws back the non-intersected segments, + * and deletes the original stroke. + * @param eraserIntersections The intersections made by the eraser. + */ + eraseInkStrokes = (eraserIntersections: Intersection[]) => { + eraserIntersections.forEach(intersect => { + const ink = intersect.ink; + if (ink && !this._deleteList.includes(ink)) { + this._deleteList.push(ink); + SetActiveInkWidth(StrCast(ink.rootDoc.strokeWidth?.toString()) || "1"); + SetActiveInkColor(StrCast(ink.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. + this.segmentInkStroke(ink, intersect.t ?? 0).forEach(segment => + GestureOverlay.Instance.dispatchGesture(GestureUtils.Gestures.Stroke, + segment.reduce((data, curve) => [...data, ...curve.points + .map(p => ink.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. + ink.layoutDoc.opacity = 0.5; + } + }); + } + + /** + * Determines if the Eraser tool has intersected with an ink stroke in the current freeform collection. + * @returns A dictionary mapping the t-value intersection of the eraser with the corresponding ink DocumentView. + */ + getEraserIntersections = (): Intersection[] => { + const intersections: Intersection[] = []; + this.childDocs + .filter(doc => doc.type === DocumentType.INK) + .forEach(doc => { + const inkView = DocumentManager.Instance.getDocumentView(doc, this.props.CollectionView); + const inkStroke = inkView?.ComponentView as InkingStroke; + const { inkData } = inkStroke?.inkScaledData(); + for (var i = 0; i < inkData.length - 3; i += 4) { + const array = inkData.slice(i, i + 4); + // Converting from screen space to ink space for the intersection. + const prevPointInkSpace = inkStroke?.ptFromScreen?.(this._prevPoint); + const currPointInkSpace = inkStroke?.ptFromScreen?.(this._currPoint); + if (prevPointInkSpace && currPointInkSpace) { + const curve = new Bezier(array.map(p => ({ x: p.X, y: p.Y }))); + const intersects = curve.intersects({ + p1: { x: prevPointInkSpace.X, y: prevPointInkSpace.Y }, + p2: { x: currPointInkSpace.X, y: currPointInkSpace.Y } + }); + if (inkView && intersects) { + for (const val of intersects) { + // Casting t-value from type: (string | number) to number for comparisons. + const t = +(Number(val) + Math.floor(i / 4)).toString(); // add start of curve segment to convert from local t value to t value along complete curve + var unique: boolean = true; + // Ensuring there are no duplicate intersections in the list returned. + for (const prevIntersect of intersections) { + if (prevIntersect.t === t) { + unique = false; + break; + } + } + if (unique) intersections.push({ t: +t.toString(), ink: inkView, curve: curve }); + } + } + } + } + }); + return intersections; + } + + /** + * 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 curve = new Bezier(inkData.slice(i, i + 4).map(p => ({ x: p.X, y: p.Y }))); + // Getting all t-value intersections of the current curve with all other curves. + const tVals = this.getInkIntersections(i, ink, curve).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(curve.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 ? [curve.split(t).right] : []; + startSegmentT = docCurveTVal; + }); + } else { + segment.push(curve); + } + } + 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(); + 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); 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/fields/InkField.ts b/src/fields/InkField.ts index f16e143d8..f61313674 100644 --- a/src/fields/InkField.ts +++ b/src/fields/InkField.ts @@ -3,6 +3,8 @@ import { Scripting } from "../client/util/Scripting"; import { Deserializable } from "../client/util/SerializationHelper"; import { Copy, ToScriptString, ToString } from "./FieldSymbols"; import { ObjectField } from "./ObjectField"; +import { DocumentView } from "../client/views/nodes/DocumentView"; +import { Bezier } from "bezier-js"; // Helps keep track of the current ink tool in use. export enum InkTool { @@ -20,6 +22,15 @@ export interface PointData { Y: number; } +export interface Intersection { + t?: number; + ink?: DocumentView; + curve?: Bezier; + index?: number; +} + +export type Segment = Array<Bezier>; + // Defines an ink as an array of points. export type InkData = Array<PointData>; |