/* InkingStroke - a document that represents an individual vector stroke drawn as a Bezier curve (open or closed) and optionally filled. The primary data is: data - an InkField which is an array of PointData (X,Y values). The data is laid out as a sequence of simple bezier segments: point 1, tangent pt 1, tangent pt 2, point 2, point 3, tangent pt 3, ... (Note that segment endpoints are duplicated ie Point2 = Point 3) brokenIndices - an array of indexes into the data field where the incoming and outgoing tangents are not constrained to be equal text - a text field that will be centered within a closed ink stroke stroke_isInkMask - a flag that makes the ink stroke render as a mask over its collection where the stroke itself is mixBlendMode multiplied by the underlying collection content, and everything outside the stroke is covered by a semi-opaque dark gray mask. The coordinates of the ink data need to be mapped to the screen since ink points are not changed when the DocumentView is translated or scaled. Thus the mapping can roughly be described by: the Top/Left of the ink data (minus 1/2 the ink width) maps to the Top/Left of the DocumentView the Width/Height of the ink data (minus the ink width) is scaled to the PanelWidth/PanelHeight of the documentView NOTE: use ptToScreen() and ptFromScreen() to transform between ink and screen space InkStrokes have a specialized 'componentUI' method that is called by MainView to render all of the interactive editing controls in screen space (to avoid scaling artifacts) Most of the operations that can be performed on an InkStroke (eg delete a point, rotate, stretch) are implemented in the InkStrokeProperties helper class */ import { Property } from 'csstype'; import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { DashColor, returnFalse, setupMoveUpEvents } from '../../ClientUtils'; import { Doc } from '../../fields/Doc'; import { InkData } from '../../fields/InkField'; import { BoolCast, InkCast, NumCast, RTFCast, StrCast } from '../../fields/Types'; import { TraceMobx } from '../../fields/util'; import { Gestures } from '../../pen-gestures/GestureTypes'; import { Docs } from '../documents/Documents'; import { DocumentType } from '../documents/DocumentTypes'; import { InteractionUtils } from '../util/InteractionUtils'; import { SnappingManager } from '../util/SnappingManager'; import { UndoManager } from '../util/UndoManager'; import { CollectionFreeFormView } from './collections/collectionFreeForm'; import { ContextMenu } from './ContextMenu'; import { ViewBoxAnnotatableComponent } from './DocComponent'; import { Colors } from './global/globalEnums'; import { InkControlPtHandles, InkEndPtHandles } from './InkControlPtHandles'; import './InkStroke.scss'; import { InkStrokeProperties } from './InkStrokeProperties'; import { InkTangentHandles } from './InkTangentHandles'; import { InkTranscription } from './InkTranscription'; import { DocumentView } from './nodes/DocumentView'; import { FieldView, FieldViewProps } from './nodes/FieldView'; import { FormattedTextBox, FormattedTextBoxProps } from './nodes/formattedText/FormattedTextBox'; import { PinDocView, PinProps } from './PinFuncs'; import { StyleProp } from './StyleProp'; import { ViewBoxInterface } from './ViewBoxInterface'; // eslint-disable-next-line @typescript-eslint/no-require-imports const { INK_MASK_SIZE } = require('./global/globalCssVariables.module.scss'); // prettier-ignore @observer export class InkingStroke extends ViewBoxAnnotatableComponent() { 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) public static LayoutString(fieldStr: string) { return FieldView.LayoutString(InkingStroke, fieldStr); } public static IsClosed(inkData: InkData) { return inkData?.length && inkData.lastElement().X === inkData[0].X && inkData.lastElement().Y === inkData[0].Y; } private _handledClick = false; // flag denoting whether ink stroke has handled a psuedo-click onPointerUp so that the real onClick event can be stopPropagated private _disposers: { [key: string]: IReactionDisposer } = {}; constructor(props: FieldViewProps) { super(props); makeObservable(this); } @observable _nearestSeg?: number = undefined; // nearest Bezier segment along the ink stroke to the cursor (used for displaying the Add Point highlight) @observable _nearestT?: number = undefined; // nearest t value within the nearest Bezier segment " @observable _nearestScrPt?: { X: number; Y: number } = { X: 0, Y: 0 }; // nearst screen point on the ink stroke "" componentDidMount() { this._props.setContentViewBox?.(this); this._disposers.selfDisper = reaction( () => this._props.isSelected(), // react to stroke being deselected by turning off ink handles selected => { !selected && (InkStrokeProperties.Instance._controlButton = false); } ); } componentWillUnmount() { Object.keys(this._disposers).forEach(key => this._disposers[key]()); } getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { const subAnchor = this._subContentView?.getAnchor?.(addAsAnnotation); if (subAnchor !== this.Document && subAnchor) return subAnchor; if (!addAsAnnotation && !pinProps) return this.Document; const anchor = Docs.Create.ConfigDocument({ title: 'Ink anchor:' + this.Document.title, // set presentation timing for restoring shape presentation_duration: 1100, presentation_transition: 1000, annotationOn: this.Document, }); if (anchor) { anchor.backgroundColor = 'transparent'; addAsAnnotation && this.addDocument(anchor); PinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ?? {}), inkable: true } }, this.Document); return anchor; } return this.Document; }; /** * analyzes the ink stroke and saves the analysis of the stroke to the 'inkAnalysis' field, * and the recognized words to the 'handwriting' */ analyzeStrokes = () => { const ffView = CollectionFreeFormView.from(this.DocumentView?.()); if (ffView) { const selected = DocumentView.SelectedDocs(); const newCollection = InkTranscription.Instance.groupInkDocs( selected.filter(doc => doc.embedContainer), ffView ); ffView.unprocessedDocs = []; InkTranscription.Instance.transcribeInk(ffView, newCollection, selected, false); } }; /** * Toggles whether the ink stroke is displayed as an overlay mask or as a regular stroke. * When displayed as a mask, the stroke is rendered with mixBlendMode set to multiply so that the stroke will * appear to illuminate what it covers up. At the same time, all pixels that are not under the stroke will be * dimmed by a semi-opaque overlay mask. */ public static toggleMask = action((inkDoc: Doc) => { inkDoc.stroke_isInkMask = !inkDoc.stroke_isInkMask; }); @observable controlUndo: UndoManager.Batch | undefined = undefined; /** * Drags the a simple bezier segment of the stroke. * Also adds a control point when double clicking on the stroke. */ @action onPointerDown = (e: React.PointerEvent) => { this._handledClick = false; const inkView = this.DocumentView?.(); if (!inkView) return; const { inkData, inkScaleX, inkScaleY, inkStrokeWidth, inkTop, inkLeft } = this.inkScaledData(); const screenPts = inkData .map(point => this.ScreenToLocalBoxXf() .inverse() .transformPoint((point.X - inkLeft - inkStrokeWidth / 2) * inkScaleX + inkStrokeWidth / 2, (point.Y - inkTop - inkStrokeWidth / 2) * inkScaleY + inkStrokeWidth / 2) ) .map(p => ({ X: p[0], Y: p[1] })); const { nearestSeg } = InkStrokeProperties.nearestPtToStroke(screenPts, { X: e.clientX, Y: e.clientY }); const controlIndex = nearestSeg; const wasSelected = InkStrokeProperties.Instance._currentPoint === controlIndex; const isEditing = InkStrokeProperties.Instance._controlButton && this._props.isSelected(); this.controlUndo = undefined; this._nearestScrPt = undefined; setupMoveUpEvents( this, e, !isEditing ? returnFalse : action((moveEv: PointerEvent, down: number[], delta: number[]) => { if (!this.controlUndo) this.controlUndo = UndoManager.StartBatch('drag ink ctrl pt'); const inkMoveEnd = this.ptFromScreen({ X: delta[0], Y: delta[1] }); const inkMoveStart = this.ptFromScreen({ X: 0, Y: 0 }); InkStrokeProperties.Instance.moveControlPtHandle(inkView, inkMoveEnd.X - inkMoveStart.X, inkMoveEnd.Y - inkMoveStart.Y, controlIndex); InkStrokeProperties.Instance.moveControlPtHandle(inkView, inkMoveEnd.X - inkMoveStart.X, inkMoveEnd.Y - inkMoveStart.Y, controlIndex + 3); return false; }), !isEditing ? returnFalse : action(() => { this.controlUndo?.end(); this.controlUndo = undefined; UndoManager.FilterBatches(['data', 'x', 'y', 'width', 'height']); }), action((moveEv: PointerEvent, doubleTap: boolean | undefined) => { if (doubleTap) { InkStrokeProperties.Instance._controlButton = true; InkStrokeProperties.Instance._currentPoint = -1; this._handledClick = true; // mark the double-click pseudo pointerevent so we can block the real mouse event from propagating to DocumentView if (isEditing) { this._nearestT && this._nearestSeg !== undefined && InkStrokeProperties.Instance.addPoints(inkView, this._nearestT, this._nearestSeg, this.inkScaledData().inkData.slice()); } } }), isEditing, isEditing, action(() => { wasSelected && (InkStrokeProperties.Instance._currentPoint = -1); }) ); }; /** * @param scrPt a point in the screen coordinate space * @returns the point in the ink data's coordinate space. */ ptFromScreen = (scrPt: { X: number; Y: number }) => { const { inkScaleX, inkScaleY, inkStrokeWidth, inkTop, inkLeft } = this.inkScaledData(); const docPt = this.ScreenToLocalBoxXf().transformPoint(scrPt.X, scrPt.Y); const inkPt = { X: (docPt[0] - inkStrokeWidth / 2) / inkScaleX + inkStrokeWidth / 2 + inkLeft, Y: (docPt[1] - inkStrokeWidth / 2) / inkScaleY + inkStrokeWidth / 2 + inkTop, }; return inkPt; }; /** * @param inkPt a point in the ink data's coordinate space * @returns the screen point corresponding to the ink point */ ptToScreen = (inkPt: { X: number; Y: number }) => { const { inkScaleX, inkScaleY, inkStrokeWidth, inkTop, inkLeft } = this.inkScaledData(); const docPt = { X: (inkPt.X - inkLeft - inkStrokeWidth / 2) * inkScaleX + inkStrokeWidth / 2, Y: (inkPt.Y - inkTop - inkStrokeWidth / 2) * inkScaleY + inkStrokeWidth / 2, }; const scrPt = this.ScreenToLocalBoxXf().inverse().transformPoint(docPt.X, docPt.Y); return { X: scrPt[0], Y: scrPt[1] }; }; /** * Snaps a screen space point to this stroke, optionally skipping bezier segments indicated by 'excludeSegs' * @param scrPt - the point to snap to this stroke * @param excludeSegs - optional segments in this stroke to skip (this is used when dragging a point on the stroke and not wanting the drag point to snap to its neighboring segments) * * @returns the nearest ink space point on this stroke to the screen point AND the screen space distance from the snapped point to the nearest point */ snapPt = (scrPt: { X: number; Y: number }, excludeSegs?: number[]) => { const { inkData } = this.inkScaledData(); const { nearestPt, distance } = InkStrokeProperties.nearestPtToStroke(inkData, this.ptFromScreen(scrPt), excludeSegs ?? []); return { nearestPt, distance: distance * this.ScreenToLocalBoxXf().inverse().Scale }; }; /** * extracts key features from the inkData, including: the data points, the ink width, the ink bounds (top,left, width, height), and the scale * factor for converting between ink and screen space. */ inkScaledData = () => { const inkData = InkCast(this.dataDoc[this.fieldKey], InkCast(this.layoutDoc[this.fieldKey]) ?? null)?.inkData ?? []; const inkStrokeWidth = NumCast(this.layoutDoc.stroke_width, 1); const inkTop = Math.min(...inkData.map(p => p.Y)) - inkStrokeWidth / 2; const inkBottom = Math.max(...inkData.map(p => p.Y)) + inkStrokeWidth / 2; const inkLeft = Math.min(...inkData.map(p => p.X)) - inkStrokeWidth / 2; const inkRight = Math.max(...inkData.map(p => p.X)) + inkStrokeWidth / 2; const inkWidth = Math.max(1, inkRight - inkLeft); const inkHeight = Math.max(1, inkBottom - inkTop); return { inkData, inkStrokeWidth, inkTop, inkLeft, inkWidth, inkHeight, inkScaleX: (this._props.PanelWidth() - inkStrokeWidth) / (inkWidth - inkStrokeWidth || 1) || 1, inkScaleY: (this._props.PanelHeight() - inkStrokeWidth) / (inkHeight - inkStrokeWidth || 1) || 1, }; }; // // this updates the highlight for the nearest point on the curve to the cursor. // if the user double clicks, this highlighted point will be added as a control point in the curve. // @action onPointerMove = (e: React.PointerEvent) => { const { inkData, inkScaleX, inkScaleY, inkStrokeWidth, inkTop, inkLeft } = this.inkScaledData(); const screenPts = inkData .map(point => this.ScreenToLocalBoxXf() .inverse() .transformPoint((point.X - inkLeft - inkStrokeWidth / 2) * inkScaleX + inkStrokeWidth / 2, (point.Y - inkTop - inkStrokeWidth / 2) * inkScaleY + inkStrokeWidth / 2) ) .map(p => ({ X: p[0], Y: p[1] })); const { distance, nearestT, nearestSeg, nearestPt } = InkStrokeProperties.nearestPtToStroke(screenPts, { X: e.clientX, Y: e.clientY }); if (distance < 40 && !e.buttons) { this._nearestT = nearestT; this._nearestSeg = nearestSeg; this._nearestScrPt = nearestPt; } else { this._nearestT = this._nearestSeg = this._nearestScrPt = undefined; } }; /** * @returns the nearest screen point to the cursor (to render a highlight for the point to be added) */ nearestScreenPt = () => this._nearestScrPt; @computed get screenCtrlPts() { const { inkData, inkScaleX, inkScaleY, inkStrokeWidth, inkTop, inkLeft } = this.inkScaledData(); return inkData .map(point => this.ScreenToLocalBoxXf() .inverse() .transformPoint((point.X - inkLeft - inkStrokeWidth / 2) * inkScaleX + inkStrokeWidth / 2, (point.Y - inkTop - inkStrokeWidth / 2) * inkScaleY + inkStrokeWidth / 2) ) .map(p => ({ X: p[0], Y: p[1] })); } startPt = () => this.screenCtrlPts[0]; endPt = () => this.screenCtrlPts.lastElement(); /** * @param boundsLeft the screen space left coordinate of the ink stroke * @param boundsTop the screen space top coordinate of the ink stroke * @returns the JSX controls for displaying an editing UI for the stroke (control point & tangent handles) */ componentUI = (boundsLeft: number, boundsTop: number): null | JSX.Element => { const inkDoc = this.Document; const { inkData, inkStrokeWidth } = this.inkScaledData(); const screenSpaceCenterlineStrokeWidth = 3; //Math.min(3, inkStrokeWidth * this.ScreenToLocalBoxXf().inverse().Scale); // the width of the blue line widget that shows the centerline of the ink stroke const screenInkWidth = this.ScreenToLocalBoxXf().inverse().transformDirection(inkStrokeWidth, inkStrokeWidth); const startMarker = StrCast(this.layoutDoc.stroke_startMarker); const endMarker = StrCast(this.layoutDoc.stroke_endMarker); const markerScale = NumCast(this.layoutDoc.stroke_markerScale); return SnappingManager.IsDragging ? null : !InkStrokeProperties.Instance._controlButton ? ( !this._props.isSelected() || InkingStroke.IsClosed(inkData) ? null : (
) ) : (
{InteractionUtils.CreatePolyline( this.screenCtrlPts, 0, 0, Colors.MEDIUM_BLUE, screenInkWidth[0], screenSpaceCenterlineStrokeWidth, StrCast(inkDoc.stroke_lineJoin) as Property.StrokeLinejoin, StrCast(this.layoutDoc.stroke_lineCap) as Property.StrokeLinecap, StrCast(inkDoc.stroke_bezier), 'none', startMarker, endMarker, markerScale * Math.min(screenSpaceCenterlineStrokeWidth, screenInkWidth[0] / screenSpaceCenterlineStrokeWidth), StrCast(inkDoc.stroke_dash), 1, 1, '' as Gestures, 'all', 1.0, false, this.onPointerDown )}
); }; _subContentView: ViewBoxInterface | undefined; setSubContentView = (box: ViewBoxInterface) => { this._subContentView = box; }; @computed get fillColor(): string { const isInkMask = BoolCast(this.layoutDoc.stroke_isInkMask); return isInkMask ? DashColor(StrCast(this.layoutDoc.fillColor, 'transparent')).blacken(0).rgb().toString() : ((this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FillColor) as 'string') ?? 'transparent'); } @computed get strokeColor() { const { inkData } = this.inkScaledData(); const { fillColor } = this; return !InkingStroke.IsClosed(inkData) && fillColor && fillColor !== 'transparent' ? fillColor : ((this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color) as 'string') ?? StrCast(this.layoutDoc.color)); } render() { TraceMobx(); const { inkData, inkStrokeWidth, inkLeft, inkTop, inkScaleX, inkScaleY } = this.inkScaledData(); const startMarker = StrCast(this.layoutDoc.stroke_startMarker); const endMarker = StrCast(this.layoutDoc.stroke_endMarker); const markerScale = NumCast(this.layoutDoc.stroke_markerScale, 1); const closed = InkingStroke.IsClosed(inkData); const isInkMask = BoolCast(this.layoutDoc.stroke_isInkMask); const { fillColor } = this; // bcz: Hack!! Not really sure why, but having fractional values for width/height of mask ink strokes causes the dragging clone (see DragManager) to be offset from where it should be. if (isInkMask && (this.layoutDoc._width !== Math.round(NumCast(this.layoutDoc._width)) || this.layoutDoc._height !== Math.round(NumCast(this.layoutDoc._height)))) { setTimeout(() => { this.layoutDoc._width = Math.round(NumCast(this.layoutDoc._width)); this.layoutDoc._height = Math.round(NumCast(this.layoutDoc._height)); }); } const highlight = !this.controlUndo && this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Highlighting); const { highlightIndex, highlightColor: hColor } = (highlight as { highlightIndex?: number; highlightColor?: string }) ?? { highlightIndex: undefined, highlightColor: undefined }; const highlightColor = !this._props.isSelected() && !isInkMask && highlightIndex ? hColor : undefined; const color = StrCast(this.layoutDoc.stroke_outlineColor, !closed && fillColor && fillColor !== 'transparent' ? StrCast(this.layoutDoc.color, 'transparent') : 'transparent'); // Visually renders the polygonal line made by the user. const inkLine = InteractionUtils.CreatePolyline( inkData, inkLeft, inkTop, this.strokeColor, inkStrokeWidth, inkStrokeWidth, StrCast(this.layoutDoc.stroke_lineJoin) as Property.StrokeLinejoin, StrCast(this.layoutDoc.stroke_lineCap) as Property.StrokeLinecap, StrCast(this.layoutDoc.stroke_bezier), !closed ? 'none' : fillColor === 'transparent' ? 'none' : fillColor, startMarker, endMarker, markerScale, StrCast(this.layoutDoc.stroke_dash), inkScaleX, inkScaleY, '' as Gestures, 'none', 1.0, false, undefined, undefined ); const higlightMargin = Math.min(12, Math.max(2, 0.3 * inkStrokeWidth)); // Invisible polygonal line that enables the ink to be selected by the user. const clickableLine = (downHdlr?: (e: React.PointerEvent) => void, mask: boolean = false) => InteractionUtils.CreatePolyline( inkData, inkLeft, inkTop, mask && color === 'transparent' ? this.strokeColor : (highlightColor ?? color), inkStrokeWidth, inkStrokeWidth + NumCast(this.layoutDoc.stroke_borderWidth) + (fillColor ? (closed ? higlightMargin : (highlightIndex ?? 0) + higlightMargin) : higlightMargin), StrCast(this.layoutDoc.stroke_lineJoin) as Property.StrokeLinejoin, StrCast(this.layoutDoc.stroke_lineCap) as Property.StrokeLinecap, StrCast(this.layoutDoc.stroke_bezier), closed && fillColor && DashColor(fillColor).alpha() ? fillColor : 'none', startMarker, endMarker, markerScale, StrCast(this.layoutDoc.stroke_dash), inkScaleX, inkScaleY, '' as Gestures, this._props.pointerEvents?.() ?? 'visiblePainted', 0.0, false, downHdlr, mask ); // bootsrap 3 style sheet sets line height to be 20px for default 14 point font size. // this attempts to figure out the lineHeight ratio by inquiring the body's lineHeight and dividing by the fontsize which should yield 1.428571429 // see: https://bibwild.wordpress.com/2019/06/10/bootstrap-3-to-4-changes-in-how-font-size-line-height-and-spacing-is-done-or-what-happened-to-line-height-computed/ // const lineHeightGuess = +getComputedStyle(document.body).lineHeight.replace('px', '') / +getComputedStyle(document.body).fontSize.replace('px', ''); const interactions = { onPointerLeave: action(() => { this._nearestScrPt = undefined; }), onPointerMove: this._props.isSelected() ? this.onPointerMove : undefined, onClick: (e: React.MouseEvent) => this._handledClick && e.stopPropagation(), onContextMenu: () => { const cm = ContextMenu.Instance; !Doc.noviceMode && cm?.addItem({ description: 'Recognize Writing', event: this.analyzeStrokes, icon: 'paint-brush' }); cm?.addItem({ description: 'Toggle Mask', event: () => InkingStroke.toggleMask(this.dataDoc), icon: 'paint-brush' }); cm?.addItem({ description: 'Edit Points', event: action(() => { InkStrokeProperties.Instance._controlButton = !InkStrokeProperties.Instance._controlButton; }), icon: 'paint-brush', }); }, }; return (
{clickableLine(this.onPointerDown, isInkMask)} {isInkMask ? null : inkLine} {!closed || this.dataDoc[this.fieldKey + '_showLabel'] === false || (!RTFCast(this.dataDoc.text)?.Text && !this.dataDoc[this.fieldKey + '_showLabel'] && (!this._props.isSelected() || Doc.UserDoc().activeHideTextLabels)) ? null : (
)}
); } } Docs.Prototypes.TemplateMap.set(DocumentType.INK, { // NOTE: this is unused!! ink fields are filled in directly within the InkDocument() method layout: { view: InkingStroke, dataField: 'stroke' }, options: { acl: '', systemIcon: 'BsFillPencilFill', // _layout_nativeDimEditable: true, _layout_reflowVertical: true, _layout_reflowHorizontal: true, layout_hideDecorationTitle: true, // don't show title when selected _layout_fitWidth: false, layout_isSvg: true, }, });