diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/Utils.ts | 4 | ||||
-rw-r--r-- | src/client/documents/Documents.ts | 4 | ||||
-rw-r--r-- | src/client/util/CurrentUserUtils.ts | 10 | ||||
-rw-r--r-- | src/client/views/GlobalKeyHandler.ts | 2 | ||||
-rw-r--r-- | src/client/views/InkingStroke.tsx | 7 | ||||
-rw-r--r-- | src/client/views/MainView.tsx | 4 | ||||
-rw-r--r-- | src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx | 588 | ||||
-rw-r--r-- | src/client/views/global/globalScripts.ts | 32 | ||||
-rw-r--r-- | src/client/views/nodes/FontIconBox/FontIconBox.tsx | 17 | ||||
-rw-r--r-- | src/fields/InkField.ts | 4 |
10 files changed, 608 insertions, 64 deletions
diff --git a/src/Utils.ts b/src/Utils.ts index 291d7c799..226051f7d 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -348,6 +348,10 @@ export namespace Utils { return project(near.point.x, near.point.y, fres[1][0], fres[1][1], fres[1][2], fres[1][3]); } + export function ptDistance(p1: { x: number; y: number }, p2: { x: number; y: number }) { + return Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2); + } + export function getNearestPointInPerimeter(l: number, t: number, w: number, h: number, x: number, y: number) { const r = l + w, b = t + h; diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index b160379df..61a58caaa 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -26,7 +26,7 @@ import { ScriptingGlobals } from '../util/ScriptingGlobals'; import { UndoManager, undoable } from '../util/UndoManager'; import { ContextMenu } from '../views/ContextMenu'; import { ContextMenuProps } from '../views/ContextMenuItem'; -import { ActiveArrowEnd, ActiveArrowStart, ActiveDash, ActiveFillColor, ActiveInkBezierApprox, ActiveInkColor, ActiveInkWidth, ActiveIsInkMask, InkingStroke } from '../views/InkingStroke'; +import { ActiveArrowEnd, ActiveArrowStart, ActiveDash, ActiveEraserWidth, ActiveFillColor, ActiveInkBezierApprox, ActiveInkColor, ActiveInkWidth, ActiveEraserWidth, ActiveIsInkMask, InkingStroke } from '../views/InkingStroke'; import { CollectionDockingView } from '../views/collections/CollectionDockingView'; import { CollectionView } from '../views/collections/CollectionView'; import { DimUnit } from '../views/collections/collectionMulticolumn/CollectionMulticolumnView'; @@ -1123,6 +1123,7 @@ export namespace Docs { points: PointData[], options: DocumentOptions = {}, strokeWidth = ActiveInkWidth(), + eraserWidth = ActiveEraserWidth(), color = ActiveInkColor(), stroke_bezier = ActiveInkBezierApprox(), fillColor = ActiveFillColor(), @@ -1138,6 +1139,7 @@ export namespace Docs { I.fillColor = fillColor; I.stroke = new InkField(points); I.stroke_width = strokeWidth; + I.eraser_width = eraserWidth; I.stroke_bezier = stroke_bezier; I.stroke_startMarker = arrowStart; I.stroke_endMarker = arrowEnd; diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts index 081115879..3ee1b42aa 100644 --- a/src/client/util/CurrentUserUtils.ts +++ b/src/client/util/CurrentUserUtils.ts @@ -34,6 +34,7 @@ import { SnappingManager } from "./SnappingManager"; import { UndoManager } from "./UndoManager"; import { LabelBox } from "../views/nodes/LabelBox"; import { ImageBox } from "../views/nodes/ImageBox"; +import { PiEraser } from "react-icons/pi"; interface Button { // DocumentOptions fields a button can set @@ -751,7 +752,13 @@ pie title Minerals in my tap water return [ { title: "Pen", toolTip: "Pen (Ctrl+P)", btnType: ButtonType.ToggleButton, icon: "pen-nib", toolType: "pen", scripts: {onClick:'{ return setActiveTool(this.toolType, false, _readOnly_);}' }}, { title: "Write", toolTip: "Write (Ctrl+Shift+P)", btnType: ButtonType.ToggleButton, icon: "pen", toolType: "write", scripts: {onClick:'{ return setActiveTool(this.toolType, false, _readOnly_);}' }, funcs: {hidden:"IsNoviceMode()" }}, - { title: "Eraser", toolTip: "Eraser (Ctrl+E)", btnType: ButtonType.ToggleButton, icon: "eraser", toolType: "eraser", scripts: {onClick:'{ return setActiveTool(this.toolType, false, _readOnly_);}' }, funcs: {hidden:"IsNoviceMode()" }}, + { title: "Eraser", toolTip: "Eraser (Ctrl+E)", btnType: ButtonType.MultiToggleButton, scripts: {onClick: '{ return setActiveTool(this.toolType, false, _readOnly_);}'}, funcs: {toolType:"activeEraserTool()"}, + subMenu: [ + { title: "Stroke", toolTip: "Stroke Erase", btnType: ButtonType.ToggleButton, icon: "eraser", toolType:InkTool.StrokeEraser, ignoreClick: true, scripts: {onClick: '{ return setActiveTool(this.toolType, false, _readOnly_);}'} }, + { title: "Segment", toolTip: "Segment Erase", btnType: ButtonType.ToggleButton, icon: "xmark",toolType:InkTool.SegmentEraser,ignoreClick: true, scripts: {onClick: '{ return setActiveTool(this.toolType, false, _readOnly_);}'} }, + { title: "Radius", toolTip: "Radius Erase", btnType: ButtonType.ToggleButton, icon: "circle-xmark",toolType:InkTool.RadiusEraser, ignoreClick: true, scripts: {onClick: '{ return setActiveTool(this.toolType, false, _readOnly_);}'} }, + ]}, + { title: "Eraser Width", toolTip: "Eraser Width", btnType: ButtonType.NumberSliderButton, toolType: "eraserWidth", ignoreClick: true, scripts: {script: '{ return setInkProperty(this.toolType, value, _readOnly_);}'}, numBtnMin: 1, funcs: {hidden:"NotRadiusEraser()"}}, { title: "Circle", toolTip: "Circle (double tap to lock mode)", btnType: ButtonType.ToggleButton, icon: "circle", toolType:GestureUtils.Gestures.Circle, scripts: {onClick:`{ return setActiveTool(this.toolType, false, _readOnly_);}`, onDoubleClick:`{ return setActiveTool(this.toolType, true, _readOnly_);}`} }, { title: "Square", toolTip: "Square (double tap to lock mode)", btnType: ButtonType.ToggleButton, icon: "square", toolType:GestureUtils.Gestures.Rectangle, scripts: {onClick:`{ return setActiveTool(this.toolType, false, _readOnly_);}`, onDoubleClick:`{ return setActiveTool(this.toolType, true, _readOnly_);}`} }, { title: "Line", toolTip: "Line (double tap to lock mode)", btnType: ButtonType.ToggleButton, icon: "minus", toolType:GestureUtils.Gestures.Line, scripts: {onClick:`{ return setActiveTool(this.toolType, false, _readOnly_);}`, onDoubleClick:`{ return setActiveTool(this.toolType, true, _readOnly_);}`} }, @@ -1112,6 +1119,7 @@ pie title Minerals in my tap water ScriptingGlobals.add(function MySharedDocs() { return Doc.MySharedDocs; }, "document containing all shared Docs"); ScriptingGlobals.add(function IsExploreMode() { return SnappingManager.ExploreMode; }, "is Dash in exploration mode"); ScriptingGlobals.add(function IsNoviceMode() { return Doc.noviceMode; }, "is Dash in novice mode"); +ScriptingGlobals.add(function NotRadiusEraser() { return Doc.ActiveTool !== InkTool.RadiusEraser; }, "is the active tool anything but the radius eraser"); ScriptingGlobals.add(function toggleComicMode() { Doc.UserDoc().renderStyle = Doc.UserDoc().renderStyle === "comic" ? undefined : "comic"; }, "switches between comic and normal document rendering"); ScriptingGlobals.add(function importDocument() { return CurrentUserUtils.importDocument(); }, "imports files from device directly into the import sidebar"); ScriptingGlobals.add(function setInkToolDefaults() { Doc.ActiveTool = InkTool.None; }); diff --git a/src/client/views/GlobalKeyHandler.ts b/src/client/views/GlobalKeyHandler.ts index 2f64ea28c..3349fb5dc 100644 --- a/src/client/views/GlobalKeyHandler.ts +++ b/src/client/views/GlobalKeyHandler.ts @@ -273,7 +273,7 @@ export class KeyManager { } break; case 'e': - Doc.ActiveTool = Doc.ActiveTool === InkTool.Eraser ? InkTool.None : InkTool.Eraser; + Doc.ActiveTool = [InkTool.StrokeEraser, InkTool.SegmentEraser, InkTool.RadiusEraser].includes(Doc.ActiveTool) ? InkTool.None : InkTool.StrokeEraser; break; case 'p': Doc.ActiveTool = Doc.ActiveTool === InkTool.Pen ? InkTool.None : InkTool.Pen; diff --git a/src/client/views/InkingStroke.tsx b/src/client/views/InkingStroke.tsx index 51baaa23e..9e09c0aa9 100644 --- a/src/client/views/InkingStroke.tsx +++ b/src/client/views/InkingStroke.tsx @@ -45,6 +45,7 @@ import { FormattedTextBox } from './nodes/formattedText/FormattedTextBox'; import { PinProps, PresBox } from './nodes/trails'; import { StyleProp } from './StyleProvider'; const { INK_MASK_SIZE } = require('./global/globalCssVariables.module.scss'); // prettier-ignore + @observer export class InkingStroke extends ViewBoxAnnotatableComponent<FieldViewProps>() implements ViewBoxInterface { static readonly MaskDim = INK_MASK_SIZE; // choose a really big number to make sure mask fits over container (which in theory can be arbitrarily big) @@ -506,6 +507,9 @@ export function SetActiveArrowScale(value: number) { export function SetActiveDash(dash: string): void { !isNaN(parseInt(dash)) && ActiveInkPen() && (ActiveInkPen().activeDash = dash); } +export function SetEraserWidth(radius: string): void { + !isNaN(parseInt(radius)) && ActiveInkPen() && (ActiveInkPen().eraserWidth = radius); +} export function ActiveInkPen(): Doc { return Doc.UserDoc(); } @@ -539,3 +543,6 @@ export function ActiveInkWidth(): number { export function ActiveInkBezierApprox(): string { return StrCast(ActiveInkPen()?.activeInkBezier); } +export function ActiveEraserWidth(): number { + return Number(ActiveInkPen()?.eraserWidth); +} diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index 58b8d255a..d0b3221b4 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -358,6 +358,10 @@ export class MainView extends ObservableReactComponent<{}> { fa.faCut, fa.faEllipsisV, fa.faEraser, + fa.faDeleteLeft, + fa.faXmarksLines, + fa.faCircleXmark, + fa.faXmark, fa.faExclamation, fa.faFileAlt, fa.faFileAudio, diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 079a5d977..06c6f58a1 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -35,7 +35,7 @@ import { Timeline } from '../../animationtimeline/Timeline'; import { ContextMenu } from '../../ContextMenu'; import { GestureOverlay } from '../../GestureOverlay'; import { CtrlKey } from '../../GlobalKeyHandler'; -import { ActiveInkWidth, InkingStroke, SetActiveInkColor, SetActiveInkWidth } from '../../InkingStroke'; +import { ActiveEraserWidth, ActiveInkWidth, InkingStroke, SetActiveInkColor, SetActiveInkWidth } from '../../InkingStroke'; import { LightboxView } from '../../LightboxView'; import { CollectionFreeFormDocumentView } from '../../nodes/CollectionFreeFormDocumentView'; import { SchemaCSVPopUp } from '../../nodes/DataVizBox/SchemaCSVPopUp'; @@ -105,6 +105,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection private _batch: UndoManager.Batch | undefined = undefined; private _brushtimer: any; private _brushtimer1: any; + private prevZoom: number = 0; public get isAnnotationOverlay() { return this._props.isAnnotationOverlay; @@ -132,6 +133,9 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection @observable _marqueeViewRef = React.createRef<MarqueeView>(); @observable _brushedView: { width: number; height: number; panX: number; panY: number } | undefined = undefined; // highlighted region of freeform canvas used by presentations to indicate a region @observable GroupChildDrag: boolean = false; // child document view being dragged. needed to update drop areas of groups when a group item is dragged. + @observable _eraserX: number = 0; + @observable _eraserY: number = 0; + @observable _showEraserCircle: boolean = false; // to determine whether the radius eraser should show @computed get contentViews() { const viewsMask = this._layoutElements.filter(ele => ele.bounds && !ele.bounds.z && ele.inkMask !== -1 && ele.inkMask !== undefined).map(ele => ele.ele); @@ -611,13 +615,23 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection // group freeforms don't pan when dragged -- instead let the event go through to allow the group itself to drag // prettier-ignore switch (Doc.ActiveTool) { - case InkTool.Highlighter: break; - case InkTool.Write: break; - 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: + case InkTool.Highlighter: + break; + case InkTool.Write: + break; + 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.StrokeEraser: + case InkTool.SegmentEraser: this._batch = UndoManager.StartBatch('collectionErase'); + this._eraserPts.length = 0; setupMoveUpEvents(this, e, this.onEraserMove, this.onEraserUp, emptyFunction); break; + case InkTool.RadiusEraser: + this._batch = UndoManager.StartBatch('collectionErase'); + this._eraserPts.length = 0; + setupMoveUpEvents(this, e, this.onRadiusEraserMove, this.onEraserUp, emptyFunction); + break; case InkTool.None: if (!(this._props.layoutEngine?.() || StrCast(this.layoutDoc._layoutEngine))) { this._hitCluster = this.pickCluster(this.screenToFreeformContentsXf.transformPoint(e.clientX, e.clientY)); @@ -682,7 +696,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection }; @action onEraserUp = (e: PointerEvent): void => { - this._deleteList.forEach(ink => ink._props.removeDocument?.(ink.Document)); + this._deleteList.lastElement()?._props.removeDocument?.(this._deleteList.map(ink => ink.Document)); this._deleteList = []; this._batch?.end(); }; @@ -724,41 +738,91 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection }; _eraserLock = 0; + _eraserPts: number[][] = []; // keep track of the last few eraserPts to make the eraser circle 'stretch' + /** * 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, down: number[], delta: number[]) => { const currPoint = { X: e.clientX, Y: e.clientY }; - if (this._eraserLock) return false; // bcz: should be fixed by putting it on a queue to be processed after the last eraser movement is processed. + this._eraserPts.push([currPoint.X, currPoint.Y]); + this._eraserPts = this._eraserPts.slice(Math.max(0, this._eraserPts.length - 5)); + // if (this._eraserLock) return false; // leaving this commented out in case the idea is revisited in the future this.getEraserIntersections({ X: currPoint.X - delta[0], Y: currPoint.Y - delta[1] }, currPoint).forEach(intersect => { if (!this._deleteList.includes(intersect.inkView)) { this._deleteList.push(intersect.inkView); SetActiveInkWidth(StrCast(intersect.inkView.Document.stroke_width?.toString()) || '1'); SetActiveInkColor(StrCast(intersect.inkView.Document.color?.toString()) || 'black'); // create a new curve by appending all curves of the current segment together in order to render a single new stroke. - if (!e.shiftKey) { - this._eraserLock++; - const segments = this.segmentInkStroke(intersect.inkView, intersect.t); - segments.forEach(segment => - this.forceStrokeGesture( - e, - 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[]) - ) - ); - setTimeout(() => this._eraserLock--); + if (Doc.ActiveTool !== InkTool.StrokeEraser) { + // this._eraserLock++; + const segments = this.segmentErase(intersect.inkView, intersect.t); // intersect.t is where the eraser intersected the ink stroke - want to remove the segment that starts at the intersection just before this t value and goes to the one just after it + const newStrokes = segments?.map(segment => { + const points = 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[]); + const bounds = GestureOverlay.getBounds(points); + const B = this.screenToFreeformContentsXf.transformBounds(bounds.left, bounds.top, bounds.width, bounds.height); + const inkWidth = ActiveInkWidth() * this.ScreenToLocalBoxXf().Scale; + return Docs.Create.InkDocument( + points, + { title: 'stroke', + x: B.x - inkWidth / 2, + y: B.y - inkWidth / 2, + _width: B.width + inkWidth, + _height: B.height + inkWidth, + stroke_showLabel: BoolCast(Doc.UserDoc().activeInkHideTextLabels)}, // prettier-ignore + inkWidth + ); + }); + newStrokes && this.addDocument?.(newStrokes); + // setTimeout(() => this._eraserLock--); } // Lower ink opacity to give the user a visual indicator of deletion. - intersect.inkView.layoutDoc.opacity = 0.5; + intersect.inkView.layoutDoc.opacity = 0; intersect.inkView.layoutDoc.dontIntersect = true; } }); return false; }; + + /** + * Erases strokes by intersecting them with a circle of variable radius. Essentially creates an InkField for the + * eraser circle, then determines its intersections with other ink strokes. Each stroke's DocumentView and its + * intersection t-values are put into a map, which gets looped through to take out the erased parts. + * @param e + * @param down + * @param delta + * @returns + */ + @action + onRadiusEraserMove = (e: PointerEvent, down: number[], delta: number[]) => { + const currPoint = { X: e.clientX, Y: e.clientY }; + this._eraserPts.push([currPoint.X, currPoint.Y]); + this._eraserPts = this._eraserPts.slice(Math.max(0, this._eraserPts.length - 5)); + const strokeMap: Map<DocumentView, number[]> = this.getRadiusEraserIntersections({ X: currPoint.X - delta[0], Y: currPoint.Y - delta[1] }, currPoint); + + strokeMap.forEach((intersects, stroke) => { + if (!this._deleteList.includes(stroke)) { + this._deleteList.push(stroke); + SetActiveInkWidth(StrCast(stroke.Document.stroke_width?.toString()) || '1'); + SetActiveInkColor(StrCast(stroke.Document.color?.toString()) || 'black'); + const segments = this.radiusErase(stroke, intersects.sort()); + segments?.forEach(segment => + this.forceStrokeGesture( + e, + GestureUtils.Gestures.Stroke, + segment.reduce((data, curve) => [...data, ...curve.points.map(p => stroke.ComponentView?.ptToScreen?.({ X: p.x, Y: p.y }) ?? { X: 0, Y: 0 })], [] as PointData[]) + ) + ); + } + stroke.layoutDoc.opacity = 0; + stroke.layoutDoc.dontIntersect = true; + }); + return false; + }; + forceStrokeGesture = (e: PointerEvent, gesture: GestureUtils.Gestures, points: InkData, text?: any) => { this.onGesture(e, new GestureUtils.GestureEvent(gesture, points, GestureOverlay.getBounds(points), text)); }; @@ -777,6 +841,96 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection }; /** + * Creates the eraser outline for a radius eraser. The outline is used to intersect with ink strokes and determine + * what falls inside the eraser outline. + * @param startInkCoordsIn + * @param endInkCoordsIn + * @param inkStrokeWidth + * @returns + */ + createEraserOutline = (startInkCoordsIn: { X: number; Y: number }, endInkCoordsIn: { X: number; Y: number }, inkStrokeWidth: number) => { + // increase radius slightly based on the erased stroke's width, added to make eraser look more realistic + var radius = ActiveEraserWidth() + 5 + inkStrokeWidth * 0.1; // add 5 to prevent eraser from being too small + const c = 0.551915024494; // circle tangent length to side ratio + const movement = { x: endInkCoordsIn.X - startInkCoordsIn.X, y: endInkCoordsIn.Y - startInkCoordsIn.Y }; + const moveLen = Math.sqrt(movement.x ** 2 + movement.y ** 2); + const direction = { x: (movement.x / moveLen) * radius, y: (movement.y / moveLen) * radius }; + const normal = { x: -direction.y, y: direction.x }; // prettier-ignore + + const startCoords = { X: startInkCoordsIn.X - direction.x, Y: startInkCoordsIn.Y - direction.y }; + const endCoords = { X: endInkCoordsIn.X + direction.x, Y: endInkCoordsIn.Y + direction.y }; + return new InkField([ + // left bot arc + { X: startCoords.X, Y: startCoords.Y }, // prettier-ignore + { X: startCoords.X + normal.x * c, Y: startCoords.Y + normal.y * c }, // prettier-ignore + { X: startCoords.X + direction.x + normal.x - direction.x * c, Y: startCoords.Y + direction.y + normal.y - direction.y * c }, + { X: startCoords.X + direction.x + normal.x, Y: startCoords.Y + direction.y + normal.y }, // prettier-ignore + + // bot + { X: startCoords.X + direction.x + normal.x, Y: startCoords.Y + direction.y + normal.y }, // prettier-ignore + { X: startCoords.X + direction.x + normal.x + direction.x * c, Y: startCoords.Y + direction.y + normal.y + direction.y * c }, + { X: endCoords.X - direction.x + normal.x - direction.x * c, Y: endCoords.Y - direction.y + normal.y - direction.y * c }, // prettier-ignore + { X: endCoords.X - direction.x + normal.x, Y: endCoords.Y - direction.y + normal.y }, // prettier-ignore + + // right bot arc + { X: endCoords.X - direction.x + normal.x, Y: endCoords.Y - direction.y + normal.y }, // prettier-ignore + { X: endCoords.X - direction.x + normal.x + direction.x * c, Y: endCoords.Y - direction.y + normal.y + direction.y * c}, // prettier-ignore + { X: endCoords.X + normal.x * c, Y: endCoords.Y + normal.y * c }, // prettier-ignore + { X: endCoords.X, Y: endCoords.Y }, // prettier-ignore + + // right top arc + { X: endCoords.X, Y: endCoords.Y }, // prettier-ignore + { X: endCoords.X - normal.x * c, Y: endCoords.Y - normal.y * c }, // prettier-ignore + { X: endCoords.X - direction.x - normal.x + direction.x * c, Y: endCoords.Y - direction.y - normal.y + direction.y * c}, // prettier-ignore + { X: endCoords.X - direction.x - normal.x, Y: endCoords.Y - direction.y - normal.y }, // prettier-ignore + + // top + { X: endCoords.X - direction.x - normal.x, Y: endCoords.Y - direction.y - normal.y }, // prettier-ignore + { X: endCoords.X - direction.x - normal.x - direction.x * c, Y: endCoords.Y - direction.y - normal.y - direction.y * c}, // prettier-ignore + { X: startCoords.X + direction.x - normal.x + direction.x * c, Y: startCoords.Y + direction.y - normal.y + direction.y * c }, + { X: startCoords.X + direction.x - normal.x, Y: startCoords.Y + direction.y - normal.y }, // prettier-ignore + + // left top arc + { X: startCoords.X + direction.x - normal.x, Y: startCoords.Y + direction.y - normal.y }, // prettier-ignore + { X: startCoords.X + direction.x - normal.x - direction.x * c, Y: startCoords.Y + direction.y - normal.y - direction.y * c }, // prettier-ignore + { X: startCoords.X - normal.x * c, Y: startCoords.Y - normal.y * c }, // prettier-ignore + { X: startCoords.X, Y: startCoords.Y }, // prettier-ignore + ]); + }; + + /** + * Ray-tracing algorithm to determine whether a point is inside the eraser outline. + * @param eraserOutline + * @param point + * @returns + */ + insideEraserOutline = (eraserOutline: InkData, point: { X: number; Y: number }) => { + var isInside = false; + if (!isNaN(eraserOutline[0].X) && !isNaN(eraserOutline[0].Y)) { + let minX = eraserOutline[0].X, maxX = eraserOutline[0].X; + let minY = eraserOutline[0].Y, maxY = eraserOutline[0].Y; + for (let i = 1; i < eraserOutline.length; i++) { + const currPoint: { X: number; Y: number } = eraserOutline[i]; + minX = Math.min(currPoint.X, minX); + maxX = Math.max(currPoint.X, maxX); + minY = Math.min(currPoint.Y, minY); + maxY = Math.max(currPoint.Y, maxY); + } + + if (point.X < minX || point.X > maxX || point.Y < minY || point.Y > maxY) { + return false; + } + + for (var i = 0, j = eraserOutline.length - 1; i < eraserOutline.length; j = i, i++) { + if (eraserOutline[i].Y > point.Y != eraserOutline[j].Y > point.Y && point.X < ((eraserOutline[j].X - eraserOutline[i].X) * (point.Y - eraserOutline[i].Y)) / (eraserOutline[j].Y - eraserOutline[i].Y) + eraserOutline[i].X) { + isInside = !isInside; + } + } + } + return isInside; + }; + + /** * 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 */ @@ -798,12 +952,14 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection ) .reduce( (intersections, { inkStroke, inkView }) => { - const { inkData } = inkStroke.inkScaledData(); + const { inkData } = inkStroke.inkScaledData(); // get bezier curve as set of control points // 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) { + // iterate over each segment of bezier curve const rawIntersects = InkField.Segment(inkData, i).intersects({ + // segment's are indexed by 0, 4, 8, // compute all unique intersections p1: { x: prevPointInkSpace.X, y: prevPointInkSpace.Y }, p2: { x: currPointInkSpace.X, y: currPointInkSpace.Y }, @@ -819,53 +975,353 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection }; /** - * 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). + * Same as getEraserIntersections but specific to the radius eraser. The key difference is that the radius eraser + * will often intersect multiple strokes, depending on what strokes are inside the eraser. Populates a Map of each + * intersected DocumentView to the t-values where the eraser intersected it, then returns this map. + * @returns + */ + getRadiusEraserIntersections = (lastPoint: { X: number; Y: number }, currPoint: { X: number; Y: number }) => { + // set distance of the eraser's bounding box based on the zoom + var boundingBoxDist = ActiveEraserWidth() + 5; + this.zoomScaling() < 1 ? boundingBoxDist = boundingBoxDist / (this.zoomScaling() * 1.5) : boundingBoxDist *= this.zoomScaling(); + + const eraserMin = { X: Math.min(lastPoint.X, currPoint.X) - boundingBoxDist, Y: Math.min(lastPoint.Y, currPoint.Y) - boundingBoxDist }; + const eraserMax = { X: Math.max(lastPoint.X, currPoint.X) + boundingBoxDist, Y: Math.max(lastPoint.Y, currPoint.Y) + boundingBoxDist }; + const strokeToTVals = new Map<DocumentView, number[]>(); + const intersectingStrokes = this.childDocs + .map(doc => DocumentManager.Instance.getDocumentView(doc, this.DocumentView?.())) + .filter(inkView => inkView?.ComponentView instanceof InkingStroke) // filter to all inking strokes + .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 + ); + + intersectingStrokes.forEach(({ inkStroke, inkView }) => { + const { inkData, inkStrokeWidth } = inkStroke.inkScaledData(); + const prevPointInkSpace = inkStroke.ptFromScreen(lastPoint); + const currPointInkSpace = inkStroke.ptFromScreen(currPoint); + const eraserInkData = this.createEraserOutline(prevPointInkSpace, currPointInkSpace, inkStrokeWidth).inkData; + + // add the ends of the stroke in as "intersections" + if (this.insideEraserOutline(eraserInkData, inkData[0])) { + strokeToTVals.set(inkView, [0]); + } + if (this.insideEraserOutline(eraserInkData, inkData[inkData.length - 1])) { + const inkList = strokeToTVals.get(inkView); + if (inkList !== undefined) { + inkList.push(Math.floor(inkData.length / 4) + 1); + } else { + strokeToTVals.set(inkView, [Math.floor(inkData.length / 4) + 1]); + } + } + + for (var i = 0; i < inkData.length - 3; i += 4) { + // iterate over each segment of bezier curve + for (var j = 0; j < eraserInkData.length - 3; j += 4) { + const intersectCurve: Bezier = InkField.Segment(inkData, i); // other curve + const eraserCurve: Bezier = InkField.Segment(eraserInkData, j); // eraser curve + this.bintersects(intersectCurve, eraserCurve).forEach((val: string | number, k: number) => { + // Converting the Bezier.js Split type to a t-value number. + const t = +val.toString().split('/')[0]; + if (k % 2 === 0) { + // here, add to the map + const inkList = strokeToTVals.get(inkView); + if (inkList !== undefined) { + const tValOffset = ActiveEraserWidth() / 1050; // to prevent tVals from being added when too close, but scaled by eraser width + const inList = inkList.some(val => Math.abs(val - (t + Math.floor(i / 4))) <= tValOffset); + if (!inList) { + inkList.push(t + Math.floor(i / 4)); + } + } else { + strokeToTVals.set(inkView, [t + Math.floor(i / 4)]); + } + } + }); + } + } + }); + return strokeToTVals; + }; + + /** + * Splits the passed in ink stroke at the intersection t values, taking out the erased parts. + * Operates in pairs of t values, where the first t value is the start of the erased portion and the following t value is the end. + * @param ink the ink stroke DocumentView to split + * @param tVals all the t values to split the ink stroke at + * @returns a list of the new segments with the erased part removed + */ + @action + radiusErase = (ink: DocumentView, tVals: number[]): Segment[] => { + const segments: Segment[] = []; + const inkStroke = ink?.ComponentView as InkingStroke; + const { inkData } = inkStroke.inkScaledData(); + var currSegment: Segment = []; + + // any radius erase stroke will always result in even tVals, since the ends are included + if (tVals.length % 2 !== 0) { + for (var i = 0; i < inkData.length - 3; i += 4) { + currSegment.push(InkField.Segment(inkData, i)); + } + segments.push(currSegment); + return segments; // return the full original stroke + } + + var continueErasing = false; // used to erase segments if they are completely enclosed in the eraser + var firstSegment: Segment = []; // used to keep track of the first segment for closed curves + + // early return if nothing to split on + if (tVals.length === 0 || (tVals.length === 1 && tVals[0] === 0)) { + for (var i = 0; i < inkData.length - 3; i += 4) { + currSegment.push(InkField.Segment(inkData, i)); + } + segments.push(currSegment); + return segments; + } + + // loop through all segments of an ink stroke, string together the pieces, excluding the erased parts, + // and push each piece we want to keep to the return list + for (var i = 0; i < inkData.length - 3; i += 4) { + const currCurveT = Math.floor(i / 4); + const inkBezier: Bezier = InkField.Segment(inkData, i); + // filter to this segment's t-values + const segmentTs = tVals.filter(t => t >= currCurveT && t < currCurveT + 1); + + if (segmentTs.length > 0) { + for (var j = 0; j < segmentTs.length; j++) { + if (segmentTs[j] === 0) { + // if the first end of the segment is within the eraser + continueErasing = true; + } else if (segmentTs[j] === Math.floor(inkData.length / 4) + 1) { + // the last end + break; + } else { + if (!continueErasing) { + currSegment.push(inkBezier.split(0, segmentTs[j] - currCurveT)); + continueErasing = true; + } else { + // we've reached the end of the part to take out... + continueErasing = false; + if (currSegment.length > 0) { + segments.push(currSegment); // ...so we add it to the list and reset currSegment + if (firstSegment.length === 0) { + firstSegment = currSegment; + } + currSegment = []; + } + currSegment.push(inkBezier.split(segmentTs[j] - currCurveT, 1)); + } + } + } + } else { + if (!continueErasing) { + // push the bezier piece if not in the eraser circle + currSegment.push(inkBezier); + } + } + } + + if (currSegment.length > 0) { + // add the first segment onto the last to avoid fragmentation for closed curves + if (InkingStroke.IsClosed(inkData)) { + currSegment = currSegment.concat(firstSegment); + } + segments.push(currSegment); + } + return segments; + }; + + /** + * Erases ink strokes by segments. Locates intersections of the current ink stroke with all other ink strokes (including itself), + * then erases the segment that was intersected by the eraser. This is done by creating either 1 or two resulting segments + * (this depends on whether the eraser his the middle or end of a stroke), and returning the segments to "redraw." * @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[] => { + segmentErase = (ink: DocumentView, excludeT: number): Segment[] => { const segments: Segment[] = []; - var segment: Segment = []; - var startSegmentT = 0; + var segment1: Segment = []; + var segment2: Segment = []; 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) + var intersections: number[] = []; // list of the ink stroke's intersections + var segmentIndexes: number[] = []; // list of indexes of the curve's segment where each intersection occured + + // loops through each segment and adds intersections to the list 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); - t !== (localStartTVal < 0 ? 0 : localStartTVal) && segment.push(inkSegment.split(localStartTVal < 0 ? 0 : localStartTVal, t)); - if (segment.length && (Math.abs(segment[0].points[0].x - segment[0].points.lastElement().x) > 0.5 || Math.abs(segment[0].points[0].y - segment[0].points.lastElement().y) > 0.5)) segments.push(segment); + const inkSegment: Bezier = InkField.Segment(inkData, i); + var currIntersects = this.getInkIntersections(i, ink, inkSegment).sort(); + // get current segment's intersections (if any) and add the curve index + currIntersects = currIntersects.filter(tVal => tVal > 0 && tVal < 1).map(tVal => tVal + Math.floor(i / 4)); + if (currIntersects.length) { + intersections = [...intersections, ...currIntersects]; + for (var j = 0; j < currIntersects.length; j++) { + segmentIndexes.push(Math.floor(i / 4)); + } + } + } + + var isClosedCurve = false; + if (InkingStroke.IsClosed(inkData)) { + isClosedCurve = true; + if (intersections.length === 1) { // delete whole stroke if a closed curve has 1 intersection + return segments; + } + } + + if (intersections.length) { + // this is the indexes of the closest intersection(s) + var closestTs = this.getClosestTs(intersections, excludeT, 0, intersections.length - 1); + + // find the segments that need to be split + var splitSegment1 = -1; // stays -1 if left end is deleted + var splitSegment2 = -1; // stays -1 if right end is deleted + if (closestTs[0] !== -1 && closestTs[1] !== -1) { // if not on the ends + splitSegment1 = segmentIndexes[closestTs[0]]; + splitSegment2 = segmentIndexes[closestTs[1]]; + } else if (closestTs[0] === -1) { // for a curve before an intersection + splitSegment2 = segmentIndexes[closestTs[1]]; + } else { // for a curve after an intersection + splitSegment1 = segmentIndexes[closestTs[0]]; + } + // so by this point splitSegment1 and splitSegment2 will be the index(es) of the segment(s) to split + + var hasSplit = false; + var continueErasing = false; + // loop through segments again and split them if they match the split segments + for (var i = 0; i < inkData.length - 3; i += 4) { + const currCurveT = Math.floor(i / 4); + const inkSegment: Bezier = InkField.Segment(inkData, i); + + // case where the current curve is the first to split + if (splitSegment1 !== -1 && splitSegment2 !== -1) { + if (splitSegment1 === splitSegment2 && splitSegment1 === currCurveT) { + // if it's the same segment + segment1.push(inkSegment.split(0, intersections[closestTs[0]] - currCurveT)); + segment2.push(inkSegment.split(intersections[closestTs[1]] - currCurveT, 1)); + hasSplit = true; + } else if (splitSegment1 === currCurveT) { + segment1.push(inkSegment.split(0, intersections[closestTs[0]] - currCurveT)); + continueErasing = true; + } else if (splitSegment2 === currCurveT) { + segment2.push(inkSegment.split(intersections[closestTs[1]] - currCurveT, 1)); + continueErasing = false; + hasSplit = true; + } else { + if (!continueErasing && !hasSplit) { + // segment doesn't get pushed if continueErasing is true + segment1.push(inkSegment); + } else if (!continueErasing && hasSplit) { + segment2.push(inkSegment); + } } - // start a new segment from the intersection t value - if (tVals.length - 1 === index) { - const split = inkSegment.split(t).right; - if (split && (Math.abs(split.points[0].x - split.points.lastElement().x) > 0.5 || Math.abs(split.points[0].y - split.points.lastElement().y) > 0.5)) segment = [split]; - else segment = []; - } else segment = []; - startSegmentT = docCurveTVal; - }); - } else { - segment.push(inkSegment); + } else if (splitSegment1 === -1) { + // case where first end is erased + if (currCurveT === splitSegment2) { + if (isClosedCurve && currCurveT === segmentIndexes.lastElement()) { + segment2.push(inkSegment.split(intersections[closestTs[1]] - currCurveT, intersections.lastElement() - currCurveT)); + continueErasing = true; + } else { + segment2.push(inkSegment.split(intersections[closestTs[1]] - currCurveT, 1)); + } + hasSplit = true; + } else { + if (isClosedCurve && currCurveT === segmentIndexes.lastElement()) { + segment2.push(inkSegment.split(0, intersections.lastElement() - currCurveT)); + continueErasing = true; + } else if (hasSplit && !continueErasing) { + segment2.push(inkSegment); + } + } + } else { + // case where last end is erased + if (currCurveT === segmentIndexes[0] && isClosedCurve) { + if (isClosedCurve && currCurveT === segmentIndexes.lastElement()) { + segment1.push(inkSegment.split(intersections[0] - currCurveT, intersections.lastElement() - currCurveT)); + continueErasing = true; + } else { + segment1.push(inkSegment.split(intersections[0] - currCurveT, 1)); + } + hasSplit = true; + } else if (currCurveT === splitSegment1) { + segment1.push(inkSegment.split(0, intersections[closestTs[0]] - currCurveT)); + hasSplit = true; + continueErasing = true; + } else { + if ((isClosedCurve && hasSplit && !continueErasing) || (!isClosedCurve && !hasSplit)) { + segment1.push(inkSegment); + } + } + } } } - if (excludeT < startSegmentT || excludeT > inkData.length / 4) { - segment.length && segments.push(segment); + + // add the first segment onto the second one for closed curves, so they don't get fragmented into two pieces + if (isClosedCurve && segment1.length > 0 && segment2.length > 0) { + segment2 = segment2.concat(segment1); + segment1 = []; } + + // push 1 or both segments if they are not empty + if (segment1.length && (Math.abs(segment1[0].points[0].x - segment1[0].points.lastElement().x) > 0.5 || Math.abs(segment1[0].points[0].y - segment1[0].points.lastElement().y) > 0.5)) { + segments.push(segment1); + } + if (segment2.length && (Math.abs(segment2[0].points[0].x - segment2[0].points.lastElement().x) > 0.5 || Math.abs(segment2[0].points[0].y - segment2[0].points.lastElement().y) > 0.5)) { + segments.push(segment2); + } + return segments; }; + /** + * Standard logarithmic search function to search a sorted list of tVals for the ones closest to excludeT. + * @param tVals list of tvalues (usage is for intersection t values) to search within + * @param excludeT the t value of where the eraser intersected the curve + * @param startIndex the start index to search from + * @param endIndex the end index to search to + * @returns 2-item array of the closest tVals indexes + */ + getClosestTs = (tVals: number[], excludeT: number, startIndex: number, endIndex: number): number[] => { + if (tVals[startIndex] >= excludeT) { + return [-1, startIndex]; + } else if (tVals[endIndex] < excludeT) { + return [endIndex, -1]; + } else { + const mid = Math.floor((startIndex + endIndex) / 2); + if (excludeT >= tVals[mid]) { + if (mid + 1 <= endIndex && tVals[mid + 1] > excludeT) { + return [mid, mid + 1]; + } else { + return this.getClosestTs(tVals, excludeT, mid + 1, endIndex); + } + } else { + if (mid - 1 >= startIndex && tVals[mid - 1] < excludeT) { + return [mid - 1, mid]; + } else { + return this.getClosestTs(tVals, excludeT, startIndex, mid - 1); + } + } + } + }; + + // for some reason bezier.js doesn't handle the case of intersecting a linear curve, so we wrap the intersection // call in a test for linearity bintersects = (curve: Bezier, otherCurve: Bezier) => { + if ((curve as any)._linear) { + // bezier.js doesn't intersect properly if the curve is actually a line -- so get intersect other curve against this line, then figure out the t coordinates of the intersection on this line + const intersections = otherCurve.lineIntersects({ p1: curve.points[0], p2: curve.points[3] }); + if (intersections.length) { + const intPt = otherCurve.get(intersections[0]); + const intT = curve.project(intPt).t; + return intT ? [intT] : []; + } + } if ((otherCurve as any)._linear) { return curve.lineIntersects({ p1: otherCurve.points[0], p2: otherCurve.points[3] }); } @@ -1027,7 +1483,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection const maxPanY = minPanY + // minPanY + scrolling introduced by view scaling + scrolling introduced by layout_fitWidth scale * NumCast(this.dataDoc._panY_max, nativeHeight) + - (!this._props.getScrollHeight?.() ? fitYscroll : 0); // when not zoomed, scrolling is handled via a scrollbar, not panning + (!this._props.getScrollHeight?.() ? fitYscroll : 0); // when not zoomed, scrollaing is handled via a scrollbar, not panning let newPanY = Math.max(minPanY, Math.min(maxPanY, panY)); if (false && NumCast(this.layoutDoc.layout_scrollTop) && NumCast(this.layoutDoc._freeform_scale, minScale) !== minScale) { const relTop = NumCast(this.layoutDoc.layout_scrollTop) / maxScrollTop; @@ -1588,9 +2044,21 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection @action onCursorMove = (e: React.PointerEvent) => { + this._eraserX = e.clientX; + this._eraserY = e.clientY; // super.setCursorPosition(this.getTransform().transformPoint(e.clientX, e.clientY)); }; + @action + onMouseLeave = () => { + this._showEraserCircle = false; + }; + + @action + onMouseEnter = () => { + this._showEraserCircle = true; + }; + @undoBatch promoteCollection = () => { const childDocs = this.childDocs.slice(); @@ -1736,6 +2204,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection for (var i = 0; i < Math.min(layout_unrendered.length, loadIncrement); i++) { this._renderCutoffData.set(layout_unrendered[i][Id] + '', true); } + layout_unrendered.forEach(lu => lu.stroke && this._renderCutoffData.set(lu[Id] + '', true)); } this.childDocs.some(doc => !this._renderCutoffData.get(doc[Id])) && setTimeout(this.incrementalRender, 1); }); @@ -1858,6 +2327,8 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection this._oldWheel = r; // prevent wheel events from passivly propagating up through containers r?.addEventListener('wheel', this.onPassiveWheel, { passive: false }); + r?.addEventListener('mouseleave', this.onMouseLeave); + r?.addEventListener('mouseenter', this.onMouseEnter); }} onWheel={this.onPointerWheel} onClick={this.onClick} @@ -1873,6 +2344,21 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection width: `${100 / this.nativeDimScaling}%`, height: this._props.getScrollHeight?.() ?? `${100 / this.nativeDimScaling}%`, }}> + {(Doc.ActiveTool === InkTool.RadiusEraser && this._showEraserCircle) && ( + <div + onPointerMove={this.onCursorMove} + style={{ + position: 'fixed', + left: this._eraserX - 60, + top: this._eraserY - 100, + width: (ActiveEraserWidth() + 5) * 2, + height: (ActiveEraserWidth() + 5) * 2, + borderRadius: '50%', + border: '1px solid gray', + transform: 'translate(-50%, -50%)', + }} + /> + )} {this.paintFunc ? ( <FormattedTextBox {...this.props} /> // need this so that any live dashfieldviews will update the underlying text that the code eval reads ) : this._lightboxDoc ? ( diff --git a/src/client/views/global/globalScripts.ts b/src/client/views/global/globalScripts.ts index 2a5732708..0a8434148 100644 --- a/src/client/views/global/globalScripts.ts +++ b/src/client/views/global/globalScripts.ts @@ -12,7 +12,21 @@ import { ScriptingGlobals } from '../../util/ScriptingGlobals'; import { SelectionManager } from '../../util/SelectionManager'; import { UndoManager, undoable } from '../../util/UndoManager'; import { GestureOverlay } from '../GestureOverlay'; -import { ActiveFillColor, ActiveInkColor, ActiveInkHideTextLabels, ActiveInkWidth, ActiveIsInkMask, InkingStroke, SetActiveFillColor, SetActiveInkColor, SetActiveInkHideTextLabels, SetActiveInkWidth, SetActiveIsInkMask } from '../InkingStroke'; +import { + ActiveFillColor, + ActiveInkColor, + ActiveInkHideTextLabels, + ActiveInkWidth, + ActiveIsInkMask, + ActiveEraserWidth, + InkingStroke, + SetActiveFillColor, + SetActiveInkColor, + SetActiveInkHideTextLabels, + SetActiveInkWidth, + SetActiveIsInkMask, + SetEraserWidth, +} from '../InkingStroke'; import { CollectionFreeFormView } from '../collections/collectionFreeForm'; // import { InkTranscription } from '../InkTranscription'; import { DocData } from '../../../fields/DocSymbols'; @@ -323,6 +337,9 @@ function setActiveTool(tool: InkTool | GestureUtils.Gestures, keepPrim: boolean, GestureOverlay.Instance.InkShape = tool as GestureUtils.Gestures; } } else if (tool) { + if ([InkTool.StrokeEraser, InkTool.RadiusEraser, InkTool.SegmentEraser].includes(tool as any)) { + Doc.UserDoc().activeEraserTool = tool; + } // pen or eraser if (Doc.ActiveTool === tool && !GestureOverlay.Instance.InkShape && !keepPrim) { Doc.ActiveTool = InkTool.None; @@ -338,11 +355,15 @@ function setActiveTool(tool: InkTool | GestureUtils.Gestures, keepPrim: boolean, ScriptingGlobals.add(setActiveTool, 'sets the active ink tool mode'); +ScriptingGlobals.add(function activeEraserTool() { + return StrCast(Doc.UserDoc().activeEraserTool, InkTool.StrokeEraser); +}, 'returns the current eraser tool'); + // toggle: Set overlay status of selected document -ScriptingGlobals.add(function setInkProperty(option: 'inkMask' | 'labels' | 'fillColor' | 'strokeWidth' | 'strokeColor', value: any, checkResult?: boolean) { +ScriptingGlobals.add(function setInkProperty(option: 'inkMask' | 'labels' | 'fillColor' | 'strokeWidth' | 'strokeColor' | 'eraserWidth', value: any, checkResult?: boolean) { const selected = SelectionManager.Docs.lastElement() ?? Doc.UserDoc(); // prettier-ignore - const map: Map<'inkMask' | 'labels' | 'fillColor' | 'strokeWidth' | 'strokeColor', { checkResult: () => any; setInk: (doc: Doc) => void; setMode: () => void }> = new Map([ + const map: Map<'inkMask' | 'labels' | 'fillColor' | 'strokeWidth' | 'strokeColor' | 'eraserWidth', { checkResult: () => any; setInk: (doc: Doc) => void; setMode: () => void }> = new Map([ ['inkMask', { checkResult: () => ((selected?._layout_isSvg ? BoolCast(selected[DocData].stroke_isInkMask) : ActiveIsInkMask())), setInk: (doc: Doc) => (doc[DocData].stroke_isInkMask = !doc.stroke_isInkMask), @@ -368,6 +389,11 @@ ScriptingGlobals.add(function setInkProperty(option: 'inkMask' | 'labels' | 'fil setInk: (doc: Doc) => (doc[DocData].color = String(value)), setMode: () => { SetActiveInkColor(StrCast(value)); selected?.type === DocumentType.INK && setActiveTool(GestureOverlay.Instance.InkShape ?? InkTool.Pen, true, false);}, }], + [ 'eraserWidth', { + checkResult: () => (selected?._layout_isSvg ? NumCast(selected[DocData].eraser_width) : ActiveEraserWidth()), + setInk: (doc: Doc) => (doc[DocData].eraserWidth = NumCast(value)), + setMode: () => { SetEraserWidth(value.toString());}, + }] ]); if (checkResult) { diff --git a/src/client/views/nodes/FontIconBox/FontIconBox.tsx b/src/client/views/nodes/FontIconBox/FontIconBox.tsx index 57ae92359..ebd6c97bc 100644 --- a/src/client/views/nodes/FontIconBox/FontIconBox.tsx +++ b/src/client/views/nodes/FontIconBox/FontIconBox.tsx @@ -6,7 +6,7 @@ import { observer } from 'mobx-react'; import * as React from 'react'; import { Doc, DocListCast, StrListCast } from '../../../../fields/Doc'; import { BoolCast, DocCast, NumCast, ScriptCast, StrCast } from '../../../../fields/Types'; -import { emptyFunction, returnTrue, setupMoveUpEvents, Utils } from '../../../../Utils'; +import { emptyFunction, returnFalse, returnTrue, setupMoveUpEvents, Utils } from '../../../../Utils'; import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes'; import { SelectionManager } from '../../../util/SelectionManager'; import { SettingsManager } from '../../../util/SettingsManager'; @@ -20,6 +20,7 @@ import { OpenWhere } from '../DocumentView'; import { FieldView, FieldViewProps } from '../FieldView'; import './FontIconBox.scss'; import TrailsIcon from './TrailsIcon'; +import { InkTool } from '../../../../fields/InkField'; export enum ButtonType { TextButton = 'textBtn', @@ -274,24 +275,28 @@ export class FontIconBox extends ViewBoxBaseComponent<ButtonProps>() { // Determine the type of toggle button const tooltip: string = StrCast(this.Document.toolTip); - const script = ScriptCast(this.Document.onClick); - const toggleStatus = script ? script.script.run({ this: this.Document, self: this.Document, value: undefined, _readOnly_: true }).result : false; + const script = ScriptCast(this.Document.onClick)?.script; + const toggleStatus = script?.run({ this: this.Document, self: this.Document, value: undefined, _readOnly_: true }).result; // Colors const color = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color); const items = DocListCast(this.dataDoc.data); + const multiDoc = this.Document; return ( <MultiToggle tooltip={`Toggle ${tooltip}`} type={Type.PRIM} color={color} - background={SettingsManager.userBackgroundColor} + onPointerDown={e => script && !toggleStatus && setupMoveUpEvents(this, e, returnFalse, emptyFunction, e => script.run({ this: multiDoc, value: undefined, _readOnly_: false }))} + isToggle={script ? true : false} + toggleStatus={toggleStatus} + //background={SettingsManager.userBackgroundColor} label={this.label} items={DocListCast(this.dataDoc.data).map(item => ({ icon: <FontAwesomeIcon className={`fontIconBox-icon-${this.type}`} icon={StrCast(item.icon) as any} color={color} />, tooltip: StrCast(item.toolTip), val: StrCast(item.toolType), }))} - selectedVal={StrCast(items.find(itemDoc => ScriptCast(itemDoc.onClick).script.run({ this: itemDoc, self: itemDoc, value: undefined, _readOnly_: true }).result)?.toolType)} + selectedVal={StrCast(items.find(itemDoc => ScriptCast(itemDoc.onClick).script.run({ this: itemDoc, self: itemDoc, value: undefined, _readOnly_: true }).result)?.toolType ?? StrCast(multiDoc.toolType))} setSelectedVal={(val: string | number) => { const itemDoc = items.find(item => item.toolType === val); itemDoc && ScriptCast(itemDoc.onClick).script.run({ this: itemDoc, self: itemDoc, value: val, _readOnly_: false }); @@ -322,7 +327,7 @@ export class FontIconBox extends ViewBoxBaseComponent<ButtonProps>() { toggleStatus={toggleStatus} text={buttonText} color={color} - //background={SettingsManager.userBackgroundColor} + // background={SettingsManager.userBackgroundColor} icon={this.Icon(color)!} label={this.label} onPointerDown={e => diff --git a/src/fields/InkField.ts b/src/fields/InkField.ts index b3e01229a..1c01a5d7c 100644 --- a/src/fields/InkField.ts +++ b/src/fields/InkField.ts @@ -10,7 +10,9 @@ export enum InkTool { None = 'none', Pen = 'pen', Highlighter = 'highlighter', - Eraser = 'eraser', + StrokeEraser = 'strokeeraser', + SegmentEraser = 'segmenteraser', + RadiusEraser = 'radiuseraser', Stamp = 'stamp', Write = 'write', PresentationPin = 'presentationpin', |