aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/InkingStroke.tsx
blob: 4b651af7d70edcb05bbbdced8a7844ac61ce06e8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
/*
    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<FieldViewProps>() {
    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 : (
                <div className="inkstroke-UI" style={{ clip: `rect(${boundsTop}px, 10000px, 10000px, ${boundsLeft}px)` }}>
                    <InkEndPtHandles inkView={this} inkDoc={inkDoc} startPt={this.startPt} endPt={this.endPt} screenSpaceLineWidth={screenSpaceCenterlineStrokeWidth} />
                </div>
            )
        ) : (
            <div className="inkstroke-UI" style={{ clip: `rect(${boundsTop}px, 10000px, 10000px, ${boundsLeft}px)` }}>
                {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
                )}
                <InkControlPtHandles inkView={this} inkDoc={inkDoc} inkCtrlPoints={inkData} screenCtrlPoints={this.screenCtrlPts} nearestScreenPt={this.nearestScreenPt} screenSpaceLineWidth={screenSpaceCenterlineStrokeWidth} />
                <InkTangentHandles inkView={this} inkDoc={inkDoc} screenCtrlPoints={this.screenCtrlPts} screenSpaceLineWidth={screenSpaceCenterlineStrokeWidth} />
            </div>
        );
    };

    _subContentView: ViewBoxInterface<FormattedTextBoxProps> | undefined;
    setSubContentView = (box: ViewBoxInterface<FormattedTextBoxProps>) => {
        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 (
            <div className="inkStroke-wrapper">
                <svg
                    className="inkStroke"
                    style={{
                        transform: isInkMask ? `rotate(-${NumCast(this._props.LocalRotation?.() ?? 0)}deg) translate(${InkingStroke.MaskDim / 2}px, ${InkingStroke.MaskDim / 2}px)` : undefined,
                        cursor: this._props.isSelected() ? 'default' : undefined,
                    }}
                    {...interactions}>
                    {clickableLine(this.onPointerDown, isInkMask)}
                    {isInkMask ? null : inkLine}
                </svg>
                {!closed || this.dataDoc[this.fieldKey + '_showLabel'] === false || (!RTFCast(this.dataDoc.text)?.Text && !this.dataDoc[this.fieldKey + '_showLabel'] && (!this._props.isSelected() || Doc.UserDoc().activeHideTextLabels)) ? null : (
                    <div
                        className="inkStroke-text"
                        style={{
                            color: StrCast(this.layoutDoc.textColor, 'black'),
                            pointerEvents: this._props.isDocumentActive?.() ? 'all' : undefined,
                            width: NumCast(this.layoutDoc._width),
                            transform: `scale(${this._props.NativeDimScaling?.() || 1})`,
                            transformOrigin: 'top left',
                            // top: (this._props.PanelHeight() - (lineHeightGuess * fsize + 20) * (this._props.NativeDimScaling?.() || 1)) / 2,
                        }}>
                        <FormattedTextBox
                            {...this._props}
                            setHeight={undefined}
                            setContentViewBox={this.setSubContentView} // this makes the inkingStroke the "dominant" component - ie, it will show the inking UI when selected (not text)
                            yMargin={10}
                            xMargin={10}
                            fieldKey="text"
                            // dontRegisterView={true}
                            noSidebar
                            dontScale
                            isContentActive={this._props.isContentActive}
                        />
                    </div>
                )}
            </div>
        );
    }
}
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,
    },
});