import * as React from 'react'; import { action, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import { Doc } from '../../fields/Doc'; import { ControlPoint, InkData, PointData } from '../../fields/InkField'; import { List } from '../../fields/List'; import { listSpec } from '../../fields/Schema'; import { Cast } from '../../fields/Types'; import { returnFalse, setupMoveUpEvents } from '../../Utils'; import { SelectionManager } from '../util/SelectionManager'; import { UndoManager } from '../util/UndoManager'; import { Colors } from './global/globalEnums'; import { InkingStroke } from './InkingStroke'; import { InkStrokeProperties } from './InkStrokeProperties'; import { SnappingManager } from '../util/SnappingManager'; import { ObservableReactComponent } from './ObservableReactComponent'; export interface InkControlProps { inkDoc: Doc; inkView: InkingStroke; inkCtrlPoints: InkData; screenCtrlPoints: InkData; screenSpaceLineWidth: number; nearestScreenPt: () => PointData | undefined; } @observer export class InkControlPtHandles extends ObservableReactComponent { @observable private _overControl = -1; get docView() { return this._props.inkView.DocumentView?.(); } constructor(props: InkControlProps) { super(props); makeObservable(this); } componentDidMount() { document.addEventListener('keydown', this.onDelete, true); } componentWillUnmount() { document.removeEventListener('keydown', this.onDelete, true); } /** * Handles the movement of a selected control point when the user clicks and drags. * @param controlIndex The index of the currently selected control point. */ @action onControlDown = (e: React.PointerEvent, controlIndex: number): void => { const ptFromScreen = this._props.inkView.ptFromScreen; if (ptFromScreen) { const order = controlIndex % 4; const handleIndexA = ((order === 3 ? controlIndex - 1 : controlIndex - 2) + this._props.inkCtrlPoints.length) % this._props.inkCtrlPoints.length; const handleIndexB = (order === 3 ? controlIndex + 2 : controlIndex + 1) % this._props.inkCtrlPoints.length; const brokenIndices = Cast(this._props.inkDoc.brokenInkIndices, listSpec('number')); const wasSelected = InkStrokeProperties.Instance._currentPoint === controlIndex; if (!wasSelected) InkStrokeProperties.Instance._currentPoint = -1; const origInk = this._props.inkCtrlPoints.slice(); setupMoveUpEvents( this, e, action((e: PointerEvent, down: number[], delta: number[]) => { if (!this._props.inkView.controlUndo) this._props.inkView.controlUndo = UndoManager.StartBatch('drag ink ctrl pt'); const inkMoveEnd = ptFromScreen({ X: delta[0], Y: delta[1] }); const inkMoveStart = ptFromScreen({ X: 0, Y: 0 }); this.docView && InkStrokeProperties.Instance.moveControlPtHandle(this.docView, inkMoveEnd.X - inkMoveStart.X, inkMoveEnd.Y - inkMoveStart.Y, controlIndex, origInk); return false; }), action(() => { if (this._props.inkView.controlUndo && this.docView) { InkStrokeProperties.Instance.snapControl(this.docView, controlIndex); } this._props.inkView.controlUndo?.end(); this._props.inkView.controlUndo = undefined; UndoManager.FilterBatches(['data', 'x', 'y', 'width', 'height']); }), action((e: PointerEvent, doubleTap: boolean | undefined) => { const equivIndex = controlIndex === 0 ? this._props.inkCtrlPoints.length - 1 : controlIndex === this._props.inkCtrlPoints.length - 1 ? 0 : controlIndex; if (doubleTap || e.button === 2) { if (!brokenIndices?.includes(equivIndex) && !brokenIndices?.includes(controlIndex)) { if (brokenIndices) brokenIndices.push(controlIndex); else this._props.inkDoc.brokenInkIndices = new List([controlIndex]); } else { if (brokenIndices?.includes(equivIndex)) { if (!this._props.inkView.controlUndo) this._props.inkView.controlUndo = UndoManager.StartBatch('make smooth'); this.docView && InkStrokeProperties.Instance.snapHandleTangent(this.docView, equivIndex, handleIndexA, handleIndexB); } if (equivIndex !== controlIndex && brokenIndices?.includes(controlIndex)) { if (!this._props.inkView.controlUndo) this._props.inkView.controlUndo = UndoManager.StartBatch('make smooth'); this.docView && InkStrokeProperties.Instance.snapHandleTangent(this.docView, controlIndex, handleIndexA, handleIndexB); } } this._props.inkView.controlUndo?.end(); this._props.inkView.controlUndo = undefined; } this.changeCurrPoint(controlIndex); }), undefined, undefined, () => wasSelected && this.changeCurrPoint(-1) ); } }; /** * Updates whether a user has hovered over a particular control point or point that could be added * on click. */ @action onEnterControl = (i: number) => { this._overControl = i; }; @action onLeaveControl = () => { this._overControl = -1; }; /** * Deletes the currently selected point. */ @action onDelete = (e: KeyboardEvent) => { if (['-', 'Backspace', 'Delete'].includes(e.key)) { this.docView && InkStrokeProperties.Instance.deletePoints(this.docView, e.shiftKey); e.stopPropagation(); } }; /** * Changes the current selected control point. */ @action changeCurrPoint = (i: number) => (InkStrokeProperties.Instance._currentPoint = i); render() { // Accessing the current ink's data and extracting all control points. const scrData = this._props.screenCtrlPoints; const sreenCtrlPoints: ControlPoint[] = []; for (let i = 0; i <= scrData.length - 4; i += 4) { sreenCtrlPoints.push({ ...scrData[i], I: i }); sreenCtrlPoints.push({ ...scrData[i + 3], I: i + 3 }); } const inkData = this._props.inkCtrlPoints; const inkCtrlPts: ControlPoint[] = []; for (let i = 0; i <= inkData.length - 4; i += 4) { inkCtrlPts.push({ ...inkData[i], I: i }); inkCtrlPts.push({ ...inkData[i + 3], I: i + 3 }); } const closed = InkingStroke.IsClosed(inkData); const nearestScreenPt = this._props.nearestScreenPt(); const TagType = (broken?: boolean) => (broken ? 'rect' : 'circle'); const hdl = (control: { X: number; Y: number; I: number }, scale: number, color: string) => { const broken = Cast(this._props.inkDoc.brokenInkIndices, listSpec('number'))?.includes(control.I); const Tag = TagType((control.I === 0 || control.I === inkData.length - 1) && !closed) as keyof JSX.IntrinsicElements; return ( this.onControlDown(e, control.I)} onMouseEnter={() => this.onEnterControl(control.I)} onMouseLeave={this.onLeaveControl} pointerEvents="all" cursor="default" /> ); }; return ( {!nearestScreenPt ? null : } {sreenCtrlPoints.map(control => hdl(control, this._overControl !== control.I ? 1 : 3 / 2, Colors.WHITE))} ); } } export interface InkEndProps { inkDoc: Doc; inkView: InkingStroke; screenSpaceLineWidth: number; startPt: () => PointData; endPt: () => PointData; } @observer export class InkEndPtHandles extends ObservableReactComponent { @observable _overStart: boolean = false; @observable _overEnd: boolean = false; constructor(props: InkEndProps) { super(props); makeObservable(this); } _throttle = 0; // need to throttle dragging since the position may change when the control points change. this allows the stroke to settle so that we don't get increasingly bad jitter @action dragRotate = (e: React.PointerEvent, pt1: () => { X: number; Y: number }, pt2: () => { X: number; Y: number }) => { SnappingManager.SetIsDragging(true); setupMoveUpEvents( this, e, action(e => { if (this._throttle++ % 2 !== 0) return false; if (!this._props.inkView.controlUndo) this._props.inkView.controlUndo = UndoManager.StartBatch('stretch ink'); // compute stretch factor by finding scaling along axis between start and end points const p1 = pt1(); const p2 = pt2(); const v1 = { X: p1.X - p2.X, Y: p1.Y - p2.Y }; const v2 = { X: e.clientX - p2.X, Y: e.clientY - p2.Y }; const v1len = Math.sqrt(v1.X * v1.X + v1.Y * v1.Y); const v2len = Math.sqrt(v2.X * v2.X + v2.Y * v2.Y); const scaling = v2len / v1len; const v1n = { X: v1.X / v1len, Y: v1.Y / v1len }; const v2n = { X: v2.X / v2len, Y: v2.Y / v2len }; const angle = Math.acos(v1n.X * v2n.X + v1n.Y * v2n.Y) * Math.sign(v1.X * v2.Y - v2.X * v1.Y); InkStrokeProperties.Instance.stretchInk(SelectionManager.Views, scaling, p2, v1n, e.shiftKey); InkStrokeProperties.Instance.rotateInk(SelectionManager.Views, angle, pt2()); // bcz: call pt2() func here because pt2 will have changed from previous stretchInk call return false; }), action(() => { SnappingManager.SetIsDragging(false); this._props.inkView.controlUndo?.end(); this._props.inkView.controlUndo = undefined; UndoManager.FilterBatches(['stroke', 'x', 'y', 'width', 'height']); }), returnFalse ); }; render() { const hdl = (key: string, pt: PointData, dragFunc: (e: React.PointerEvent) => void) => ( (this._overStart = false))} onPointerEnter={action(() => (this._overStart = true))} onPointerDown={dragFunc} pointerEvents="all" /> ); return ( {hdl('start', this._props.startPt(), e => this.dragRotate(e, this._props.startPt, this._props.endPt))} {hdl('end', this._props.endPt(), e => this.dragRotate(e, this._props.endPt, this._props.startPt))} ); } }