aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/collections/collectionFreeForm
diff options
context:
space:
mode:
authorNathan-SR <144961007+Nathan-SR@users.noreply.github.com>2024-06-03 13:33:37 -0400
committerNathan-SR <144961007+Nathan-SR@users.noreply.github.com>2024-06-03 13:33:37 -0400
commit9e77f980e7704999ef0a1c1845d660bccb13ff8a (patch)
tree14ca0da5915e4382a7bcb15f7d0b241941c8291f /src/client/views/collections/collectionFreeForm
parent1be63695875c9242fba43d580465e8765cf3991d (diff)
parent202e994515392892676f8f080852db1e32b8dbd3 (diff)
Merge branch 'master' into nathan-starter
Diffstat (limited to 'src/client/views/collections/collectionFreeForm')
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormClusters.ts14
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx2
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx687
-rw-r--r--src/client/views/collections/collectionFreeForm/ImageLabelHandler.scss44
-rw-r--r--src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx120
-rw-r--r--src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx3
-rw-r--r--src/client/views/collections/collectionFreeForm/MarqueeView.tsx97
7 files changed, 843 insertions, 124 deletions
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormClusters.ts b/src/client/views/collections/collectionFreeForm/CollectionFreeFormClusters.ts
index 26a52cd2a..6ad67a864 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormClusters.ts
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormClusters.ts
@@ -179,7 +179,6 @@ export class CollectionFreeFormClusters {
};
styleProvider = (doc: Opt<Doc>, props: Opt<FieldViewProps>, property: string) => {
- let styleProp = this.viewStyleProvider?.(doc, props, property); // bcz: check 'props' used to be renderDepth + 1
if (doc && this.childDocs?.includes(doc))
switch (property.split(':')[0]) {
case StyleProp.BackgroundColor:
@@ -189,14 +188,9 @@ export class CollectionFreeFormClusters {
if (this._clusterSets.length <= cluster) {
setTimeout(() => doc && this.addDocument(doc));
} else {
- // choose a cluster color from a palette
- const colors = ['#da42429e', '#31ea318c', 'rgba(197, 87, 20, 0.55)', '#4a7ae2c4', 'rgba(216, 9, 255, 0.5)', '#ff7601', '#1dffff', 'yellow', 'rgba(27, 130, 49, 0.55)', 'rgba(0, 0, 0, 0.268)'];
- styleProp = colors[cluster % colors.length];
- const set = this._clusterSets[cluster]?.filter(s => s.backgroundColor);
- // override the cluster color with an explicitly set color on a non-background document. then override that with an explicitly set color on a background document
- set?.forEach(s => {
- styleProp = StrCast(s.backgroundColor);
- });
+ const palette = ['#da42429e', '#31ea318c', 'rgba(197, 87, 20, 0.55)', '#4a7ae2c4', 'rgba(216, 9, 255, 0.5)', '#ff7601', '#1dffff', 'yellow', 'rgba(27, 130, 49, 0.55)', 'rgba(0, 0, 0, 0.268)'];
+ // override palette cluster color with an explicitly set cluster doc color
+ return this._clusterSets[cluster]?.reduce((b, s) => StrCast(s.backgroundColor, b), palette[cluster % palette.length]);
}
}
}
@@ -208,7 +202,7 @@ export class CollectionFreeFormClusters {
break;
default:
}
- return styleProp;
+ return this.viewStyleProvider?.(doc, props, property); // bcz: check 'props' used to be renderDepth + 1
};
tryToSelect = (addToSel: boolean) => {
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx
index a4496a417..de51cc73c 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx
@@ -245,7 +245,7 @@ export function computePivotLayout(poolData: Map<string, PoolData>, pivotDoc: Do
y: -y + (pivotAxisWidth - hgt) / 2,
width: wid,
height: hgt,
- backgroundColor: StrCast(layoutDoc.backgroundColor),
+ backgroundColor: StrCast(layoutDoc.backgroundColor, 'white'),
pair: { layout: doc },
replica: val.replicas[i],
});
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
index dbd9fb11f..b6e1fca77 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
@@ -9,7 +9,7 @@ import { computedFn } from 'mobx-utils';
import * as React from 'react';
import { ClientUtils, DashColor, lightOrDark, OmitKeys, returnFalse, returnZero, setupMoveUpEvents, UpdateIcon } from '../../../../ClientUtils';
import { DateField } from '../../../../fields/DateField';
-import { ActiveInkWidth, Doc, DocListCast, Field, FieldType, Opt, SetActiveInkColor, SetActiveInkWidth } from '../../../../fields/Doc';
+import { ActiveEraserWidth, ActiveInkWidth, Doc, DocListCast, Field, FieldType, Opt, SetActiveInkColor, SetActiveInkWidth } from '../../../../fields/Doc';
import { DocData, Height, Width } from '../../../../fields/DocSymbols';
import { Id } from '../../../../fields/FieldSymbols';
import { InkData, InkField, InkTool, Segment } from '../../../../fields/InkField';
@@ -42,7 +42,7 @@ import { DocumentView } from '../../nodes/DocumentView';
import { FieldViewProps } from '../../nodes/FieldView';
import { FocusViewOptions } from '../../nodes/FocusViewOptions';
import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox';
-import { OpenWhere } from '../../nodes/OpenWhere';
+import { OpenWhere, OpenWhereMod } from '../../nodes/OpenWhere';
import { PinDocView, PinProps } from '../../PinFuncs';
import { StyleProp } from '../../StyleProp';
import { CollectionSubView } from '../CollectionSubView';
@@ -55,6 +55,7 @@ import { CollectionFreeFormRemoteCursors } from './CollectionFreeFormRemoteCurso
import './CollectionFreeFormView.scss';
import { MarqueeView } from './MarqueeView';
+@observer
class CollectionFreeFormOverlayView extends React.Component<{ elements: () => ViewDefResult[] }> {
render() {
return this.props.elements().filter(ele => ele.bounds?.z).map(ele => ele.ele); // prettier-ignore
@@ -98,9 +99,14 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
private _batch: UndoManager.Batch | undefined = undefined;
private _brushtimer: any;
private _brushtimer1: any;
- private _eraserLock = 0;
private _keyTimer: NodeJS.Timeout | undefined; // timer for turning off transition flag when key frame change has completed. Need to clear this if you do a second navigation before first finishes, or else first timer can go off during second naviation.
+ private _presEaseFunc: string = 'ease';
+
+ @action
+ setPresEaseFunc = (easeFunc: string) => {
+ this._presEaseFunc = easeFunc;
+ };
private get isAnnotationOverlay() { return this._props.isAnnotationOverlay; } // prettier-ignore
private get scaleFieldKey() { return (this._props.viewField ?? '') + '_freeform_scale'; } // prettier-ignore
private get panXFieldKey() { return (this._props.viewField ?? '') + '_freeform_panX'; } // prettier-ignore
@@ -120,12 +126,13 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
@observable _lightboxDoc: Opt<Doc> = undefined;
@observable _paintedId = 'id' + Utils.GenerateGuid().replace(/-/g, '');
@observable _keyframeEditing = false;
-
+ @observable _eraserX: number = 0;
+ @observable _eraserY: number = 0;
+ @observable _showEraserCircle: boolean = false; // to determine whether the radius eraser should show
constructor(props: any) {
super(props);
makeObservable(this);
}
-
@computed get layoutEngine() {
return this._props.layoutEngine?.() || StrCast(this.layoutDoc._layoutEngine);
}
@@ -355,6 +362,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
// which will override any group focus. (If we allowed the group to focus, it would mark didMove even if there were no net movement)
return undefined;
}
+ if (options.easeFunc) this.setPresEaseFunc(options.easeFunc);
if (this._lightboxDoc) return undefined;
if (options.pointFocus) return this.focusOnPoint(options);
const anchorInCollection = DocListCast(this.Document[this.fieldKey ?? Doc.LayoutFieldKey(this.Document)]).includes(anchor);
@@ -397,38 +405,40 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
const fromScreenXf = NumCast(refDoc.z) ? this.ScreenToLocalBoxXf() : this.screenToFreeformContentsXf;
const [xpo, ypo] = fromScreenXf.transformPoint(de.x, de.y);
const [x, y] = [xpo - docDragData.offset[0], ypo - docDragData.offset[1]];
- const zsorted = this.childLayoutPairs
- .map(pair => pair.layout)
- .slice()
- .sort((doc1, doc2) => NumCast(doc1.zIndex) - NumCast(doc2.zIndex));
- zsorted.forEach((doc, index) => {
- doc.zIndex = doc.stroke_isInkMask ? 5000 : index + 1;
- });
- const dvals = CollectionFreeFormDocumentView.getValues(refDoc, NumCast(refDoc.activeFrame, 1000));
- const dropPos = this.Document._currentFrame !== undefined ? [NumCast(dvals.x), NumCast(dvals.y)] : [NumCast(refDoc.x), NumCast(refDoc.y)];
-
- docDragData.droppedDocuments.forEach((d, i) => {
- const layoutDoc = Doc.Layout(d);
- const delta = Utils.rotPt(x - dropPos[0], y - dropPos[1], fromScreenXf.Rotate);
- if (this.Document._currentFrame !== undefined) {
- CollectionFreeFormDocumentView.setupKeyframes([d], NumCast(this.Document._currentFrame), false);
- const pvals = CollectionFreeFormDocumentView.getValues(d, NumCast(d.activeFrame, 1000)); // get filled in values (uses defaults when not value is specified) for position
- const vals = CollectionFreeFormDocumentView.getValues(d, NumCast(d.activeFrame, 1000), false); // get non-default values for everything else
- vals.x = NumCast(pvals.x) + delta.x;
- vals.y = NumCast(pvals.y) + delta.y;
- CollectionFreeFormDocumentView.setValues(NumCast(this.Document._currentFrame), d, vals);
- } else {
- d.x = NumCast(d.x) + delta.x;
- d.y = NumCast(d.y) + delta.y;
- }
- d._layout_modificationDate = new DateField();
- const nd = [Doc.NativeWidth(layoutDoc), Doc.NativeHeight(layoutDoc)];
- layoutDoc._width = NumCast(layoutDoc._width, 300);
- layoutDoc._height = NumCast(layoutDoc._height, nd[0] && nd[1] ? (nd[1] / nd[0]) * NumCast(layoutDoc._width) : 300);
- !d._keepZWhenDragged && (d.zIndex = zsorted.length + 1 + i); // bringToFront
+ runInAction(() => {
+ // needs to be in action to avoid having each edit trigger a freeform layout engine recompute - this triggers just one for each document at the end
+ const zsorted = this.childLayoutPairs
+ .map(pair => pair.layout) //
+ .sort((doc1, doc2) => NumCast(doc1.zIndex) - NumCast(doc2.zIndex));
+ zsorted.forEach((doc, index) => {
+ doc.zIndex = doc.stroke_isInkMask ? 5000 : index + 1;
+ });
+ const dvals = CollectionFreeFormDocumentView.getValues(refDoc, NumCast(refDoc.activeFrame, 1000));
+ const dropPos = this.Document._currentFrame !== undefined ? [NumCast(dvals.x), NumCast(dvals.y)] : [NumCast(refDoc.x), NumCast(refDoc.y)];
+
+ docDragData.droppedDocuments.forEach((d, i) => {
+ const layoutDoc = Doc.Layout(d);
+ const delta = Utils.rotPt(x - dropPos[0], y - dropPos[1], fromScreenXf.Rotate);
+ if (this.Document._currentFrame !== undefined) {
+ CollectionFreeFormDocumentView.setupKeyframes([d], NumCast(this.Document._currentFrame), false);
+ const pvals = CollectionFreeFormDocumentView.getValues(d, NumCast(d.activeFrame, 1000)); // get filled in values (uses defaults when not value is specified) for position
+ const vals = CollectionFreeFormDocumentView.getValues(d, NumCast(d.activeFrame, 1000), false); // get non-default values for everything else
+ vals.x = NumCast(pvals.x) + delta.x;
+ vals.y = NumCast(pvals.y) + delta.y;
+ CollectionFreeFormDocumentView.setValues(NumCast(this.Document._currentFrame), d, vals);
+ } else {
+ d.x = NumCast(d.x) + delta.x;
+ d.y = NumCast(d.y) + delta.y;
+ }
+ d._layout_modificationDate = new DateField();
+ const nd = [Doc.NativeWidth(layoutDoc), Doc.NativeHeight(layoutDoc)];
+ layoutDoc._width = NumCast(layoutDoc._width, 300);
+ layoutDoc._height = NumCast(layoutDoc._height, nd[0] && nd[1] ? (nd[1] / nd[0]) * NumCast(layoutDoc._width) : 300);
+ !d._keepZWhenDragged && (d.zIndex = zsorted.length + 1 + i); // bringToFront
+ });
+ (docDragData.droppedDocuments.length === 1 || de.shiftKey) && this._clusters.addDocuments(docDragData.droppedDocuments);
});
- (docDragData.droppedDocuments.length === 1 || de.shiftKey) && this._clusters.addDocuments(docDragData.droppedDocuments);
return true;
}
@@ -490,17 +500,24 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
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.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))) {
const hit = this._clusters.handlePointerDown(this.screenToFreeformContentsXf.transformPoint(e.clientX, e.clientY));
setupMoveUpEvents(this, e, this.onPointerMove, emptyFunction, emptyFunction, hit !== -1, false);
}
break;
- default:
+ default:
}
}
}
@@ -546,7 +563,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
};
@action
onEraserUp = (): 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();
};
@@ -581,41 +598,92 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
this._lastY = e.clientY;
};
+ _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,
- 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 = InkField.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,
+ 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: Gestures, points: InkData, text?: any) => {
this.onGesture(e, new GestureUtils.GestureEvent(gesture, points, InkField.getBounds(points), text));
};
@@ -634,32 +702,125 @@ 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
+ const 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 }) => {
+ let isInside = false;
+ if (!isNaN(eraserOutline[0].X) && !isNaN(eraserOutline[0].Y)) {
+ let [minX, minY] = [eraserOutline[0].X, eraserOutline[0].Y];
+ let [maxX, maxY] = [eraserOutline[0].X, 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 (let 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
*/
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) };
- // prettier-ignore
- return this.childDocs
+
+ return this.childDocs
.map(doc => DocumentView.getDocumentView(doc, this.DocumentView?.()))
.filter(inkView => inkView?.ComponentView instanceof InkingStroke)
- .map(inkView => inkView!)
- .map(inkView => ({ inkViewBounds: inkView.getBounds, inkStroke: inkView.ComponentView as InkingStroke, inkView }))
- .filter(({ inkViewBounds }) =>
+ .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)
+ eraserMax.Y >= inkViewBounds.top
+ )
.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 (let 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 },
@@ -675,54 +836,342 @@ 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
+ let boundingBoxDist = ActiveEraserWidth() + 5;
+ this.zoomScaling() < 1 ? (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 => DocumentView.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 (let i = 0; i < inkData.length - 3; i += 4) {
+ // iterate over each segment of bezier curve
+ for (let 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(ival => Math.abs(ival - (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();
+ let currSegment: Segment = [];
+
+ // any radius erase stroke will always result in even tVals, since the ends are included
+ if (tVals.length % 2 !== 0) {
+ for (let i = 0; i < inkData.length - 3; i += 4) {
+ currSegment.push(InkField.Segment(inkData, i));
+ }
+ segments.push(currSegment);
+ return segments; // return the full original stroke
+ }
+
+ let continueErasing = false; // used to erase segments if they are completely enclosed in the eraser
+ let 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 (let 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 (let 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 (let 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[] = [];
- let segment: Segment = [];
- let startSegmentT = 0;
+ let segment1: Segment = [];
+ let 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)
+ let intersections: number[] = []; // list of the ink stroke's intersections
+ const 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 (let 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) {
- // eslint-disable-next-line no-loop-func
- 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);
- }
- // 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;
- });
+ const inkSegment: Bezier = InkField.Segment(inkData, i);
+ let 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 (let j = 0; j < currIntersects.length; j++) {
+ segmentIndexes.push(Math.floor(i / 4));
+ }
+ }
+ }
+
+ let 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)
+ const closestTs = this.getClosestTs(intersections, excludeT, 0, intersections.length - 1);
+
+ // find the segments that need to be split
+ let splitSegment1 = -1; // stays -1 if left end is deleted
+ let 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 {
- segment.push(inkSegment);
+ // 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
+
+ let hasSplit = false;
+ let continueErasing = false;
+ // loop through segments again and split them if they match the split segments
+ for (let 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);
+ }
+ } 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);
+ }
+ }
+ // case where last end is erased
+ else 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);
+ }
+ }
+ }
+
+ // 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 (excludeT < startSegmentT || excludeT > inkData.length / 4) {
- segment.length && segments.push(segment);
+ 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];
+ }
+ if (tVals[endIndex] < excludeT) {
+ return [endIndex, -1];
+ }
+ const mid = Math.floor((startIndex + endIndex) / 2);
+ if (excludeT >= tVals[mid]) {
+ if (mid + 1 <= endIndex && tVals[mid + 1] > excludeT) {
+ return [mid, mid + 1];
+ }
+ return this.getClosestTs(tVals, excludeT, mid + 1, endIndex);
+ }
+ if (mid - 1 >= startIndex && tVals[mid - 1] < excludeT) {
+ return [mid - 1, mid];
+ }
+ 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] });
}
@@ -761,10 +1210,10 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
if (apt.d !== undefined && apt.d < 1 && apt.t !== undefined && !tVals.includes(apt.t)) {
tVals.push(apt.t);
}
- this.bintersects(curve, otherCurve).forEach((val: string | number /* , i: number */) => {
+ this.bintersects(curve, otherCurve).forEach((val: string | number, ival: 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).
+ if (ival % 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).
});
if (bpt.d !== undefined && bpt.d < 1 && bpt.t !== undefined && !tVals.includes(bpt.t)) {
tVals.push(bpt.t);
@@ -1066,9 +1515,10 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
/>
);
}
- addDocTab = action((docsIn: Doc | Doc[], where: OpenWhere) => {
+ addDocTab = action((docsIn: Doc | Doc[], location: OpenWhere) => {
const docs = toList(docsIn);
- if (this._props.isAnnotationOverlay) return this._props.addDocTab(docs, where);
+ if (this._props.isAnnotationOverlay) return this._props.addDocTab(docs, location);
+ const where = location.split(':')[0];
switch (where) {
case OpenWhere.inParent:
return this._props.addDocument?.(docs) || false;
@@ -1095,15 +1545,14 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
}
if (firstDoc === this.Document || this.childDocList?.includes(firstDoc) || this.childLayoutPairs.map(pair => pair.layout)?.includes(firstDoc)) {
if (firstDoc.hidden) firstDoc.hidden = false;
- return true;
+ if (!location.includes(OpenWhereMod.always)) return true;
}
}
break;
default:
}
- return this._props.addDocTab(docs, where);
+ return this._props.addDocTab(docs, location);
});
-
getCalculatedPositions(pair: { layout: Doc; data?: Doc }): PoolData {
const random = (min: number, max: number, x: number, y: number) => /* min should not be equal to max */ min + (((Math.abs(x * y) * 9301 + 49297) % 233280) / 233280) * (max - min);
const childDoc = pair.layout;
@@ -1120,16 +1569,15 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
const rotation = Cast(_rotation,'number',
!this.layoutDoc._rotation_jitter ? null
: NumCast(this.layoutDoc._rotation_jitter) * random(-1, 1, NumCast(x), NumCast(y)) );
- const childProps = { ...this._props, fieldKey: '', styleProvider: this._clusters.styleProvider };
return {
x: isNaN(NumCast(x)) ? 0 : NumCast(x),
y: isNaN(NumCast(y)) ? 0 : NumCast(y),
z: Cast(z, 'number'),
autoDim,
rotation,
- color: Cast(color, 'string') ? StrCast(color) : this._clusters.styleProvider(childDoc, childProps, StyleProp.Color),
- backgroundColor: Cast(backgroundColor, 'string') ? StrCast(backgroundColor) : this._clusters.styleProvider(childDoc, childProps, StyleProp.BackgroundColor),
- opacity: !_width ? 0 : this._keyframeEditing ? 1 : Cast(opacity, 'number') ?? this._clusters.styleProvider?.(childDoc, childProps, StyleProp.Opacity),
+ color: Cast(color, 'string', null),
+ backgroundColor: Cast(backgroundColor, 'string', null),
+ opacity: !_width ? 0 : this._keyframeEditing ? 1 : Cast(opacity, 'number', null),
zIndex: Cast(zIndex, 'number'),
width: _width,
height: _height,
@@ -1201,6 +1649,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
}
doFreeformLayout(poolData: Map<string, PoolData>) {
+ this._clusters.initLayout();
this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map(pair => poolData.set(pair.layout[Id], this.getCalculatedPositions(pair)));
return [] as ViewDefResult[];
}
@@ -1229,7 +1678,6 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
})
);
- this._clusters.initLayout();
return elements;
};
@@ -1366,10 +1814,23 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
}
);
- onCursorMove = () => {
+ @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();
@@ -1539,7 +2000,6 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
}
this.childDocs.some(doc => !this._renderCutoffData.get(doc[Id])) && setTimeout(this.incrementalRender, 1);
});
-
showPresPaths = () => SnappingManager.ShowPresPaths;
brushedView = () => this._brushedView;
gridColor = () => DashColor(lightOrDark(this.backgroundColor)).fade(0.5).toString(); // prettier-ignore
@@ -1584,6 +2044,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
</div>
);
}
+ transitionFunc = () => (this._panZoomTransition ? `transform ${this._panZoomTransition}ms ${this._presEaseFunc}` : Cast(this.layoutDoc._viewTransition, 'string', Cast(this.Document._viewTransition, 'string', null)));
get pannableContents() {
this.incrementalRender(); // needs to happen synchronously or freshly typed text documents will flash and miss their first characters
return (
@@ -1593,7 +2054,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
isAnnotationOverlay={this.isAnnotationOverlay}
transform={this.PanZoomCenterXf}
showPresPaths={this.showPresPaths}
- transition={this.panZoomTransition}
+ transition={this.transitionFunc}
viewDefDivClick={this._props.viewDefDivClick}>
{this.props.children ?? null} {/* most likely case of children is document content that's being annoated: eg., an image */}
{this.contentViews}
@@ -1645,6 +2106,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}
@@ -1660,6 +2123,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 ? (
@@ -1698,6 +2176,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
);
}
}
+
// eslint-disable-next-line prefer-arrow-callback
ScriptingGlobals.add(function nextKeyFrame(readOnly: boolean) {
!readOnly && (DocumentView.Selected()[0].ComponentView as CollectionFreeFormView)?.changeKeyFrame();
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelHandler.scss b/src/client/views/collections/collectionFreeForm/ImageLabelHandler.scss
new file mode 100644
index 000000000..e7413bf8e
--- /dev/null
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelHandler.scss
@@ -0,0 +1,44 @@
+#label-handler {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ > div:first-child {
+ display: flex; // Puts the input and button on the same row
+ align-items: center; // Vertically centers items in the flex container
+
+ input {
+ color: black;
+ }
+
+ .IconButton {
+ margin-left: 8px; // Adds space between the input and the icon button
+ width: 19px;
+ }
+ }
+
+ > div:not(:first-of-type) {
+ display: flex;
+ flex-direction: column;
+ align-items: center; // Centers the content vertically in the flex container
+ width: 100%;
+
+ > div {
+ display: flex;
+ justify-content: space-between; // Puts the content and delete button on opposite ends
+ align-items: center;
+ width: 100%;
+ margin-top: 8px; // Adds space between label rows
+
+ p {
+ text-align: center; // Centers the text of the paragraph
+ flex-grow: 1; // Allows the paragraph to grow and occupy the available space
+ }
+
+ .IconButton {
+ // Styling for the delete button
+ margin-left: auto; // Pushes the button to the far right
+ }
+ }
+ }
+}
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx
new file mode 100644
index 000000000..7f27c6b5c
--- /dev/null
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx
@@ -0,0 +1,120 @@
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { IconButton } from 'browndash-components';
+import { action, makeObservable, observable } from 'mobx';
+import { observer } from 'mobx-react';
+import React from 'react';
+import { SettingsManager } from '../../../util/SettingsManager';
+import { ObservableReactComponent } from '../../ObservableReactComponent';
+import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
+import './ImageLabelHandler.scss';
+
+@observer
+export class ImageLabelHandler extends ObservableReactComponent<{}> {
+ static Instance: ImageLabelHandler;
+
+ @observable _display: boolean = false;
+ @observable _pageX: number = 0;
+ @observable _pageY: number = 0;
+ @observable _yRelativeToTop: boolean = true;
+ @observable _currentLabel: string = '';
+ @observable _labelGroups: string[] = [];
+
+ constructor(props: any) {
+ super(props);
+ makeObservable(this);
+ ImageLabelHandler.Instance = this;
+ console.log('Instantiated label handler!');
+ }
+
+ @action
+ displayLabelHandler = (x: number, y: number) => {
+ this._pageX = x;
+ this._pageY = y;
+ this._display = true;
+ this._labelGroups = [];
+ };
+
+ @action
+ hideLabelhandler = () => {
+ this._display = false;
+ this._labelGroups = [];
+ };
+
+ @action
+ addLabel = (label: string) => {
+ label = label.toUpperCase().trim();
+ if (label.length > 0) {
+ if (!this._labelGroups.includes(label)) {
+ this._labelGroups = [...this._labelGroups, label];
+ }
+ }
+ };
+
+ @action
+ removeLabel = (label: string) => {
+ const labelUp = label.toUpperCase();
+ this._labelGroups = this._labelGroups.filter(group => group !== labelUp);
+ };
+
+ @action
+ groupImages = () => {
+ MarqueeOptionsMenu.Instance.groupImages();
+ this._display = false;
+ };
+
+ render() {
+ if (this._display) {
+ return (
+ <div
+ id="label-handler"
+ className="contextMenu-cont"
+ style={{
+ display: this._display ? '' : 'none',
+ left: this._pageX,
+ ...(this._yRelativeToTop ? { top: Math.max(0, this._pageY) } : { bottom: this._pageY }),
+ background: SettingsManager.userBackgroundColor,
+ color: SettingsManager.userColor,
+ }}>
+ <div>
+ <IconButton tooltip={'Cancel'} onPointerDown={this.hideLabelhandler} icon={<FontAwesomeIcon icon="eye-slash" />} color={MarqueeOptionsMenu.Instance.userColor} style={{ width: '19px' }} />
+ <input aria-label="label-input" id="new-label" type="text" style={{ color: 'black' }} />
+ <IconButton
+ tooltip={'Add Label'}
+ onPointerDown={() => {
+ const input = document.getElementById('new-label') as HTMLInputElement;
+ const newLabel = input.value;
+ this.addLabel(newLabel);
+ this._currentLabel = '';
+ input.value = '';
+ }}
+ icon={<FontAwesomeIcon icon="plus" />}
+ color={MarqueeOptionsMenu.Instance.userColor}
+ style={{ width: '19px' }}
+ />
+ <IconButton tooltip={'Group Images'} onPointerDown={this.groupImages} icon={<FontAwesomeIcon icon="object-group" />} color={MarqueeOptionsMenu.Instance.userColor} style={{ width: '19px' }} />
+ </div>
+ <div>
+ {this._labelGroups.map(group => {
+ return (
+ <div>
+ <p>{group}</p>
+ <IconButton
+ tooltip={'Remove Label'}
+ onPointerDown={() => {
+ this.removeLabel(group);
+ }}
+ icon={'x'}
+ color={MarqueeOptionsMenu.Instance.userColor}
+ style={{ width: '19px' }}
+ />
+ </div>
+ );
+ })}
+ </div>
+ </div>
+ );
+ } else {
+ return <></>;
+ }
+ }
+}
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx
index adac5a102..f02cd9d45 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx
+++ b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx
@@ -18,6 +18,8 @@ export class MarqueeOptionsMenu extends AntimodeMenu<AntimodeMenuProps> {
public showMarquee: () => void = unimplementedFunction;
public hideMarquee: () => void = unimplementedFunction;
public pinWithView: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction;
+ public classifyImages: (e: React.MouseEvent | undefined) => void = unimplementedFunction;
+ public groupImages: () => void = unimplementedFunction;
public isShown = () => this._opacity > 0;
constructor(props: any) {
super(props);
@@ -37,6 +39,7 @@ export class MarqueeOptionsMenu extends AntimodeMenu<AntimodeMenuProps> {
<IconButton tooltip="Summarize Documents" onPointerDown={this.summarize} icon={<FontAwesomeIcon icon="compress-arrows-alt" />} color={this.userColor} />
<IconButton tooltip="Delete Documents" onPointerDown={this.delete} icon={<FontAwesomeIcon icon="trash-alt" />} color={this.userColor} />
<IconButton tooltip="Pin selected region" onPointerDown={this.pinWithView} icon={<FontAwesomeIcon icon="map-pin" />} color={this.userColor} />
+ <IconButton tooltip="Classify Images" onPointerDown={this.classifyImages} icon={<FontAwesomeIcon icon="object-group" />} color={this.userColor} />
</>
);
return this.getElement(buttons);
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
index b96444024..dc15c83c5 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
+++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
@@ -1,21 +1,23 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
+import similarity from 'compute-cosine-similarity';
import { action, computed, makeObservable, observable } from 'mobx';
import { observer } from 'mobx-react';
import * as React from 'react';
import { ClientUtils, lightOrDark, returnFalse } from '../../../../ClientUtils';
-import { intersectRect } from '../../../../Utils';
-import { Doc, Opt } from '../../../../fields/Doc';
+import { intersectRect, numberRange } from '../../../../Utils';
+import { Doc, NumListCast, Opt } from '../../../../fields/Doc';
import { AclAdmin, AclAugment, AclEdit, DocData } from '../../../../fields/DocSymbols';
import { Id } from '../../../../fields/FieldSymbols';
import { InkData, InkField, InkTool } from '../../../../fields/InkField';
import { List } from '../../../../fields/List';
import { RichTextField } from '../../../../fields/RichTextField';
-import { Cast, FieldValue, NumCast, StrCast } from '../../../../fields/Types';
+import { Cast, FieldValue, ImageCast, NumCast, StrCast } from '../../../../fields/Types';
import { ImageField } from '../../../../fields/URLField';
import { GetEffectiveAcl } from '../../../../fields/util';
+import { gptGetEmbedding, gptImageLabel } from '../../../apis/gpt/GPT';
import { CognitiveServices } from '../../../cognitive_services/CognitiveServices';
import { DocUtils } from '../../../documents/DocUtils';
-import { DocumentType } from '../../../documents/DocumentTypes';
+import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes';
import { Docs, DocumentOptions } from '../../../documents/Documents';
import { SnappingManager, freeformScrollMode } from '../../../util/SnappingManager';
import { Transform } from '../../../util/Transform';
@@ -28,7 +30,10 @@ import { DocumentView } from '../../nodes/DocumentView';
import { OpenWhere } from '../../nodes/OpenWhere';
import { pasteImageBitmap } from '../../nodes/WebBoxRenderer';
import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox';
+import { CollectionCardView } from '../CollectionCardDeckView';
import { SubCollectionViewProps } from '../CollectionSubView';
+import { CollectionFreeFormView } from './CollectionFreeFormView';
+import { ImageLabelHandler } from './ImageLabelHandler';
import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
import './MarqueeView.scss';
@@ -61,11 +66,13 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
}
private _commandExecuted = false;
+ private _selectedDocs: Doc[] = [];
@observable _lastX: number = 0;
@observable _lastY: number = 0;
@observable _downX: number = 0;
@observable _downY: number = 0;
@observable _visible: boolean = false; // selection rentangle for marquee selection/free hand lasso is visible
+ @observable _labelsVisibile: boolean = false;
@observable _lassoPts: [number, number][] = [];
@observable _lassoFreehand: boolean = false;
@@ -267,6 +274,8 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
MarqueeOptionsMenu.Instance.hideMarquee = this.hideMarquee;
MarqueeOptionsMenu.Instance.jumpTo(e.clientX, e.clientY);
MarqueeOptionsMenu.Instance.pinWithView = this.pinWithView;
+ MarqueeOptionsMenu.Instance.classifyImages = this.classifyImages;
+ MarqueeOptionsMenu.Instance.groupImages = this.groupImages;
document.addEventListener('pointerdown', hideMarquee, true);
document.addEventListener('wheel', hideMarquee, true);
} else {
@@ -419,6 +428,66 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
this.hideMarquee();
});
+ /**
+ * Classifies images and assigns the labels as document fields.
+ * TODO: Turn into lists of labels instead of individual fields.
+ */
+ @undoBatch
+ classifyImages = action(async (e: React.MouseEvent | undefined) => {
+ this._selectedDocs = this.marqueeSelect(false, DocumentType.IMG);
+
+ const imageInfos = this._selectedDocs.map(async doc => {
+ const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
+ return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 =>
+ !hrefBase64 ? undefined :
+ gptImageLabel(hrefBase64).then(labels =>
+ Promise.all(labels.split('\n').map(label => gptGetEmbedding(label))).then(embeddings =>
+ ({ doc, embeddings, labels }))) ); // prettier-ignore
+ });
+
+ (await Promise.all(imageInfos)).forEach(imageInfo => {
+ if (imageInfo && Array.isArray(imageInfo.embeddings)) {
+ imageInfo.doc[DocData].data_labels = imageInfo.labels;
+ numberRange(3).forEach(n => {
+ imageInfo.doc[`data_labels_embedding_${n + 1}`] = new List<number>(imageInfo.embeddings[n]);
+ });
+ }
+ });
+
+ if (e) {
+ ImageLabelHandler.Instance.displayLabelHandler(e.pageX, e.pageY);
+ }
+ });
+
+ /**
+ * Groups images to most similar labels.
+ */
+ @undoBatch
+ groupImages = action(async () => {
+ const labelGroups = ImageLabelHandler.Instance._labelGroups;
+ const labelToEmbedding = new Map<string, number[]>();
+ // Create embeddings for the labels.
+ await Promise.all(labelGroups.map(async label => gptGetEmbedding(label).then(labelEmbedding => labelToEmbedding.set(label, labelEmbedding))));
+
+ // For each image, loop through the labels, and calculate similarity. Associate it with the
+ // most similar one.
+ this._selectedDocs.forEach(doc => {
+ const embedLists = numberRange(3).map(n => Array.from(NumListCast(doc[`data_labels_embedding_${n + 1}`])));
+ const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map(l => (embedding && similarity(Array.from(embedding), l)) || 0));
+ const {label: mostSimilarLabelCollect} =
+ labelGroups.map(label => ({ label, similarityScore: bestEmbedScore(labelToEmbedding.get(label)) }))
+ .reduce((prev, cur) => cur.similarityScore < 0.3 || cur.similarityScore <= prev.similarityScore ? prev: cur,
+ { label: '', similarityScore: 0, }); // prettier-ignore
+
+ numberRange(3).forEach(n => {
+ doc[`data_labels_embedding_${n + 1}`] = undefined;
+ });
+ doc[DocData].data_label = mostSimilarLabelCollect;
+ });
+ this._props.Document._type_collection = CollectionViewType.Time;
+ this._props.Document.pivotField = 'data_label';
+ });
+
@undoBatch
syntaxHighlight = action((e: KeyboardEvent | React.PointerEvent | undefined) => {
const selected = this.marqueeSelect(false);
@@ -579,7 +648,10 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
return false;
}
- marqueeSelect(selectBackgrounds: boolean = false) {
+ /**
+ * When this is called, returns the list of documents that have been selected by the marquee box.
+ */
+ marqueeSelect(selectBackgrounds: boolean = false, docType: DocumentType | undefined = undefined) {
const selection: Doc[] = [];
const selectFunc = (doc: Doc) => {
const layoutDoc = Doc.Layout(doc);
@@ -590,10 +662,17 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
(this.touchesLine(bounds) || this.boundingShape(bounds)) && selection.push(doc);
}
};
- this._props
- .activeDocuments()
- .filter(doc => !doc.z && !doc._lockedPosition)
- .map(selectFunc);
+ if (docType) {
+ this._props
+ .activeDocuments()
+ .filter(doc => !doc.z && !doc._lockedPosition && doc.type === docType)
+ .map(selectFunc);
+ } else {
+ this._props
+ .activeDocuments()
+ .filter(doc => !doc.z && !doc._lockedPosition)
+ .map(selectFunc);
+ }
if (!selection.length && selectBackgrounds)
this._props
.activeDocuments()