@inditextech/weave-sdk 5.7.0 → 5.8.0-SNAPSHOT.613.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/sdk.js CHANGED
@@ -9579,7 +9579,7 @@ var WeaveRegisterManager = class {
9579
9579
  };
9580
9580
  //#endregion
9581
9581
  //#region package.json
9582
- var version = "5.7.0";
9582
+ var version = "5.8.0-SNAPSHOT.613.1";
9583
9583
  //#endregion
9584
9584
  //#region src/managers/setup.ts
9585
9585
  var WeaveSetupManager = class {
@@ -12519,6 +12519,12 @@ function isSafeLink(value) {
12519
12519
  return false;
12520
12520
  }
12521
12521
  }
12522
+ /** Marks an empty block carries: normalized and never a link. */
12523
+ function emptyBlockMarks(m) {
12524
+ if (!m) return void 0;
12525
+ const { link: _link, ...rest } = m;
12526
+ return normalizeMarks(rest);
12527
+ }
12522
12528
  /** Block fields that describe structure (not content). */
12523
12529
  function blockFields(block, withStart) {
12524
12530
  const out = {};
@@ -12596,6 +12602,76 @@ function sliceRuns(runs, from, to) {
12596
12602
  }
12597
12603
  return out;
12598
12604
  }
12605
+ /** Blocks covering the global range [start, end), keeping structure and marks. */
12606
+ function sliceBlocks(blocks, start, end) {
12607
+ const out = [];
12608
+ let pos = 0;
12609
+ for (const block of blocks) {
12610
+ const len = blockText(block).length;
12611
+ const bStart = pos;
12612
+ const bEnd = pos + len;
12613
+ pos = bEnd + 1;
12614
+ if (bEnd < start || bStart > end) continue;
12615
+ const runs = normalizeRuns(sliceRuns(block.runs, Math.max(start, bStart) - bStart, end - bStart));
12616
+ out.push({
12617
+ ...blockFields(block, true),
12618
+ runs,
12619
+ ...runs.length === 0 && block.m ? { m: block.m } : {}
12620
+ });
12621
+ }
12622
+ return out;
12623
+ }
12624
+ /**
12625
+ * Replace the global range [start, end) with a fragment of blocks. A single
12626
+ * block fragment merges inline; otherwise first/last fragment blocks merge
12627
+ * with the text before/after the range.
12628
+ */
12629
+ function insertBlocks(blocks, start, end, fragment) {
12630
+ if (fragment.length === 0) return blocks;
12631
+ const total = blocksToText(blocks).length;
12632
+ start = Math.max(0, Math.min(start, total));
12633
+ end = Math.max(start, Math.min(end, total));
12634
+ const head = sliceBlocks(blocks, 0, start);
12635
+ const tail = sliceBlocks(blocks, end, total);
12636
+ const before = head.pop() ?? { runs: [] };
12637
+ const after = tail.shift() ?? { runs: [] };
12638
+ const first = fragment[0];
12639
+ const last = fragment[fragment.length - 1];
12640
+ const finish = (b) => {
12641
+ const runs = normalizeRuns(b.runs);
12642
+ const { m: _m, ...rest } = b;
12643
+ return {
12644
+ ...rest,
12645
+ runs,
12646
+ ...runs.length === 0 && b.m ? { m: b.m } : {}
12647
+ };
12648
+ };
12649
+ if (fragment.length === 1) return [
12650
+ ...head,
12651
+ finish({
12652
+ ...before,
12653
+ runs: [
12654
+ ...before.runs,
12655
+ ...first.runs,
12656
+ ...after.runs
12657
+ ]
12658
+ }),
12659
+ ...tail
12660
+ ];
12661
+ return [
12662
+ ...head,
12663
+ finish({
12664
+ ...before,
12665
+ runs: [...before.runs, ...first.runs]
12666
+ }),
12667
+ ...fragment.slice(1, -1).map(finish),
12668
+ finish({
12669
+ ...last,
12670
+ runs: [...last.runs, ...after.runs]
12671
+ }),
12672
+ ...tail
12673
+ ];
12674
+ }
12599
12675
  /** Marks of the run at (or immediately before) a local offset. */
12600
12676
  function marksAt(runs, offset) {
12601
12677
  let pos = 0;
@@ -12636,7 +12712,7 @@ function replaceRange(blocks, start, end, insert, insertMarks) {
12636
12712
  const local = start - bStart;
12637
12713
  before = sliceRuns(block.runs, 0, local);
12638
12714
  startBlock = block;
12639
- inheritedMarks = marksAt(block.runs, local);
12715
+ inheritedMarks = len === 0 ? block.m : marksAt(block.runs, local);
12640
12716
  }
12641
12717
  if (end >= bStart && end <= bEnd) after = sliceRuns(block.runs, end - bStart, len);
12642
12718
  }
@@ -12652,9 +12728,12 @@ function replaceRange(blocks, start, end, insert, insertMarks) {
12652
12728
  let runs = mk(line);
12653
12729
  if (idx === 0) runs = [...before, ...runs];
12654
12730
  if (idx === lines.length - 1) runs = [...runs, ...after];
12731
+ const normalized = normalizeRuns(runs);
12732
+ const carried = emptyBlockMarks(inheritedMarks);
12655
12733
  middle.push({
12656
12734
  ...blockFields(startBlock, idx === 0),
12657
- runs: normalizeRuns(runs)
12735
+ runs: normalized,
12736
+ ...normalized.length === 0 && carried ? { m: carried } : {}
12658
12737
  });
12659
12738
  });
12660
12739
  return [
@@ -12718,7 +12797,11 @@ function blockIndicesInRange(blocks, start, end) {
12718
12797
  }
12719
12798
  /** Marks that typing at a collapsed offset would inherit. */
12720
12799
  function marksAtOffset(blocks, offset) {
12721
- for (const s of blockSpans(blocks)) if (offset >= s.start && offset <= s.end) return cleanMarks(marksAt(blocks[s.index].runs, offset - s.start));
12800
+ for (const s of blockSpans(blocks)) if (offset >= s.start && offset <= s.end) {
12801
+ const block = blocks[s.index];
12802
+ if (block.runs.length === 0) return cleanMarks(block.m);
12803
+ return cleanMarks(marksAt(block.runs, offset - s.start));
12804
+ }
12722
12805
  }
12723
12806
  function mapRunsInRange(block, from, to, fn) {
12724
12807
  const runs = [];
@@ -12751,6 +12834,26 @@ function mapRunsInRange(block, from, to, fn) {
12751
12834
  runs: normalizeRuns(runs)
12752
12835
  };
12753
12836
  }
12837
+ function withEmptyMark(block, key, value) {
12838
+ const { m: _m, ...rest } = block;
12839
+ const m = normalizeMarks({
12840
+ ...block.m,
12841
+ [key]: value
12842
+ });
12843
+ return m ? {
12844
+ ...rest,
12845
+ m
12846
+ } : rest;
12847
+ }
12848
+ /**
12849
+ * Set (or remove with `undefined`) one mark on the empty block holding the
12850
+ * collapsed offset. Other blocks are returned untouched.
12851
+ */
12852
+ function applyMarkToEmptyBlock(blocks, offset, key, value) {
12853
+ const span = blockSpans(blocks).find((s) => offset >= s.start && offset <= s.end);
12854
+ if (!span || blocks[span.index].runs.length > 0) return blocks;
12855
+ return blocks.map((b, i) => i === span.index ? withEmptyMark(b, key, value) : b);
12856
+ }
12754
12857
  /** Set (or remove with `undefined`) one mark over the global range. */
12755
12858
  function applyMark(blocks, start, end, key, value) {
12756
12859
  if (start >= end) return blocks;
@@ -12763,6 +12866,7 @@ function applyMark(blocks, start, end, key, value) {
12763
12866
  return blocks.map((block, i) => {
12764
12867
  const s = spans[i];
12765
12868
  if (s.end <= start || s.start >= end) return block;
12869
+ if (block.runs.length === 0) return withEmptyMark(block, key, value);
12766
12870
  return mapRunsInRange(block, Math.max(start, s.start) - s.start, Math.min(end, s.end) - s.start, (m) => normalizeMarks({
12767
12871
  ...m,
12768
12872
  [key]: value
@@ -12988,6 +13092,44 @@ function alignQuery(blocks, start, end) {
12988
13092
  value: set[0]
12989
13093
  };
12990
13094
  }
13095
+ const WEAVE_TEXT_MIME = "application/x-weave-text+json";
13096
+ /** Defensive parse of a clipboard payload; undefined when invalid. */
13097
+ function parseWeaveClipboard(raw) {
13098
+ if (!raw) return void 0;
13099
+ try {
13100
+ const data = JSON.parse(raw);
13101
+ if (data?.v !== 1 || !Array.isArray(data.blocks) || !data.blocks.length) return;
13102
+ const out = [];
13103
+ for (const b of data.blocks.slice(0, 5e3)) {
13104
+ if (!b || !Array.isArray(b.runs)) return void 0;
13105
+ const runs = [];
13106
+ for (const r of b.runs) {
13107
+ if (typeof r?.t !== "string") return void 0;
13108
+ const m = cleanMarks(r.m && typeof r.m === "object" ? { ...r.m } : void 0);
13109
+ if (m && "link" in m && !isSafeLink(m.link)) delete m.link;
13110
+ runs.push({
13111
+ t: r.t,
13112
+ ...m && Object.keys(m).length ? { m } : {}
13113
+ });
13114
+ }
13115
+ const block = { runs: normalizeRuns(runs) };
13116
+ if (b.kind === "ul" || b.kind === "ol") block.kind = b.kind;
13117
+ if (Number.isInteger(b.depth) && b.depth > 0) block.depth = b.depth;
13118
+ if (Number.isInteger(b.start)) block.start = b.start;
13119
+ if ([
13120
+ "left",
13121
+ "center",
13122
+ "right"
13123
+ ].includes(b.align)) block.align = b.align;
13124
+ const em = cleanMarks(b.m && typeof b.m === "object" ? b.m : void 0);
13125
+ if (!block.runs.length && em) block.m = em;
13126
+ out.push(block);
13127
+ }
13128
+ return out;
13129
+ } catch {
13130
+ return;
13131
+ }
13132
+ }
12991
13133
  //#endregion
12992
13134
  //#region src/nodes/text-v2/layout/engine.ts
12993
13135
  const DEFAULT_INDENT_STEP = 1.5;
@@ -13136,7 +13278,7 @@ var TextLayoutEngine = class {
13136
13278
  return Math.min(wanted, limit);
13137
13279
  }
13138
13280
  sigFor(block, style, fixedWidth, maxWidth, indent) {
13139
- return `${fontString(style)}|${style.lineHeight}|${style.letterSpacing}|${style.wrap}|${fixedWidth ? maxWidth - indent : "auto"}|${JSON.stringify(block.runs)}`;
13281
+ return `${fontString(style)}|${style.lineHeight}|${style.letterSpacing}|${style.wrap}|${fixedWidth ? maxWidth - indent : "auto"}|${JSON.stringify(block.runs)}|${block.runs.length === 0 ? JSON.stringify(block.m ?? null) : ""}`;
13140
13282
  }
13141
13283
  /**
13142
13284
  * Pre-compute paragraph layouts for `blocks` within a time budget so a
@@ -13233,7 +13375,7 @@ var TextLayoutEngine = class {
13233
13375
  const startChar = entry.graphemes.slice(0, pl.startG).join("").length;
13234
13376
  let marker;
13235
13377
  if (first && opts.marker) {
13236
- const m0 = block.runs[0]?.m;
13378
+ const m0 = block.runs[0]?.m ?? block.m;
13237
13379
  const rs = runStyle(style, m0);
13238
13380
  const stepPx = Math.min(indent, this.stepPx(style));
13239
13381
  marker = {
@@ -13347,9 +13489,10 @@ var TextLayoutEngine = class {
13347
13489
  desc = Math.max(desc, m.descent);
13348
13490
  }
13349
13491
  if (seen.size === 0) {
13350
- size = style.fontSize;
13351
- height = style.lineHeight * size;
13352
- const m = this.metrics(baseFont, style.fontSize);
13492
+ const rs = runStyle(style, entry.emptyMarks);
13493
+ size = rs.fontSize;
13494
+ height = (runLineHeight(entry.emptyMarks) ?? style.lineHeight) * size;
13495
+ const m = this.metrics(entry.emptyMarks ? fontString(rs) : baseFont, size);
13353
13496
  asc = m.ascent;
13354
13497
  desc = m.descent;
13355
13498
  }
@@ -13369,6 +13512,7 @@ var TextLayoutEngine = class {
13369
13512
  fonts,
13370
13513
  lineHeights: block.runs.map((r) => runLineHeight(r.m) ?? style.lineHeight),
13371
13514
  sizes,
13515
+ ...block.runs.length === 0 && block.m ? { emptyMarks: block.m } : {},
13372
13516
  lines: []
13373
13517
  };
13374
13518
  const n = graphemes.length;
@@ -14424,6 +14568,10 @@ var TextEditModel = class {
14424
14568
  const { start, end } = this.getSelection();
14425
14569
  return this.getText().slice(start, end);
14426
14570
  }
14571
+ getSelectedBlocks() {
14572
+ const { start, end } = this.getSelection();
14573
+ return sliceBlocks(this.blocks, start, end);
14574
+ }
14427
14575
  canUndo() {
14428
14576
  return this.undoStack.length > 0;
14429
14577
  }
@@ -14498,6 +14646,19 @@ var TextEditModel = class {
14498
14646
  this.desiredX = null;
14499
14647
  return true;
14500
14648
  }
14649
+ /** Replace the selection with rich blocks; caret ends after them. */
14650
+ replaceSelectionWithBlocks(fragment, kind) {
14651
+ if (fragment.length === 0) return false;
14652
+ const { start, end } = this.getSelection();
14653
+ const insertedLen = blocksToText(fragment).length;
14654
+ this.record(kind, start);
14655
+ this.blocks = insertBlocks(this.blocks, start, end, fragment);
14656
+ this.storedMarks = null;
14657
+ this.anchor = this.focus = start + insertedLen;
14658
+ this.lastEditEnd = this.focus;
14659
+ this.desiredX = null;
14660
+ return true;
14661
+ }
14501
14662
  replaceSelection(text, kind) {
14502
14663
  const { start, end } = this.getSelection();
14503
14664
  return this.replaceRange(start, end, text, kind);
@@ -14553,6 +14714,12 @@ var TextEditModel = class {
14553
14714
  if (value === void 0 || value === false) delete base[key];
14554
14715
  else base[key] = value;
14555
14716
  this.storedMarks = cleanMarks(base) ?? {};
14717
+ const next = applyMarkToEmptyBlock(this.blocks, start, key, value);
14718
+ if (next !== this.blocks) {
14719
+ const stored = this.storedMarks;
14720
+ this.commitFormat(next);
14721
+ this.storedMarks = stored;
14722
+ }
14556
14723
  return true;
14557
14724
  }
14558
14725
  return this.commitFormat(applyMark(this.blocks, start, end, key, value));
@@ -14624,7 +14791,7 @@ var TextEditModel = class {
14624
14791
  function outdentToPlain(blocks, index) {
14625
14792
  return blocks.map((b, i) => {
14626
14793
  if (i !== index) return b;
14627
- const { kind: _k, depth: _d, start: _s, ...rest } = b;
14794
+ const { kind: _k, depth: _d, start: _s, m: _m, ...rest } = b;
14628
14795
  return rest;
14629
14796
  });
14630
14797
  }
@@ -14665,6 +14832,8 @@ var TextEditSession = class {
14665
14832
  ta.addEventListener("beforeinput", this.onBeforeInput, opts);
14666
14833
  ta.addEventListener("input", this.onInput, opts);
14667
14834
  ta.addEventListener("paste", this.onPaste, opts);
14835
+ ta.addEventListener("copy", this.onCopy, opts);
14836
+ ta.addEventListener("cut", this.onCut, opts);
14668
14837
  ta.addEventListener("compositionstart", this.onCompositionStart, opts);
14669
14838
  ta.addEventListener("compositionend", this.onCompositionEnd, opts);
14670
14839
  ta.addEventListener("select", this.syncFromTextarea, opts);
@@ -14911,9 +15080,43 @@ var TextEditSession = class {
14911
15080
  if (inputType === "insertFromPaste") return "paste";
14912
15081
  return "type";
14913
15082
  }
15083
+ writeRich(e) {
15084
+ const { start, end } = this.model.getSelection();
15085
+ if (start === end || !this.host.richText || !e.clipboardData) return false;
15086
+ e.clipboardData.setData("text/plain", this.model.getSelectedText());
15087
+ e.clipboardData.setData(WEAVE_TEXT_MIME, JSON.stringify({
15088
+ v: 1,
15089
+ blocks: this.model.getSelectedBlocks()
15090
+ }));
15091
+ e.preventDefault();
15092
+ return true;
15093
+ }
15094
+ onCopy = (e) => {
15095
+ e.stopPropagation();
15096
+ this.writeRich(e);
15097
+ };
15098
+ onCut = (e) => {
15099
+ e.stopPropagation();
15100
+ if (!this.writeRich(e)) return;
15101
+ if (this.model.replaceSelection("", "delete")) {
15102
+ this.syncTextarea();
15103
+ this.afterContentChange(0);
15104
+ }
15105
+ };
14914
15106
  onPaste = (e) => {
14915
15107
  e.stopPropagation();
14916
15108
  e.preventDefault();
15109
+ if (this.host.richText) {
15110
+ const blocks = parseWeaveClipboard(e.clipboardData?.getData(WEAVE_TEXT_MIME));
15111
+ if (blocks) {
15112
+ const len = blocksToText(blocks).length;
15113
+ if (this.model.replaceSelectionWithBlocks(blocks, "paste")) {
15114
+ this.syncTextarea();
15115
+ this.afterContentChange(len);
15116
+ }
15117
+ return;
15118
+ }
15119
+ }
14917
15120
  const raw = e.clipboardData?.getData("text/plain") ?? "";
14918
15121
  if (raw === "") return;
14919
15122
  this.insertText(raw.replace(/\r\n?/g, "\n"), "paste");
@@ -17558,7 +17761,7 @@ var WeaveTextV2Node = class extends WeaveNode {
17558
17761
  },
17559
17762
  signal: this.instance.getEventsController().signal,
17560
17763
  onChange: (blocks) => this.checkSize(shape, blocks),
17561
- onRequestExit: () => this.exitEditMode(shape)
17764
+ onRequestExit: (reason) => this.exitEditMode(shape, reason)
17562
17765
  }, shape.id());
17563
17766
  shape.setAttr("cancelEditMode", () => this.exitEditMode(shape));
17564
17767
  stage.mode(WEAVE_STAGE_TEXT_EDITION_MODE);
@@ -17581,7 +17784,7 @@ var WeaveTextV2Node = class extends WeaveNode {
17581
17784
  });
17582
17785
  } else if (size <= threshold) this.sizeWarned = false;
17583
17786
  }
17584
- exitEditMode(shape) {
17787
+ exitEditMode(shape, reason) {
17585
17788
  const session = this.session;
17586
17789
  if (!session || !this.editing) return;
17587
17790
  clearTimeout(this.selectionTimer);
@@ -17599,7 +17802,7 @@ var WeaveTextV2Node = class extends WeaveNode {
17599
17802
  const selectionPlugin = this.instance.getPlugin("nodesSelection");
17600
17803
  if (selectionPlugin) {
17601
17804
  this.instance.enablePlugin("nodesSelection");
17602
- selectionPlugin.setSelectedNodes([liveNode]);
17805
+ selectionPlugin.setSelectedNodes(reason === "outside" ? [] : [liveNode]);
17603
17806
  this.instance.triggerAction(SELECTION_TOOL_ACTION_NAME);
17604
17807
  }
17605
17808
  stage.container().tabIndex = 1;
@@ -17614,12 +17817,33 @@ var WeaveTextV2Node = class extends WeaveNode {
17614
17817
  const serialized = this.serialize(live);
17615
17818
  if (live.text() !== "") {
17616
17819
  const container = live.getParent();
17617
- live.destroy();
17820
+ this.swapOnRendered(live);
17618
17821
  this.instance.addNode(serialized, container?.getAttrs().id);
17619
17822
  } else live.destroy();
17620
17823
  } else if (changed) this.instance.updateNode(this.serialize(live));
17621
17824
  this.createNode = false;
17622
17825
  }
17826
+ /** Destroy `temp` once the real node with the same id is in the tree. */
17827
+ swapOnRendered(temp) {
17828
+ const id = temp.id();
17829
+ const timer = {};
17830
+ const finish = (real) => {
17831
+ clearTimeout(timer.id);
17832
+ this.instance.removeEventListener("onNodeRenderedAdded", onRendered);
17833
+ temp.destroy();
17834
+ if (!real) return;
17835
+ const selection = this.instance.getPlugin("nodesSelection");
17836
+ const selected = selection?.getSelectedNodes?.() ?? [];
17837
+ if (selection && selected.some((n) => n.id() === id)) selection.setSelectedNodes([real]);
17838
+ real.getLayer()?.batchDraw();
17839
+ };
17840
+ const onRendered = (node) => {
17841
+ if (node === temp || node.id() !== id || !node.getParent()) return;
17842
+ finish(node);
17843
+ };
17844
+ this.instance.addEventListener("onNodeRenderedAdded", onRendered);
17845
+ timer.id = setTimeout(() => finish(), 2e3);
17846
+ }
17623
17847
  setupTransform(text) {
17624
17848
  let actualAnchor = void 0;
17625
17849
  const isSide = () => SIDE_ANCHOR_SET.has(actualAnchor ?? "");
@@ -21864,6 +22088,27 @@ var WeaveFrameNode = class extends WeaveNode {
21864
22088
  //#endregion
21865
22089
  //#region src/nodes/stroke/constants.ts
21866
22090
  const WEAVE_STROKE_NODE_TYPE = "stroke";
22091
+ /**
22092
+ * The look equivalent to the rendering of strokes that have no look: the
22093
+ * default of every look setting.
22094
+ */
22095
+ const WEAVE_STROKE_LEGACY_LOOK = {
22096
+ version: 1,
22097
+ outline: "legacy",
22098
+ curve: "uniform",
22099
+ pressureInterp: "catmullRom",
22100
+ zoomAware: false,
22101
+ spacingPx: 2,
22102
+ edgePx: 1,
22103
+ caps: "separate",
22104
+ width: {
22105
+ min: 0,
22106
+ max: 1,
22107
+ gamma: 1,
22108
+ taperStart: 0,
22109
+ taperEnd: 0
22110
+ }
22111
+ };
21867
22112
  const WEAVE_STROKE_NODE_DEFAULT_CONFIG = {
21868
22113
  splineResolution: 8,
21869
22114
  splineTargetEdge: 1,
@@ -21872,9 +22117,380 @@ const WEAVE_STROKE_NODE_DEFAULT_CONFIG = {
21872
22117
  isEraser: false
21873
22118
  };
21874
22119
  //#endregion
22120
+ //#region src/nodes/stroke/render/curve.ts
22121
+ const lerp = (a, b, t) => a + (b - a) * t;
22122
+ /** Interval between two knots: the square root of the distance (alpha 0.5). */
22123
+ const knotStep = (a, b) => Math.max(Math.sqrt(Math.hypot(b.x - a.x, b.y - a.y)), 1e-6);
22124
+ /** Barry-Goldman evaluation of the centripetal Catmull-Rom curve at `u` in 0..1 of the middle span. */
22125
+ function evaluate(w, u) {
22126
+ const { p, t } = w;
22127
+ const at = lerp(t[1], t[2], u);
22128
+ const blend = (channel) => {
22129
+ const a1 = lerp(p[0][channel], p[1][channel], (at - t[0]) / (t[1] - t[0]));
22130
+ const a2 = lerp(p[1][channel], p[2][channel], (at - t[1]) / (t[2] - t[1]));
22131
+ const a3 = lerp(p[2][channel], p[3][channel], (at - t[2]) / (t[3] - t[2]));
22132
+ const b1 = lerp(a1, a2, (at - t[0]) / (t[2] - t[0]));
22133
+ const b2 = lerp(a2, a3, (at - t[1]) / (t[3] - t[1]));
22134
+ return lerp(b1, b2, (at - t[1]) / (t[2] - t[1]));
22135
+ };
22136
+ return {
22137
+ x: blend("x"),
22138
+ y: blend("y"),
22139
+ pressure: blend("pressure")
22140
+ };
22141
+ }
22142
+ function distanceToSegment(p, a, b) {
22143
+ const dx = b.x - a.x;
22144
+ const dy = b.y - a.y;
22145
+ const l = dx * dx + dy * dy;
22146
+ const t = l ? Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l)) : 0;
22147
+ return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t));
22148
+ }
22149
+ /**
22150
+ * Samples a centripetal Catmull-Rom curve through `pts`.
22151
+ *
22152
+ * The knot intervals follow the distance between points, so the curve does not
22153
+ * overshoot or form cusps when the points are unevenly spaced, which the
22154
+ * uniform parametrization does. Pressure is interpolated linearly between the
22155
+ * two points of a span, or smoothly but never beyond them, so the width of the
22156
+ * stroke cannot exceed the widest of its neighbouring widths.
22157
+ */
22158
+ function getCentripetalPoints(pts, opts) {
22159
+ if (pts.length < 2) return [...pts];
22160
+ const out = [];
22161
+ for (let i = -1; i < pts.length - 2; i++) {
22162
+ const p0 = pts[Math.max(i, 0)];
22163
+ const p1 = pts[i + 1];
22164
+ const p2 = pts[i + 2];
22165
+ const p3 = pts[Math.min(i + 3, pts.length - 1)];
22166
+ const t1 = knotStep(p0, p1);
22167
+ const t2 = t1 + knotStep(p1, p2);
22168
+ const t3 = t2 + knotStep(p2, p3);
22169
+ const w = {
22170
+ p: [
22171
+ p0,
22172
+ p1,
22173
+ p2,
22174
+ p3
22175
+ ],
22176
+ t: [
22177
+ 0,
22178
+ t1,
22179
+ t2,
22180
+ t3
22181
+ ]
22182
+ };
22183
+ let deviation = 0;
22184
+ for (const probe of [
22185
+ .25,
22186
+ .5,
22187
+ .75
22188
+ ]) deviation = Math.max(deviation, distanceToSegment(evaluate(w, probe), p1, p2));
22189
+ const tolerance = Math.max(opts.edge * .1, 1e-6);
22190
+ const steps = deviation < opts.flatness ? 1 : Math.min(Math.max(Math.ceil(Math.sqrt(deviation / tolerance)), opts.minSteps), opts.maxSteps);
22191
+ const lo = Math.min(p1.pressure, p2.pressure);
22192
+ const hi = Math.max(p1.pressure, p2.pressure);
22193
+ for (let s = 0; s < steps; s++) {
22194
+ const u = s / steps;
22195
+ const q = evaluate(w, u);
22196
+ q.pressure = opts.pressure === "linear" ? lerp(p1.pressure, p2.pressure, u) : Math.min(hi, Math.max(lo, q.pressure));
22197
+ out.push(q);
22198
+ }
22199
+ }
22200
+ out.push(pts[pts.length - 1]);
22201
+ return out;
22202
+ }
22203
+ //#endregion
22204
+ //#region src/nodes/stroke/render/look.ts
22205
+ const OUTLINES = [
22206
+ "legacy",
22207
+ "round",
22208
+ "freehand"
22209
+ ];
22210
+ const CURVES = [
22211
+ "uniform",
22212
+ "centripetal",
22213
+ "none"
22214
+ ];
22215
+ const PRESSURE_INTERPS = ["catmullRom", "linear"];
22216
+ const CAPS = ["separate", "merged"];
22217
+ const isObject$2 = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
22218
+ const pick = (v, allowed, fallback) => typeof v === "string" && allowed.includes(v) ? v : fallback;
22219
+ /** A finite number clamped to a range, or the fallback. */
22220
+ const num = (v, min, max, fallback) => typeof v === "number" && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : fallback;
22221
+ /**
22222
+ * Reads the look saved on a stroke. Returns `null` when the stroke has none or
22223
+ * its look is of a version this release does not understand, so the stroke is
22224
+ * drawn as in releases that did not know looks. Missing or invalid fields fall
22225
+ * back to the legacy values and out-of-range numbers are clamped, so a stored
22226
+ * look can never make the renderer misbehave.
22227
+ */
22228
+ function readStrokeLook(raw) {
22229
+ if (!isObject$2(raw) || raw["version"] !== WEAVE_STROKE_LEGACY_LOOK.version) return null;
22230
+ const base = WEAVE_STROKE_LEGACY_LOOK;
22231
+ const width = isObject$2(raw["width"]) ? raw["width"] : {};
22232
+ return {
22233
+ version: 1,
22234
+ outline: pick(raw["outline"], OUTLINES, base.outline),
22235
+ curve: pick(raw["curve"], CURVES, base.curve),
22236
+ pressureInterp: pick(raw["pressureInterp"], PRESSURE_INTERPS, base.pressureInterp),
22237
+ zoomAware: typeof raw["zoomAware"] === "boolean" ? raw["zoomAware"] : base.zoomAware,
22238
+ spacingPx: num(raw["spacingPx"], .5, 10, base.spacingPx),
22239
+ edgePx: num(raw["edgePx"], .25, 5, base.edgePx),
22240
+ caps: pick(raw["caps"], CAPS, base.caps),
22241
+ width: {
22242
+ min: num(width["min"], 0, 1, base.width.min),
22243
+ max: num(width["max"], 0, 1, base.width.max),
22244
+ gamma: num(width["gamma"], .2, 3, base.width.gamma),
22245
+ taperStart: num(width["taperStart"], 0, 200, base.width.taperStart),
22246
+ taperEnd: num(width["taperEnd"], 0, 200, base.width.taperEnd)
22247
+ }
22248
+ };
22249
+ }
22250
+ /** Quadratic ease-out: 0 at the tip, 1 once the taper distance is covered. */
22251
+ const easeOut = (t) => t * (2 - t);
22252
+ /** Lowest half-width drawn, in canvas units, so a stroke never vanishes. */
22253
+ const MIN_HALF_WIDTH = .5;
22254
+ /**
22255
+ * Half of the drawn width at a point, in canvas units.
22256
+ *
22257
+ * The width is the stroke width times `min + (max - min) * pressure^gamma`,
22258
+ * narrowed by the start and end tapers. With the default width rule this is
22259
+ * `strokeWidth * pressure / 2`, the legacy width. `distanceFromStart` and
22260
+ * `distanceToEnd` are along the stroke, in the unit of the tapers.
22261
+ */
22262
+ function halfWidthAt(look, strokeWidth, pressure, distanceFromStart, distanceToEnd) {
22263
+ const { min, max, gamma, taperStart, taperEnd } = look.width;
22264
+ const p = Math.min(1, Math.max(0, Number.isFinite(pressure) ? pressure : .5));
22265
+ let fraction = min + (max - min) * Math.pow(p, gamma);
22266
+ if (taperStart > 0 && distanceFromStart < taperStart) fraction *= easeOut(Math.max(0, distanceFromStart) / taperStart);
22267
+ if (taperEnd > 0 && distanceToEnd < taperEnd) fraction *= easeOut(Math.max(0, distanceToEnd) / taperEnd);
22268
+ return Math.max(strokeWidth * fraction / 2, MIN_HALF_WIDTH);
22269
+ }
22270
+ //#endregion
22271
+ //#region src/nodes/stroke/render/round-outline.ts
22272
+ /**
22273
+ * Drops points that the quads between their neighbours already cover. A point
22274
+ * is skipped only while every point skipped since the last kept one stays
22275
+ * within a hundredth of the radius of the chord that replaces them, in position
22276
+ * and in radius, so a vertex, a turn or a change of width is always kept. A gap
22277
+ * is also never wider than two radii. The first and last points are kept.
22278
+ */
22279
+ function thinRoundPoints(pts) {
22280
+ if (pts.length <= 2) return pts;
22281
+ const out = [pts[0]];
22282
+ let anchor = 0;
22283
+ for (let i = 2; i < pts.length; i++) {
22284
+ const a = pts[anchor];
22285
+ const b = pts[i];
22286
+ const dx = b.x - a.x;
22287
+ const dy = b.y - a.y;
22288
+ const chord = Math.hypot(dx, dy);
22289
+ let keepPrevious = chord > 2 * Math.min(a.r, b.r) || i - anchor > 24;
22290
+ for (let j = anchor + 1; j < i && !keepPrevious; j++) {
22291
+ const p = pts[j];
22292
+ const t = chord > 0 ? Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / (chord * chord))) : 0;
22293
+ const off = Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t));
22294
+ const tolerance = .01 * Math.min(a.r, b.r, p.r);
22295
+ if (off > tolerance || Math.abs(p.r - (a.r + (b.r - a.r) * t)) > tolerance) keepPrevious = true;
22296
+ }
22297
+ if (keepPrevious) {
22298
+ out.push(pts[i - 1]);
22299
+ anchor = i - 1;
22300
+ }
22301
+ }
22302
+ out.push(pts[pts.length - 1]);
22303
+ return out;
22304
+ }
22305
+ /** Largest gap, in screen pixels, a skipped disc may leave on the outer side of a bend. */
22306
+ const MAX_WEDGE_PX = .1;
22307
+ const angleDiff = (a, b) => {
22308
+ let d = Math.abs(a - b);
22309
+ if (d > Math.PI) d = 2 * Math.PI - d;
22310
+ return d;
22311
+ };
22312
+ /**
22313
+ * Which points need their disc. Between two neighbours the tangent quad covers
22314
+ * the stroke exactly; a disc only matters where the outline turns, to fill the
22315
+ * wedge on the outside of the bend. Where the turn is so slight that the
22316
+ * wedge would be thinner than a tenth of a screen pixel the disc is skipped,
22317
+ * which removes most arcs of a smooth stroke and makes it far cheaper to fill.
22318
+ */
22319
+ function pointsNeedingDisc(pts, scale) {
22320
+ const need = new Array(pts.length).fill(false);
22321
+ if (pts.length === 0) return need;
22322
+ need[0] = true;
22323
+ need[pts.length - 1] = true;
22324
+ const dir = [0];
22325
+ const lean = [0];
22326
+ const valid = [false];
22327
+ for (let i = 1; i < pts.length; i++) {
22328
+ const p = pts[i];
22329
+ const q = pts[i - 1];
22330
+ const dx = p.x - q.x;
22331
+ const dy = p.y - q.y;
22332
+ const l = Math.hypot(dx, dy);
22333
+ const ok = l > Math.abs(p.r - q.r) && l >= 1e-9;
22334
+ valid.push(ok);
22335
+ dir.push(Math.atan2(dy, dx));
22336
+ lean.push(ok ? Math.asin(Math.max(-1, Math.min(1, (q.r - p.r) / l))) : 0);
22337
+ if (!ok) {
22338
+ need[i] = true;
22339
+ need[i - 1] = true;
22340
+ }
22341
+ }
22342
+ for (let i = 1; i < pts.length - 1; i++) {
22343
+ if (need[i]) continue;
22344
+ if (!valid[i] || !valid[i + 1]) {
22345
+ need[i] = true;
22346
+ continue;
22347
+ }
22348
+ const turn = angleDiff(dir[i], dir[i + 1]) + Math.abs(lean[i + 1] - lean[i]);
22349
+ const radiusPx = Math.max(pts[i].r * scale, 1e-6);
22350
+ if (turn > Math.sqrt(8 * MAX_WEDGE_PX / radiusPx)) need[i] = true;
22351
+ }
22352
+ return need;
22353
+ }
22354
+ /**
22355
+ * Appends the outline of a variable-width stroke to the current path: a disc at
22356
+ * every point and, between neighbours, the quad bounded by their two external
22357
+ * tangents. Every sub-path has the same winding, so a single non-zero fill is
22358
+ * the exact union: turns, reversals and ends are round by construction.
22359
+ */
22360
+ function appendRoundPath(ctx, points, scale = 1) {
22361
+ const pts = thinRoundPoints(points);
22362
+ const need = pointsNeedingDisc(pts, scale);
22363
+ const polygon = (v) => {
22364
+ let area = 0;
22365
+ for (let k = 0; k < v.length; k++) {
22366
+ const [x1, y1] = v[k];
22367
+ const [x2, y2] = v[(k + 1) % v.length];
22368
+ area += x1 * y2 - x2 * y1;
22369
+ }
22370
+ const w = area < 0 ? [...v].reverse() : v;
22371
+ ctx.moveTo(w[0][0], w[0][1]);
22372
+ for (let k = 1; k < w.length; k++) ctx.lineTo(w[k][0], w[k][1]);
22373
+ ctx.closePath();
22374
+ };
22375
+ const tangents = (i) => {
22376
+ const p = pts[i];
22377
+ const q = pts[i - 1];
22378
+ const dx = p.x - q.x;
22379
+ const dy = p.y - q.y;
22380
+ const l = Math.hypot(dx, dy);
22381
+ if (l <= Math.abs(p.r - q.r) || l < 1e-9) return null;
22382
+ const ux = dx / l;
22383
+ const uy = dy / l;
22384
+ const k = (q.r - p.r) / l;
22385
+ const s = Math.sqrt(Math.max(0, 1 - k * k));
22386
+ return {
22387
+ a: [k * ux - s * uy, k * uy + s * ux],
22388
+ b: [k * ux + s * uy, k * uy - s * ux]
22389
+ };
22390
+ };
22391
+ let left = [];
22392
+ let right = [];
22393
+ let prev = null;
22394
+ const flush = () => {
22395
+ if (left.length >= 2) polygon([...left, ...right.reverse()]);
22396
+ left = [];
22397
+ right = [];
22398
+ prev = null;
22399
+ };
22400
+ for (let i = 1; i < pts.length; i++) {
22401
+ const p = pts[i];
22402
+ const q = pts[i - 1];
22403
+ const t = tangents(i);
22404
+ if (!t) {
22405
+ flush();
22406
+ continue;
22407
+ }
22408
+ if (!prev) {
22409
+ left = [[q.x + q.r * t.a[0], q.y + q.r * t.a[1]]];
22410
+ right = [[q.x + q.r * t.b[0], q.y + q.r * t.b[1]]];
22411
+ } else {
22412
+ const mitre = (u, v) => {
22413
+ const sx = u[0] + v[0];
22414
+ const sy = u[1] + v[1];
22415
+ const d = (sx * sx + sy * sy) / 2;
22416
+ return [sx / d * q.r, sy / d * q.r];
22417
+ };
22418
+ const ml = mitre(prev.a, t.a);
22419
+ const mr = mitre(prev.b, t.b);
22420
+ left.push([q.x + ml[0], q.y + ml[1]]);
22421
+ right.push([q.x + mr[0], q.y + mr[1]]);
22422
+ }
22423
+ prev = t;
22424
+ const last = i === pts.length - 1;
22425
+ const next = last ? null : tangents(i + 1);
22426
+ if (last || need[i] || !next) {
22427
+ left.push([p.x + p.r * t.a[0], p.y + p.r * t.a[1]]);
22428
+ right.push([p.x + p.r * t.b[0], p.y + p.r * t.b[1]]);
22429
+ flush();
22430
+ }
22431
+ }
22432
+ flush();
22433
+ for (let i = 0; i < pts.length; i++) {
22434
+ if (!need[i]) continue;
22435
+ const p = pts[i];
22436
+ ctx.moveTo(p.x + p.r, p.y);
22437
+ ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2, false);
22438
+ }
22439
+ }
22440
+ /** Splits a centerline into the runs the dash pattern draws. */
22441
+ function splitRoundRuns(pts, dash) {
22442
+ if (!dash.length || dash.every((d) => !(d > 0))) return [pts];
22443
+ const runs = [];
22444
+ let index = 0;
22445
+ let on = true;
22446
+ let remaining = dash[0];
22447
+ let current = on ? [pts[0]] : [];
22448
+ for (let i = 1; i < pts.length; i++) {
22449
+ let a = pts[i - 1];
22450
+ const b = pts[i];
22451
+ let segment = Math.hypot(b.x - a.x, b.y - a.y);
22452
+ while (segment > remaining) {
22453
+ const t = remaining / segment;
22454
+ const cut = {
22455
+ x: a.x + (b.x - a.x) * t,
22456
+ y: a.y + (b.y - a.y) * t,
22457
+ r: a.r + (b.r - a.r) * t
22458
+ };
22459
+ if (on) {
22460
+ current.push(cut);
22461
+ runs.push(current);
22462
+ current = [];
22463
+ } else current = [cut];
22464
+ on = !on;
22465
+ index = (index + 1) % dash.length;
22466
+ segment -= remaining;
22467
+ remaining = dash[index];
22468
+ a = cut;
22469
+ if (!(remaining > 0)) remaining = Number.EPSILON;
22470
+ }
22471
+ remaining -= segment;
22472
+ if (on) current.push(b);
22473
+ }
22474
+ if (on && current.length > 0) runs.push(current);
22475
+ return runs.filter((r) => r.length > 0);
22476
+ }
22477
+ /**
22478
+ * Fills a round outline, once per dash run, so overlaps inside a run never
22479
+ * blend twice and a translucent colour stays uniform.
22480
+ */
22481
+ function drawRoundOutline(ctx, points, color, dash = [], scale = 1) {
22482
+ for (const run of splitRoundRuns(points, dash)) {
22483
+ ctx.beginPath();
22484
+ ctx.fillStyle = color;
22485
+ appendRoundPath(ctx, run, scale);
22486
+ ctx.fill();
22487
+ }
22488
+ }
22489
+ //#endregion
21875
22490
  //#region src/nodes/stroke/stroke.ts
21876
22491
  var WeaveStrokeNode = class WeaveStrokeNode extends WeaveNode {
21877
22492
  config;
22493
+ previewLook = null;
21878
22494
  nodeType = WEAVE_STROKE_NODE_TYPE;
21879
22495
  initialize = void 0;
21880
22496
  /**
@@ -21947,10 +22563,11 @@ var WeaveStrokeNode = class WeaveStrokeNode extends WeaveNode {
21947
22563
  * emitted as a single edge - subdividing a straight line adds vertices
21948
22564
  * without changing a pixel.
21949
22565
  */
21950
- getSplinePoints(pts, resolution = this.config.splineResolution) {
22566
+ getSplinePoints(pts, resolution = this.config.splineResolution, opts) {
21951
22567
  if (pts.length < 2) return [...pts];
21952
- const targetEdge = this.config.splineTargetEdge;
21953
- const maxSteps = this.config.splineMaxSteps;
22568
+ const targetEdge = opts?.targetEdge ?? this.config.splineTargetEdge;
22569
+ const maxSteps = opts?.maxSteps ?? this.config.splineMaxSteps;
22570
+ const flatness = opts?.flatness ?? WeaveStrokeNode.SPLINE_FLATNESS_EPSILON;
21954
22571
  const result = [];
21955
22572
  for (let i = -1; i < pts.length - 2; i++) {
21956
22573
  const p0 = pts[Math.max(i, 0)];
@@ -21967,8 +22584,12 @@ var WeaveStrokeNode = class WeaveStrokeNode extends WeaveNode {
21967
22584
  const at = this.catmullRomAt(p0, p1, p2, p3, probe);
21968
22585
  deviation = Math.max(deviation, this.distanceToSegment(at, p1, p2));
21969
22586
  }
21970
- const steps = deviation < WeaveStrokeNode.SPLINE_FLATNESS_EPSILON ? 1 : Math.min(Math.max(Math.ceil(chord / targetEdge), resolution), maxSteps);
21971
- for (let s = 0; s < steps; s++) result.push(this.catmullRomAt(p0, p1, p2, p3, s / steps));
22587
+ const steps = deviation < flatness ? 1 : Math.min(Math.max(Math.ceil(chord / targetEdge), resolution), maxSteps);
22588
+ for (let s = 0; s < steps; s++) {
22589
+ const q = this.catmullRomAt(p0, p1, p2, p3, s / steps);
22590
+ if (opts?.pressure === "linear") q.pressure = p1.pressure + (p2.pressure - p1.pressure) * (s / steps);
22591
+ result.push(q);
22592
+ }
21972
22593
  }
21973
22594
  result.push(pts[pts.length - 1]);
21974
22595
  return result;
@@ -21995,7 +22616,7 @@ var WeaveStrokeNode = class WeaveStrokeNode extends WeaveNode {
21995
22616
  * result by well under a pixel (95th-percentile outline edge 1.72px vs
21996
22617
  * 1.76px; byte-identical on long, gently-curved strokes).
21997
22618
  */
21998
- drawDashPolygon(ctx, leftSide, rightSide, color) {
22619
+ drawDashPolygon(ctx, leftSide, rightSide, color, mergeCaps = false) {
21999
22620
  const capStartL = leftSide[0];
22000
22621
  const capStartR = rightSide[0];
22001
22622
  const capEndL = leftSide.at(-1);
@@ -22007,6 +22628,24 @@ var WeaveStrokeNode = class WeaveStrokeNode extends WeaveNode {
22007
22628
  for (const p of leftSide) ctx.lineTo(p.x, p.y);
22008
22629
  for (const p of outlineRight) ctx.lineTo(p.x, p.y);
22009
22630
  ctx.closePath();
22631
+ if (mergeCaps) {
22632
+ const outline = [...leftSide, ...outlineRight];
22633
+ let area = 0;
22634
+ for (let i = 0; i < outline.length; i++) {
22635
+ const a = outline[i];
22636
+ const b = outline[(i + 1) % outline.length];
22637
+ area += a.x * b.y - b.x * a.y;
22638
+ }
22639
+ for (const [a, b] of [[capStartL, capStartR], [capEndL, capEndR]]) {
22640
+ const cx = (a.x + b.x) / 2;
22641
+ const cy = (a.y + b.y) / 2;
22642
+ const r = Math.hypot(a.x - b.x, a.y - b.y) / 2;
22643
+ ctx.moveTo(cx + r, cy);
22644
+ ctx.arc(cx, cy, r, 0, Math.PI * 2, area < 0);
22645
+ }
22646
+ ctx.fill();
22647
+ return;
22648
+ }
22010
22649
  ctx.fill();
22011
22650
  this.drawRoundCap(ctx, capStartL, capStartR, color);
22012
22651
  this.drawRoundCap(ctx, capEndL, capEndR, color);
@@ -22024,6 +22663,13 @@ var WeaveStrokeNode = class WeaveStrokeNode extends WeaveNode {
22024
22663
  }
22025
22664
  const filtered = this.resamplePoints(pts, this.config.resamplingSpacing);
22026
22665
  const centerline = this.getSplinePoints(filtered, this.config.splineResolution);
22666
+ this.drawCenterlineRibbon(ctx, centerline, (_i, p) => Math.max(baseW * p.pressure / 2, .5), color, dash, false);
22667
+ }
22668
+ /**
22669
+ * Draws the offset-outline ribbon along an already prepared centerline.
22670
+ * `halfWidth` gives the half-width at each centerline point.
22671
+ */
22672
+ drawCenterlineRibbon(ctx, centerline, halfWidth, color, dash, mergeCaps) {
22027
22673
  let dashIndex = 0;
22028
22674
  let dashOn = true;
22029
22675
  let dashRemaining = dash.length ? dash[0] : Infinity;
@@ -22037,7 +22683,7 @@ var WeaveStrokeNode = class WeaveStrokeNode extends WeaveNode {
22037
22683
  leftSide.push(pendingEndLeft);
22038
22684
  rightSide.push(pendingEndRight);
22039
22685
  }
22040
- this.drawDashPolygon(ctx, leftSide, rightSide, color);
22686
+ this.drawDashPolygon(ctx, leftSide, rightSide, color, mergeCaps);
22041
22687
  };
22042
22688
  for (let i = 0; i < centerline.length - 1; i++) {
22043
22689
  const p0 = centerline[i];
@@ -22047,8 +22693,8 @@ var WeaveStrokeNode = class WeaveStrokeNode extends WeaveNode {
22047
22693
  const segLen = Math.hypot(dx, dy) || 1;
22048
22694
  const nx = -dy / segLen;
22049
22695
  const ny = dx / segLen;
22050
- const w0 = Math.max(baseW * p0.pressure / 2, .5);
22051
- const w1 = Math.max(baseW * p1.pressure / 2, .5);
22696
+ const w0 = halfWidth(i, p0);
22697
+ const w1 = halfWidth(i + 1, p1);
22052
22698
  let traveled = 0;
22053
22699
  while (traveled < segLen) {
22054
22700
  const step = Math.min(dashRemaining, segLen - traveled);
@@ -22098,12 +22744,107 @@ var WeaveStrokeNode = class WeaveStrokeNode extends WeaveNode {
22098
22744
  }
22099
22745
  flushRun();
22100
22746
  }
22747
+ /**
22748
+ * The look a stroke is drawn with: the viewer-side preview if one is on,
22749
+ * otherwise the look saved with the stroke. `null` draws the legacy ribbon.
22750
+ */
22751
+ resolveLook(shape) {
22752
+ return this.previewLook ?? readStrokeLook(shape.getAttrs().look);
22753
+ }
22754
+ /**
22755
+ * Draws every stroke of this screen with `look`, without changing any saved
22756
+ * stroke and without other users seeing it. `null` turns the preview off.
22757
+ */
22758
+ setLookPreview(look) {
22759
+ this.previewLook = look === null ? null : readStrokeLook(look);
22760
+ this.redrawStrokes();
22761
+ }
22762
+ getLookPreview() {
22763
+ return this.previewLook;
22764
+ }
22765
+ redrawStrokes() {
22766
+ const stage = this.instance?.getStage?.();
22767
+ if (!stage || typeof stage.find !== "function") return;
22768
+ const layers = /* @__PURE__ */ new Set();
22769
+ for (const node of stage.find(".node")) if (node.getAttrs().nodeType === "stroke") layers.add(node.getLayer());
22770
+ for (const layer of layers) layer?.batchDraw();
22771
+ }
22772
+ /** Screen pixels per canvas unit at a shape. */
22773
+ screenScale(shape) {
22774
+ const scale = shape.getAbsoluteScale?.().x;
22775
+ return typeof scale === "number" && Number.isFinite(scale) && scale > 0 ? scale : 1;
22776
+ }
22777
+ /**
22778
+ * The centerline of a stroke with a look: the points resampled and joined by
22779
+ * the look's curve. Spacing and detail are in screen pixels when the look
22780
+ * follows the zoom, in canvas units otherwise; `scale` is screen pixels per
22781
+ * canvas unit.
22782
+ */
22783
+ buildLookCenterline(look, scale, pts) {
22784
+ const toCanvas = (px) => look.zoomAware ? px / scale : px;
22785
+ const edge = toCanvas(look.edgePx);
22786
+ const flatness = toCanvas(WeaveStrokeNode.SPLINE_FLATNESS_EPSILON);
22787
+ const maxSteps = look.zoomAware ? Math.max(this.config.splineMaxSteps, 256) : this.config.splineMaxSteps;
22788
+ const filtered = this.resamplePoints(pts, toCanvas(look.spacingPx));
22789
+ if (look.curve === "centripetal") return getCentripetalPoints(filtered, {
22790
+ edge,
22791
+ minSteps: 2,
22792
+ maxSteps,
22793
+ flatness,
22794
+ pressure: look.pressureInterp
22795
+ });
22796
+ if (look.curve === "none") return filtered;
22797
+ return this.getSplinePoints(filtered, this.config.splineResolution, {
22798
+ targetEdge: edge,
22799
+ maxSteps,
22800
+ flatness,
22801
+ pressure: look.pressureInterp
22802
+ });
22803
+ }
22804
+ /**
22805
+ * Draws a stroke that has a look: the centerline is built with the look's
22806
+ * curve, spacing and detail, widths follow its width rule, and the outline is
22807
+ * the legacy ribbon or the round one.
22808
+ */
22809
+ drawRibbonWithLook(ctx, shape, look, pts, baseW, color, dash) {
22810
+ if (!pts) return;
22811
+ if (pts.length < 2) {
22812
+ const pt = pts[0];
22813
+ const r = halfWidthAt(look, baseW, pt.pressure, Infinity, Infinity);
22814
+ ctx.beginPath();
22815
+ ctx.fillStyle = color;
22816
+ ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2);
22817
+ ctx.fill();
22818
+ return;
22819
+ }
22820
+ const scale = this.screenScale(shape);
22821
+ const centerline = this.buildLookCenterline(look, scale, pts);
22822
+ const unit = look.zoomAware ? scale : 1;
22823
+ const along = [0];
22824
+ for (let i = 1; i < centerline.length; i++) along.push(along[i - 1] + Math.hypot(centerline[i].x - centerline[i - 1].x, centerline[i].y - centerline[i - 1].y) * unit);
22825
+ const total = along[along.length - 1];
22826
+ const halfWidth = (i, p) => halfWidthAt(look, baseW, p.pressure, along[i], total - along[i]);
22827
+ if (look.outline === "legacy") {
22828
+ this.drawCenterlineRibbon(ctx, centerline, halfWidth, color, dash, look.caps === "merged");
22829
+ return;
22830
+ }
22831
+ drawRoundOutline(ctx, centerline.map((p, i) => ({
22832
+ x: p.x,
22833
+ y: p.y,
22834
+ r: halfWidth(i, p)
22835
+ })), color, dash, scale);
22836
+ }
22101
22837
  drawShape(ctx, shape) {
22102
22838
  const strokeElements = shape.getAttrs().strokeElements;
22103
22839
  if (strokeElements?.length === 0) return;
22104
22840
  const color = shape.getAttrs().stroke ?? "black";
22105
22841
  const strokeWidth = shape.getAttrs().strokeWidth ?? 1;
22106
22842
  const dash = shape.getAttrs().dash ?? [];
22843
+ const look = this.resolveLook(shape);
22844
+ if (look) {
22845
+ this.drawRibbonWithLook(ctx, shape, look, strokeElements, strokeWidth, color, dash);
22846
+ return;
22847
+ }
22107
22848
  this.drawRibbonWithDash(ctx, strokeElements, strokeWidth, color, dash);
22108
22849
  }
22109
22850
  onRender(props) {
@@ -28157,6 +28898,8 @@ var WeaveLineToolAction = class extends WeaveAction {
28157
28898
  //#endregion
28158
28899
  //#region src/actions/brush-tool/constants.ts
28159
28900
  const BRUSH_TOOL_ACTION_NAME = "brushTool";
28901
+ /** Name under which the lab plugin registers itself. */
28902
+ const BRUSH_LAB_PLUGIN_NAME = "brushLab";
28160
28903
  const BRUSH_TOOL_STATE = {
28161
28904
  ["INACTIVE"]: "inactive",
28162
28905
  ["IDLE"]: "idle",
@@ -28174,135 +28917,2284 @@ const PREVIEW_ID_SUFFIX = "--preview";
28174
28917
  * Bounds the handover so a preview can never be orphaned on screen.
28175
28918
  */
28176
28919
  const PREVIEW_HANDOVER_MAX_FRAMES = 30;
28920
+ const BRUSH_INPUT_PROFILE_KEYS = [
28921
+ "mouse",
28922
+ "touch",
28923
+ "pen.applePencil",
28924
+ "pen.desktop"
28925
+ ];
28926
+ /**
28927
+ * The legacy input profile: reproduces, point for point, what the brush tool
28928
+ * recorded before profiles existed. Every profile starts from it.
28929
+ */
28930
+ const BRUSH_LEGACY_INPUT_PROFILE = {
28931
+ capture: {
28932
+ source: "legacy",
28933
+ clock: "now",
28934
+ minMovePx: 0,
28935
+ predicted: "tail",
28936
+ event: "pointermove"
28937
+ },
28938
+ pressure: {
28939
+ zero: "legacy",
28940
+ filter: "legacy",
28941
+ timeConstantMs: 40,
28942
+ oneEuro: {
28943
+ minCutoff: 1,
28944
+ beta: .01
28945
+ },
28946
+ floor: .15,
28947
+ start: "backfill",
28948
+ startSamples: 3,
28949
+ end: "backfill"
28950
+ },
28951
+ stabilizer: {
28952
+ type: "none",
28953
+ minCutoff: 3,
28954
+ beta: .01,
28955
+ radiusPx: 4,
28956
+ amount: .3,
28957
+ zoomAware: false,
28958
+ catchUpOnEnd: true
28959
+ },
28960
+ finalize: {
28961
+ mode: "dp",
28962
+ toleranceUnit: "canvas",
28963
+ pressureWeight: 20,
28964
+ resampleSpacingPx: 2
28965
+ },
28966
+ touchGuard: {
28967
+ waitMs: 0,
28968
+ movePx: 0,
28969
+ cancelOnSecondTouch: false,
28970
+ ignoreAfterPen: false
28971
+ }
28972
+ };
28177
28973
  const BRUSH_TOOL_DEFAULT_CONFIG = {
28974
+ input: {
28975
+ forceProfile: null,
28976
+ profiles: {
28977
+ mouse: BRUSH_LEGACY_INPUT_PROFILE,
28978
+ touch: BRUSH_LEGACY_INPUT_PROFILE,
28979
+ "pen.applePencil": BRUSH_LEGACY_INPUT_PROFILE,
28980
+ "pen.desktop": BRUSH_LEGACY_INPUT_PROFILE
28981
+ }
28982
+ },
28178
28983
  interpolationSteps: 10,
28179
28984
  simplifyTolerance: .5
28180
28985
  };
28181
28986
  //#endregion
28182
- //#region src/actions/brush-tool/brush-tool.ts
28183
- var WeaveBrushToolAction = class extends WeaveAction {
28184
- config;
28185
- initialized = false;
28186
- state;
28187
- clickPoint;
28188
- strokeId;
28189
- isSpacePressed = false;
28190
- isEraser;
28191
- container;
28192
- measureContainer;
28193
- cancelAction;
28194
- prevTouchAction;
28195
- penActive = false;
28196
- lastSmoothedPressure = .5;
28197
- hasPressureSample = false;
28198
- lastPointerPos = null;
28199
- lastPointerTime = 0;
28200
- predictedCount = 0;
28201
- onPropsChange = void 0;
28202
- onInit = void 0;
28203
- constructor(params) {
28204
- super();
28205
- this.config = mergeExceptArrays(BRUSH_TOOL_DEFAULT_CONFIG, params?.config ?? {});
28206
- this.initialize();
28987
+ //#region src/actions/brush-tool/stages/stabilizer.ts
28988
+ /** Time assumed between two samples whose times do not advance, in ms. */
28989
+ const NOMINAL_SAMPLE_INTERVAL_MS$1 = 5;
28990
+ const seconds = (now, last) => (now - last > 0 ? now - last : NOMINAL_SAMPLE_INTERVAL_MS$1) / 1e3;
28991
+ /**
28992
+ * Scale between canvas units and the units distances are configured in: screen
28993
+ * pixels when the stabilizer is zoom-aware, canvas units otherwise.
28994
+ */
28995
+ const unitScale = (cfg, ctx) => cfg.zoomAware && ctx.zoom > 0 && Number.isFinite(ctx.zoom) ? ctx.zoom : 1;
28996
+ var NoStabilizer = class {
28997
+ reset() {}
28998
+ seed() {}
28999
+ apply(point) {
29000
+ return point;
28207
29001
  }
28208
- initialize() {
28209
- this.initialized = false;
28210
- this.state = BRUSH_TOOL_STATE.INACTIVE;
28211
- this.strokeId = null;
28212
- this.clickPoint = null;
28213
- this.container = void 0;
28214
- this.isEraser = false;
28215
- this.measureContainer = void 0;
28216
- this.props = this.initProps();
28217
- this.isSpacePressed = false;
29002
+ };
29003
+ /** One Euro low-pass filter on one axis. */
29004
+ var OneEuroAxis = class {
29005
+ last = 0;
29006
+ derivative = 0;
29007
+ lastTime = 0;
29008
+ seeded = false;
29009
+ reset() {
29010
+ this.seeded = false;
29011
+ this.derivative = 0;
29012
+ }
29013
+ seed(value, time) {
29014
+ this.last = value;
29015
+ this.lastTime = time;
29016
+ this.derivative = 0;
29017
+ this.seeded = true;
29018
+ }
29019
+ filter(value, time, minCutoff, beta) {
29020
+ if (!this.seeded) {
29021
+ this.seed(value, time);
29022
+ return value;
29023
+ }
29024
+ const dt = seconds(time, this.lastTime);
29025
+ const factor = (cutoff) => {
29026
+ const r = 2 * Math.PI * cutoff * dt;
29027
+ return r / (r + 1);
29028
+ };
29029
+ const a = factor(1);
29030
+ this.derivative = a * ((value - this.last) / dt) + (1 - a) * this.derivative;
29031
+ const alpha = factor(minCutoff + beta * Math.abs(this.derivative));
29032
+ const filtered = alpha * value + (1 - alpha) * this.last;
29033
+ this.last = Number.isFinite(filtered) ? filtered : value;
29034
+ this.lastTime = time;
29035
+ return this.last;
28218
29036
  }
28219
- getName() {
28220
- return BRUSH_TOOL_ACTION_NAME;
29037
+ };
29038
+ /**
29039
+ * One Euro filter (Casiez, Roussel, Vogel 2012) on the pen position: slow
29040
+ * movement is smoothed, fast movement is followed closely.
29041
+ */
29042
+ var OneEuroStabilizer = class {
29043
+ cfg;
29044
+ x = new OneEuroAxis();
29045
+ y = new OneEuroAxis();
29046
+ constructor(cfg) {
29047
+ this.cfg = cfg;
28221
29048
  }
28222
- initProps() {
28223
- return {
28224
- stroke: "#000000ff",
28225
- strokeWidth: 1,
28226
- opacity: 1
28227
- };
29049
+ reset() {
29050
+ this.x.reset();
29051
+ this.y.reset();
28228
29052
  }
28229
- /**
28230
- * Velocity-adaptive exponential smoothing of a single raw pressure sample.
28231
- *
28232
- * This is the one place pressure smoothing happens. Every sample that
28233
- * becomes a stroke point must go through it - including the
28234
- * high-frequency coalesced samples a stylus reports (see
28235
- * `handlePointerMove`), which previously bypassed it entirely and so
28236
- * landed in the stroke with their raw value, no pressure floor, and
28237
- * without advancing the smoothing state for later samples.
28238
- *
28239
- * Timing intentionally uses `performance.now()` (not the event's own
28240
- * `timeStamp`), matching the original behaviour: the two are not
28241
- * guaranteed to share a time origin, and mixing them corrupts the
28242
- * velocity term that drives `alpha`.
28243
- *
28244
- * The first sample of a stroke SEEDS the filter with its own value
28245
- * rather than being blended toward the neutral 0.5 baseline. Blending
28246
- * from a constant gives every stroke a warm-up transient: the filter
28247
- * starts at 0.5 and converges toward the pressure actually being
28248
- * applied, so a stroke drawn lighter than 0.5 renders visibly fatter at
28249
- * its start (and heavier than 0.5, thinner) until the filter catches
28250
- * up. Seeding removes that transient entirely - smoothing then only
28251
- * ever acts between real samples, which is its actual purpose.
28252
- */
28253
- smoothPressureSample(raw, clientX, clientY) {
28254
- const now = performance.now();
28255
- let velocity = 0;
28256
- const hasPosition = Number.isFinite(clientX) && Number.isFinite(clientY);
28257
- if (hasPosition && this.lastPointerPos && now - this.lastPointerTime > 0) {
28258
- const dx = clientX - this.lastPointerPos.x;
28259
- const dy = clientY - this.lastPointerPos.y;
28260
- velocity = Math.hypot(dx, dy) / (now - this.lastPointerTime) * 1e3;
28261
- }
28262
- if (hasPosition) this.lastPointerPos = {
28263
- x: clientX,
28264
- y: clientY
29053
+ seed(point, ctx) {
29054
+ const s = unitScale(this.cfg, ctx);
29055
+ this.x.seed(point.x * s, ctx.now);
29056
+ this.y.seed(point.y * s, ctx.now);
29057
+ }
29058
+ apply(point, ctx) {
29059
+ const s = unitScale(this.cfg, ctx);
29060
+ return {
29061
+ x: this.x.filter(point.x * s, ctx.now, this.cfg.minCutoff, this.cfg.beta) / s,
29062
+ y: this.y.filter(point.y * s, ctx.now, this.cfg.minCutoff, this.cfg.beta) / s
28265
29063
  };
28266
- this.lastPointerTime = now;
28267
- const safeRaw = Number.isFinite(raw) ? raw : .5;
28268
- if (!this.hasPressureSample) {
28269
- this.hasPressureSample = true;
28270
- this.lastSmoothedPressure = safeRaw;
28271
- return Math.max(this.lastSmoothedPressure, .15);
28272
- }
28273
- const alpha = Math.min(Math.max(velocity / 1500, .15), .6);
28274
- const smoothed = alpha * safeRaw + (1 - alpha) * this.lastSmoothedPressure;
28275
- this.lastSmoothedPressure = Number.isFinite(smoothed) ? smoothed : safeRaw;
28276
- return Math.max(this.lastSmoothedPressure, .15);
28277
29064
  }
28278
- /**
29065
+ };
29066
+ /**
29067
+ * Pulled string: the recorded point is held back on a leash of fixed length
29068
+ * and only moves when the pen pulls it taut, so movement inside the leash
29069
+ * leaves no mark and tight curves are rounded.
29070
+ */
29071
+ var PulledStringStabilizer = class {
29072
+ cfg;
29073
+ tip = null;
29074
+ constructor(cfg) {
29075
+ this.cfg = cfg;
29076
+ }
29077
+ reset() {
29078
+ this.tip = null;
29079
+ }
29080
+ seed(point) {
29081
+ this.tip = { ...point };
29082
+ }
29083
+ apply(point, ctx) {
29084
+ if (!this.tip) {
29085
+ this.tip = { ...point };
29086
+ return point;
29087
+ }
29088
+ const leash = this.cfg.radiusPx / unitScale(this.cfg, ctx);
29089
+ const dx = point.x - this.tip.x;
29090
+ const dy = point.y - this.tip.y;
29091
+ const distance = Math.hypot(dx, dy);
29092
+ if (distance > leash && distance > 0) {
29093
+ const pull = (distance - leash) / distance;
29094
+ this.tip = {
29095
+ x: this.tip.x + dx * pull,
29096
+ y: this.tip.y + dy * pull
29097
+ };
29098
+ }
29099
+ return { ...this.tip };
29100
+ }
29101
+ };
29102
+ /** Longest time constant of the streamline stabilizer, at amount 1, in ms. */
29103
+ const STREAMLINE_MAX_TAU_MS = 80;
29104
+ /**
29105
+ * Streamline: the recorded point eases towards the pen with a time constant
29106
+ * proportional to `amount`, so the result does not depend on how often samples
29107
+ * arrive. Amount 0 follows the pen exactly.
29108
+ */
29109
+ var StreamlineStabilizer = class {
29110
+ cfg;
29111
+ tip = null;
29112
+ lastTime = 0;
29113
+ constructor(cfg) {
29114
+ this.cfg = cfg;
29115
+ }
29116
+ reset() {
29117
+ this.tip = null;
29118
+ }
29119
+ seed(point, ctx) {
29120
+ this.tip = { ...point };
29121
+ this.lastTime = ctx.now;
29122
+ }
29123
+ apply(point, ctx) {
29124
+ const tau = this.cfg.amount * STREAMLINE_MAX_TAU_MS;
29125
+ if (!this.tip || tau <= 0) {
29126
+ this.tip = { ...point };
29127
+ this.lastTime = ctx.now;
29128
+ return point;
29129
+ }
29130
+ const dt = seconds(ctx.now, this.lastTime) * 1e3;
29131
+ const alpha = 1 - Math.exp(-dt / tau);
29132
+ this.tip = {
29133
+ x: this.tip.x + (point.x - this.tip.x) * alpha,
29134
+ y: this.tip.y + (point.y - this.tip.y) * alpha
29135
+ };
29136
+ this.lastTime = ctx.now;
29137
+ return { ...this.tip };
29138
+ }
29139
+ };
29140
+ /** Creates the stabilizer selected by a profile. */
29141
+ function createStabilizer(cfg) {
29142
+ switch (cfg.type) {
29143
+ case "oneEuro": return new OneEuroStabilizer(cfg);
29144
+ case "pulledString": return new PulledStringStabilizer(cfg);
29145
+ case "streamline": return new StreamlineStabilizer(cfg);
29146
+ default: return new NoStabilizer();
29147
+ }
29148
+ }
29149
+ //#endregion
29150
+ //#region src/actions/brush-tool/stages/pressure.ts
29151
+ /** Time assumed between two samples whose times do not advance, in ms. */
29152
+ const NOMINAL_SAMPLE_INTERVAL_MS = 5;
29153
+ function resetPressureState(state) {
29154
+ state.lastSmoothedPressure = .5;
29155
+ state.hasPressureSample = false;
29156
+ state.lastPointerPos = null;
29157
+ state.lastPointerTime = 0;
29158
+ state.predictedCount = 0;
29159
+ state.lastDerivative = 0;
29160
+ state.lastNonZeroPressure = null;
29161
+ }
29162
+ /**
29163
+ * Raw pressure a pointer sample reports: the device's own reading for a pen, a
29164
+ * neutral constant for devices that do not sense pressure.
29165
+ *
29166
+ * `zero` decides what a reported pressure of 0 means for a pen: `legacy`
29167
+ * replaces it with 0.5, `keep` records it (the pressure floor still applies),
29168
+ * `holdLast` repeats the last non-zero reading. `commit: false` reads without
29169
+ * remembering the value, for speculative samples.
29170
+ */
29171
+ function getRawPressure(pointerType, pressure, zero = "legacy", state, commit = true) {
29172
+ if (pointerType !== "pen") return .5;
29173
+ const value = typeof pressure === "number" && Number.isFinite(pressure) ? pressure : 0;
29174
+ switch (zero) {
29175
+ case "keep": return typeof pressure === "number" && Number.isFinite(pressure) ? pressure : .5;
29176
+ case "holdLast":
29177
+ if (value > 0) {
29178
+ if (commit && state) state.lastNonZeroPressure = value;
29179
+ return value;
29180
+ }
29181
+ return state?.lastNonZeroPressure ?? .5;
29182
+ default: return value || .5;
29183
+ }
29184
+ }
29185
+ /**
29186
+ * Legacy velocity-adaptive exponential smoothing of one raw pressure sample.
29187
+ *
29188
+ * The first sample of a stroke seeds the filter with its own value. Fast
29189
+ * movement gives a higher alpha (more responsive), slow movement a lower one.
29190
+ */
29191
+ function smoothLegacy(state, cfg, raw, clientX, clientY, now) {
29192
+ let velocity = 0;
29193
+ const hasPosition = Number.isFinite(clientX) && Number.isFinite(clientY);
29194
+ if (hasPosition && state.lastPointerPos && now - state.lastPointerTime > 0) {
29195
+ const dx = clientX - state.lastPointerPos.x;
29196
+ const dy = clientY - state.lastPointerPos.y;
29197
+ velocity = Math.hypot(dx, dy) / (now - state.lastPointerTime) * 1e3;
29198
+ }
29199
+ if (hasPosition) state.lastPointerPos = {
29200
+ x: clientX,
29201
+ y: clientY
29202
+ };
29203
+ state.lastPointerTime = now;
29204
+ const safeRaw = Number.isFinite(raw) ? raw : .5;
29205
+ if (!state.hasPressureSample) {
29206
+ state.hasPressureSample = true;
29207
+ state.lastSmoothedPressure = safeRaw;
29208
+ return Math.max(state.lastSmoothedPressure, cfg.floor);
29209
+ }
29210
+ const alpha = Math.min(Math.max(velocity / 1500, .15), .6);
29211
+ const smoothed = alpha * safeRaw + (1 - alpha) * state.lastSmoothedPressure;
29212
+ state.lastSmoothedPressure = Number.isFinite(smoothed) ? smoothed : safeRaw;
29213
+ return Math.max(state.lastSmoothedPressure, cfg.floor);
29214
+ }
29215
+ /** Seconds between two samples; a non-advancing clock assumes a nominal rate. */
29216
+ function elapsedSeconds(state, now) {
29217
+ const dt = now - state.lastPointerTime;
29218
+ return (dt > 0 ? dt : NOMINAL_SAMPLE_INTERVAL_MS) / 1e3;
29219
+ }
29220
+ /** Seeds a filter with its first sample. */
29221
+ function seed(state, value, now) {
29222
+ state.hasPressureSample = true;
29223
+ state.lastSmoothedPressure = value;
29224
+ state.lastPointerTime = now;
29225
+ state.lastDerivative = 0;
29226
+ }
29227
+ /** Exponential smoothing with a time constant, independent of the report rate. */
29228
+ function smoothTimeEma(state, cfg, safeRaw, now) {
29229
+ if (!state.hasPressureSample) {
29230
+ seed(state, safeRaw, now);
29231
+ return Math.max(safeRaw, cfg.floor);
29232
+ }
29233
+ const dt = elapsedSeconds(state, now) * 1e3;
29234
+ const alpha = 1 - Math.exp(-dt / Math.max(cfg.timeConstantMs, 1e-6));
29235
+ const smoothed = alpha * safeRaw + (1 - alpha) * state.lastSmoothedPressure;
29236
+ state.lastSmoothedPressure = Number.isFinite(smoothed) ? smoothed : safeRaw;
29237
+ state.lastPointerTime = now;
29238
+ return Math.max(state.lastSmoothedPressure, cfg.floor);
29239
+ }
29240
+ const smoothingFactor = (dt, cutoff) => {
29241
+ const r = 2 * Math.PI * cutoff * dt;
29242
+ return r / (r + 1);
29243
+ };
29244
+ /**
29245
+ * One Euro filter (Casiez, Roussel, Vogel 2012): a low-pass filter whose cutoff
29246
+ * rises with speed, so slow changes are smoothed and fast ones follow closely.
29247
+ */
29248
+ function smoothOneEuro(state, cfg, safeRaw, now) {
29249
+ if (!state.hasPressureSample) {
29250
+ seed(state, safeRaw, now);
29251
+ return Math.max(safeRaw, cfg.floor);
29252
+ }
29253
+ const dt = elapsedSeconds(state, now);
29254
+ const derivative = (safeRaw - state.lastSmoothedPressure) / dt;
29255
+ const a = smoothingFactor(dt, 1);
29256
+ state.lastDerivative = a * derivative + (1 - a) * state.lastDerivative;
29257
+ const cutoff = cfg.oneEuro.minCutoff + cfg.oneEuro.beta * Math.abs(state.lastDerivative);
29258
+ const alpha = smoothingFactor(dt, cutoff);
29259
+ const smoothed = alpha * safeRaw + (1 - alpha) * state.lastSmoothedPressure;
29260
+ state.lastSmoothedPressure = Number.isFinite(smoothed) ? smoothed : safeRaw;
29261
+ state.lastPointerTime = now;
29262
+ return Math.max(state.lastSmoothedPressure, cfg.floor);
29263
+ }
29264
+ /** Smooths one raw pressure sample with the filter selected by the profile. */
29265
+ function smoothPressure(state, cfg, raw, clientX, clientY, now) {
29266
+ const safeRaw = Number.isFinite(raw) ? raw : .5;
29267
+ switch (cfg.filter) {
29268
+ case "timeEma": return smoothTimeEma(state, cfg, safeRaw, now);
29269
+ case "oneEuro": return smoothOneEuro(state, cfg, safeRaw, now);
29270
+ case "none":
29271
+ state.hasPressureSample = true;
29272
+ state.lastSmoothedPressure = safeRaw;
29273
+ state.lastPointerTime = now;
29274
+ return Math.max(safeRaw, cfg.floor);
29275
+ default: return smoothLegacy(state, cfg, raw, clientX, clientY, now);
29276
+ }
29277
+ }
29278
+ /**
29279
+ * Pressure of a predicted (speculative) sample. It blends against the current
29280
+ * state read-only, because predicted samples are rolled back and must not
29281
+ * advance the state real samples depend on.
29282
+ */
29283
+ function peekPredictedPressure(state, cfg, raw) {
29284
+ return Math.max(.15 * raw + .85 * state.lastSmoothedPressure, cfg.floor);
29285
+ }
29286
+ /**
29287
+ * Start policy for the first point's pressure. A stroke's pointer-down reading
29288
+ * is frequently an unreliable contact spike.
29289
+ *
29290
+ * - `backfill`: the first real movement sample replaces it once it exists.
29291
+ * - `average`: it becomes the mean of the first `startSamples` real movement
29292
+ * readings, refined as each one arrives, so it never keeps the contact value.
29293
+ * - `none`: it is left as reported.
29294
+ *
29295
+ * Predicted samples never change it.
29296
+ */
29297
+ function applyStartPolicy(elements, incoming, isPredicted, cfg) {
29298
+ if (isPredicted || elements.length === 0) return;
29299
+ if (cfg.start === "backfill" && elements.length === 1) elements[0] = {
29300
+ ...elements[0],
29301
+ pressure: incoming.pressure
29302
+ };
29303
+ else if (cfg.start === "average" && elements.length <= cfg.startSamples) {
29304
+ const readings = [...elements.slice(1), incoming];
29305
+ elements[0] = {
29306
+ ...elements[0],
29307
+ pressure: readings.reduce((sum, p) => sum + p.pressure, 0) / readings.length
29308
+ };
29309
+ }
29310
+ }
29311
+ /**
29312
+ * End policy. A lift-off reading can be as unreliable as the contact spike;
29313
+ * under `backfill` the last point takes the second-to-last point's pressure.
29314
+ */
29315
+ function applyEndPolicy(elements, cfg) {
29316
+ if (cfg.end === "backfill" && elements.length >= 2) {
29317
+ const result = [...elements];
29318
+ const lastIndex = result.length - 1;
29319
+ result[lastIndex] = {
29320
+ ...result[lastIndex],
29321
+ pressure: result[lastIndex - 1].pressure
29322
+ };
29323
+ return result;
29324
+ }
29325
+ return elements;
29326
+ }
29327
+ //#endregion
29328
+ //#region src/actions/brush-tool/stages/capture.ts
29329
+ const createCaptureState = () => ({ lastClient: null });
29330
+ /**
29331
+ * Legacy capture of a pointer move that carries batched (coalesced) samples.
29332
+ *
29333
+ * Every real sample is smoothed through the same path as any other sample but
29334
+ * deliberately carries no `sourceEvent`: coalesced samples keep using the
29335
+ * event's already-resolved pointer position. The last predicted sample, if
29336
+ * any, is appended as a provisional point.
29337
+ *
29338
+ * Returns `null` when the event has at most one sample, so the caller takes
29339
+ * the single-event path.
29340
+ */
29341
+ function captureLegacySamples(evt, deps) {
29342
+ const coalescedEvents = evt.getCoalescedEvents ? evt.getCoalescedEvents() : [];
29343
+ if (coalescedEvents.length <= 1) return null;
29344
+ const samples = coalescedEvents.map((ce) => ({
29345
+ pressure: deps.smooth(deps.rawPressure(ce.pointerType, ce.pressure), ce.clientX, ce.clientY, ce.timeStamp),
29346
+ isPredicted: false
29347
+ }));
29348
+ const predictedEvents = evt.getPredictedEvents ? evt.getPredictedEvents() : [];
29349
+ if (predictedEvents.length > 0) {
29350
+ const last = predictedEvents[predictedEvents.length - 1];
29351
+ samples.push({
29352
+ pressure: deps.predictedPressure(deps.rawPressure(last.pointerType, last.pressure, false)),
29353
+ sourceEvent: last,
29354
+ isPredicted: true
29355
+ });
29356
+ }
29357
+ return samples;
29358
+ }
29359
+ /**
29360
+ * Captures every sample a pointer move carries: each batched (coalesced) sample
29361
+ * becomes a point at its own position, pressure and time, in the order the
29362
+ * browser reported them. An event without a batch is one sample.
29363
+ *
29364
+ * Positions are computed from each sample's client coordinates, so no shared
29365
+ * stage state is changed. Samples closer than `minMovePx` (screen px) to the
29366
+ * last recorded one are skipped. The browser's predicted tail is appended as a
29367
+ * provisional point when enabled and at least one real sample was kept.
29368
+ */
29369
+ function captureCoalescedSamples(evt, deps, cfg, state) {
29370
+ const batch = evt.getCoalescedEvents ? evt.getCoalescedEvents() : [];
29371
+ const source = batch.length > 0 ? batch : [evt];
29372
+ const samples = [];
29373
+ for (const ce of source) {
29374
+ if (cfg.minMovePx > 0 && state.lastClient && Math.hypot(ce.clientX - state.lastClient.x, ce.clientY - state.lastClient.y) < cfg.minMovePx) continue;
29375
+ state.lastClient = {
29376
+ x: ce.clientX,
29377
+ y: ce.clientY
29378
+ };
29379
+ samples.push({
29380
+ pressure: deps.smooth(deps.rawPressure(ce.pointerType, ce.pressure), ce.clientX, ce.clientY, ce.timeStamp),
29381
+ point: deps.toContainerPoint(ce.clientX, ce.clientY),
29382
+ time: ce.timeStamp,
29383
+ isPredicted: false
29384
+ });
29385
+ }
29386
+ if (samples.length > 0 && cfg.predicted === "tail") {
29387
+ const predictedEvents = evt.getPredictedEvents ? evt.getPredictedEvents() : [];
29388
+ if (predictedEvents.length > 0) {
29389
+ const last = predictedEvents[predictedEvents.length - 1];
29390
+ samples.push({
29391
+ pressure: deps.predictedPressure(deps.rawPressure(last.pointerType, last.pressure, false)),
29392
+ point: deps.toContainerPoint(last.clientX, last.clientY),
29393
+ time: last.timeStamp,
29394
+ isPredicted: true
29395
+ });
29396
+ }
29397
+ }
29398
+ return samples;
29399
+ }
29400
+ //#endregion
29401
+ //#region src/actions/brush-tool/stages/bezier-fit.ts
29402
+ const sub = (a, b) => ({
29403
+ x: a.x - b.x,
29404
+ y: a.y - b.y
29405
+ });
29406
+ const add = (a, b) => ({
29407
+ x: a.x + b.x,
29408
+ y: a.y + b.y
29409
+ });
29410
+ const scale = (a, s) => ({
29411
+ x: a.x * s,
29412
+ y: a.y * s
29413
+ });
29414
+ const dot = (a, b) => a.x * b.x + a.y * b.y;
29415
+ const length = (a) => Math.hypot(a.x, a.y);
29416
+ const normalize = (a) => {
29417
+ const l = length(a);
29418
+ return l > 0 ? {
29419
+ x: a.x / l,
29420
+ y: a.y / l
29421
+ } : {
29422
+ x: 0,
29423
+ y: 0
29424
+ };
29425
+ };
29426
+ const B0 = (u) => (1 - u) ** 3;
29427
+ const B1 = (u) => 3 * u * (1 - u) ** 2;
29428
+ const B2 = (u) => 3 * u * u * (1 - u);
29429
+ const B3 = (u) => u ** 3;
29430
+ /** Point on a cubic Bézier curve. */
29431
+ function bezierPoint(b, t) {
29432
+ return {
29433
+ x: B0(t) * b[0].x + B1(t) * b[1].x + B2(t) * b[2].x + B3(t) * b[3].x,
29434
+ y: B0(t) * b[0].y + B1(t) * b[1].y + B2(t) * b[2].y + B3(t) * b[3].y
29435
+ };
29436
+ }
29437
+ function derivative(b, t) {
29438
+ const q = [
29439
+ sub(b[1], b[0]),
29440
+ sub(b[2], b[1]),
29441
+ sub(b[3], b[2])
29442
+ ].map((v) => scale(v, 3));
29443
+ const s = 1 - t;
29444
+ return {
29445
+ x: s * s * q[0].x + 2 * s * t * q[1].x + t * t * q[2].x,
29446
+ y: s * s * q[0].y + 2 * s * t * q[1].y + t * t * q[2].y
29447
+ };
29448
+ }
29449
+ function secondDerivative(b, t) {
29450
+ const q = [add(sub(b[2], scale(b[1], 2)), b[0]), add(sub(b[3], scale(b[2], 2)), b[1])].map((v) => scale(v, 6));
29451
+ return {
29452
+ x: (1 - t) * q[0].x + t * q[1].x,
29453
+ y: (1 - t) * q[0].y + t * q[1].y
29454
+ };
29455
+ }
29456
+ /**
29457
+ * Direction of the curve at `index`, estimated over a window at least
29458
+ * `window` long on each side (but inside `first..last`), so that noise in the
29459
+ * nearest samples does not tilt it. With `before` false only the following
29460
+ * points are used (a start tangent); with `after` false only the preceding
29461
+ * ones (an end tangent). The vector points forward along the stroke.
29462
+ */
29463
+ function windowTangent(pts, index, first, last, window, before, after) {
29464
+ let back = index;
29465
+ if (before) while (back > first && length(sub(pts[index], pts[back])) < window) back--;
29466
+ let ahead = index;
29467
+ if (after) while (ahead < last && length(sub(pts[ahead], pts[index])) < window) ahead++;
29468
+ return normalize(sub(pts[ahead], pts[back]));
29469
+ }
29470
+ function chordLengthParameterize(pts, first, last) {
29471
+ const u = [0];
29472
+ for (let i = first + 1; i <= last; i++) u.push(u[u.length - 1] + length(sub(pts[i], pts[i - 1])));
29473
+ const total = u[u.length - 1] || 1;
29474
+ return u.map((v) => v / total);
29475
+ }
29476
+ function generateBezier(pts, first, last, u, tHat1, tHat2) {
29477
+ const p0 = pts[first];
29478
+ const p3 = pts[last];
29479
+ let c00 = 0, c01 = 0, c11 = 0, x0 = 0, x1 = 0;
29480
+ for (let i = 0; i < u.length; i++) {
29481
+ const a0 = scale(tHat1, B1(u[i]));
29482
+ const a1 = scale(tHat2, B2(u[i]));
29483
+ c00 += dot(a0, a0);
29484
+ c01 += dot(a0, a1);
29485
+ c11 += dot(a1, a1);
29486
+ const tmp = sub(pts[first + i], add(scale(p0, B0(u[i]) + B1(u[i])), scale(p3, B2(u[i]) + B3(u[i]))));
29487
+ x0 += dot(a0, tmp);
29488
+ x1 += dot(a1, tmp);
29489
+ }
29490
+ const det = c00 * c11 - c01 * c01;
29491
+ const alphaL = det === 0 ? 0 : (x0 * c11 - x1 * c01) / det;
29492
+ const alphaR = det === 0 ? 0 : (c00 * x1 - c01 * x0) / det;
29493
+ const segLength = length(sub(p3, p0));
29494
+ const epsilon = 1e-6 * segLength;
29495
+ if (alphaL < epsilon || alphaR < epsilon) {
29496
+ const d = segLength / 3;
29497
+ return [
29498
+ p0,
29499
+ add(p0, scale(tHat1, d)),
29500
+ add(p3, scale(tHat2, d)),
29501
+ p3
29502
+ ];
29503
+ }
29504
+ return [
29505
+ p0,
29506
+ add(p0, scale(tHat1, alphaL)),
29507
+ add(p3, scale(tHat2, alphaR)),
29508
+ p3
29509
+ ];
29510
+ }
29511
+ function newtonRaphson(b, p, u) {
29512
+ const d = sub(bezierPoint(b, u), p);
29513
+ const d1 = derivative(b, u);
29514
+ const d2 = secondDerivative(b, u);
29515
+ const numerator = dot(d, d1);
29516
+ const denominator = dot(d1, d1) + dot(d, d2);
29517
+ return denominator === 0 ? u : u - numerator / denominator;
29518
+ }
29519
+ function maxError(pts, first, last, b, u) {
29520
+ let split = Math.floor((last - first + 1) / 2) + first;
29521
+ let max = 0;
29522
+ for (let i = first + 1; i < last; i++) {
29523
+ const d = sub(bezierPoint(b, u[i - first]), pts[i]);
29524
+ const e = dot(d, d);
29525
+ if (e >= max) {
29526
+ max = e;
29527
+ split = i;
29528
+ }
29529
+ }
29530
+ return {
29531
+ error: max,
29532
+ split
29533
+ };
29534
+ }
29535
+ function fitCubic(pts, first, last, tHat1, tHat2, errorSq, window, out) {
29536
+ if (last - first === 1) {
29537
+ const d = length(sub(pts[last], pts[first])) / 3;
29538
+ out.push({
29539
+ bez: [
29540
+ pts[first],
29541
+ add(pts[first], scale(tHat1, d)),
29542
+ add(pts[last], scale(tHat2, d)),
29543
+ pts[last]
29544
+ ],
29545
+ first,
29546
+ last,
29547
+ u: [0, 1]
29548
+ });
29549
+ return;
29550
+ }
29551
+ let u = chordLengthParameterize(pts, first, last);
29552
+ let bez = generateBezier(pts, first, last, u, tHat1, tHat2);
29553
+ let { error, split } = maxError(pts, first, last, bez, u);
29554
+ if (error < errorSq) {
29555
+ out.push({
29556
+ bez,
29557
+ first,
29558
+ last,
29559
+ u
29560
+ });
29561
+ return;
29562
+ }
29563
+ if (error < errorSq * 4) for (let i = 0; i < 4; i++) {
29564
+ const prime = u.map((v, k) => {
29565
+ const p = newtonRaphson(bez, pts[first + k], v);
29566
+ return Number.isFinite(p) ? Math.min(Math.max(p, 0), 1) : v;
29567
+ });
29568
+ bez = generateBezier(pts, first, last, prime, tHat1, tHat2);
29569
+ ({error, split} = maxError(pts, first, last, bez, prime));
29570
+ u = prime;
29571
+ if (error < errorSq) {
29572
+ out.push({
29573
+ bez,
29574
+ first,
29575
+ last,
29576
+ u
29577
+ });
29578
+ return;
29579
+ }
29580
+ }
29581
+ const center = windowTangent(pts, split, first, last, window, true, true);
29582
+ const back = scale(center, -1);
29583
+ fitCubic(pts, first, split, tHat1, back, errorSq, window, out);
29584
+ fitCubic(pts, split, last, center, tHat2, errorSq, window, out);
29585
+ }
29586
+ /**
29587
+ * Fits cubic Bézier segments to a polyline so that no input point is farther
29588
+ * than `tolerance` from the curve. Needs at least two points, none repeated
29589
+ * consecutively.
29590
+ */
29591
+ function fitCurve(points, tolerance) {
29592
+ if (points.length < 2) return [];
29593
+ const out = [];
29594
+ const last = points.length - 1;
29595
+ const window = tolerance * 3;
29596
+ fitCubic(points, 0, last, windowTangent(points, 0, 0, last, window, false, true), scale(windowTangent(points, last, 0, last, window, true, false), -1), tolerance * tolerance, window, out);
29597
+ return out;
29598
+ }
29599
+ //#endregion
29600
+ //#region src/actions/brush-tool/stages/finalize.ts
29601
+ /** Size of one screen pixel in canvas units at a zoom level. */
29602
+ const pixelSize = (zoom) => zoom > 0 && Number.isFinite(zoom) ? 1 / zoom : 1;
29603
+ /**
29604
+ * Tolerance applied when a stroke is finished, in canvas units. The legacy
29605
+ * value is the tool-level tolerance in canvas units; with the `screen` unit the
29606
+ * configured pixels are converted with the zoom in force when the stroke ends,
29607
+ * so the same setting gives the same on-screen fidelity at any zoom.
29608
+ */
29609
+ function resolveFinalizeTolerance(cfg, toolTolerance, zoom = 1) {
29610
+ const tolerance = cfg.tolerance ?? toolTolerance;
29611
+ return cfg.toleranceUnit === "screen" ? tolerance * pixelSize(zoom) : tolerance;
29612
+ }
29613
+ /** Distance from `p` to the segment, and where along it the closest point is. */
29614
+ function toSegment(p, { a, b }) {
29615
+ const dx = b.x - a.x;
29616
+ const dy = b.y - a.y;
29617
+ const lengthSq = dx * dx + dy * dy;
29618
+ const t = lengthSq ? Math.min(1, Math.max(0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSq)) : 0;
29619
+ return {
29620
+ distance: Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t)),
29621
+ t
29622
+ };
29623
+ }
29624
+ /**
29625
+ * Douglas-Peucker simplification that also keeps pressure detail: a point is
29626
+ * kept when it is off the straight path by more than `tolerance`, or when its
29627
+ * pressure differs from the pressure interpolated along that path by more than
29628
+ * `tolerance / pressureWeight`.
29629
+ */
29630
+ function simplifyWithPressure(points, tolerance, pressureWeight) {
29631
+ if (points.length <= 2) return points;
29632
+ const keep = new Uint8Array(points.length);
29633
+ keep[0] = keep[points.length - 1] = 1;
29634
+ const stack = [[0, points.length - 1]];
29635
+ while (stack.length > 0) {
29636
+ const [first, last] = stack.pop();
29637
+ let worst = 0;
29638
+ let index = -1;
29639
+ for (let i = first + 1; i < last; i++) {
29640
+ const { distance, t } = toSegment(points[i], {
29641
+ a: points[first],
29642
+ b: points[last]
29643
+ });
29644
+ const expected = points[first].pressure + (points[last].pressure - points[first].pressure) * t;
29645
+ const error = Math.max(distance, Math.abs(points[i].pressure - expected) * pressureWeight);
29646
+ if (error > worst) {
29647
+ worst = error;
29648
+ index = i;
29649
+ }
29650
+ }
29651
+ if (worst > tolerance && index !== -1) {
29652
+ keep[index] = 1;
29653
+ stack.push([first, index], [index, last]);
29654
+ }
29655
+ }
29656
+ return points.filter((_, i) => keep[i] === 1);
29657
+ }
29658
+ /** Pressure at curve parameter `t` of a segment, interpolated between input points. */
29659
+ function pressureAt(seg, points, t) {
29660
+ let prev = seg.u[0];
29661
+ for (let k = 1; k < seg.u.length; k++) {
29662
+ const next = Math.max(seg.u[k], prev);
29663
+ if (t <= next || k === seg.u.length - 1) {
29664
+ const span = next - prev;
29665
+ const f = span > 0 ? Math.min(1, Math.max(0, (t - prev) / span)) : 0;
29666
+ const p0 = points[seg.first + k - 1].pressure;
29667
+ return p0 + (points[seg.first + k].pressure - p0) * f;
29668
+ }
29669
+ prev = next;
29670
+ }
29671
+ return points[seg.last].pressure;
29672
+ }
29673
+ /**
29674
+ * Fits a smooth curve through the stroke and samples it back to points, so the
29675
+ * saved format stays `{x, y, pressure}`. `tolerance` is the largest distance
29676
+ * between the curve and an input point; `spacing` is the distance between
29677
+ * sampled points. Both are in canvas units. The first and last points are kept
29678
+ * exactly.
29679
+ */
29680
+ function fitAndResample(points, tolerance, spacing) {
29681
+ if (points.length < 3 || !(tolerance > 0)) return points;
29682
+ const distinct = points.filter((p, i) => i === 0 || p.x !== points[i - 1].x || p.y !== points[i - 1].y);
29683
+ if (distinct.length < 3) return points;
29684
+ const segments = fitCurve(distinct, tolerance);
29685
+ if (segments.length === 0) return points;
29686
+ const out = [];
29687
+ for (const seg of segments) {
29688
+ let arc = 0;
29689
+ let prev = seg.bez[0];
29690
+ for (let k = 1; k <= 16; k++) {
29691
+ const q = bezierPoint(seg.bez, k / 16);
29692
+ arc += Math.hypot(q.x - prev.x, q.y - prev.y);
29693
+ prev = q;
29694
+ }
29695
+ const steps = Math.max(1, Math.ceil(arc / Math.max(spacing, 1e-6)));
29696
+ for (let k = 0; k < steps; k++) {
29697
+ const t = k / steps;
29698
+ const q = k === 0 ? seg.bez[0] : bezierPoint(seg.bez, t);
29699
+ out.push({
29700
+ x: q.x,
29701
+ y: q.y,
29702
+ pressure: pressureAt(seg, distinct, t)
29703
+ });
29704
+ }
29705
+ }
29706
+ const end = distinct[distinct.length - 1];
29707
+ out.push({
29708
+ x: end.x,
29709
+ y: end.y,
29710
+ pressure: end.pressure
29711
+ });
29712
+ return out;
29713
+ }
29714
+ /** Reduces a finished stroke's points as selected by the profile. */
29715
+ function finalizePoints(points, cfg, toolTolerance, zoom = 1) {
29716
+ const tolerance = resolveFinalizeTolerance(cfg, toolTolerance, zoom);
29717
+ switch (cfg.mode) {
29718
+ case "none": return points;
29719
+ case "dpPressure": return simplifyWithPressure(points, tolerance, cfg.toleranceUnit === "screen" ? cfg.pressureWeight * pixelSize(zoom) : cfg.pressureWeight);
29720
+ case "bezierFit": return fitAndResample(points, tolerance, cfg.resampleSpacingPx * pixelSize(zoom));
29721
+ default: return simplify(points, tolerance, true);
29722
+ }
29723
+ }
29724
+ //#endregion
29725
+ //#region src/actions/brush-tool/profiles.ts
29726
+ /**
29727
+ * Coarse platform of the running browser: `ipad`, `ios`, `mac`, `windows`,
29728
+ * `android`, `linux` or `unknown`. iPadOS reports a desktop Mac user agent, so
29729
+ * a Mac with multi-touch is an iPad.
29730
+ */
29731
+ function detectBrushPlatform(nav) {
29732
+ const source = nav === void 0 ? typeof navigator === "undefined" ? null : navigator : nav;
29733
+ if (!source) return "unknown";
29734
+ const ua = source.userAgent ?? "";
29735
+ const platform = source.platform ?? "";
29736
+ if (/iPad/i.test(ua)) return "ipad";
29737
+ if (/iPhone|iPod/i.test(ua)) return "ios";
29738
+ if (/Mac/i.test(platform) && (source.maxTouchPoints ?? 0) > 1) return "ipad";
29739
+ if (/Android/i.test(ua)) return "android";
29740
+ if (/Win/i.test(platform) || /Windows/i.test(ua)) return "windows";
29741
+ if (/Mac/i.test(platform) || /Macintosh/i.test(ua)) return "mac";
29742
+ if (/Linux|X11/i.test(platform) || /Linux|X11/i.test(ua)) return "linux";
29743
+ return "unknown";
29744
+ }
29745
+ const isProfileKey = (key) => BRUSH_INPUT_PROFILE_KEYS.includes(key);
29746
+ /**
29747
+ * Profile chosen from the pointer type and platform. A pen on an iPad is an
29748
+ * Apple Pencil; any other pen is a desktop pen. Unknown pointer types behave
29749
+ * like a mouse.
29750
+ */
29751
+ function automaticBrushProfileKey(info) {
29752
+ switch (info.pointerType) {
29753
+ case "pen": return info.platform === "ipad" ? "pen.applePencil" : "pen.desktop";
29754
+ case "touch": return "touch";
29755
+ default: return "mouse";
29756
+ }
29757
+ }
29758
+ /**
29759
+ * Resolves the input profile for a stroke. A forced profile wins, then the
29760
+ * application's resolver, then the automatic choice. A resolver that throws or
29761
+ * returns an unknown key is ignored.
29762
+ */
29763
+ function resolveBrushInputProfileKey(info, cfg) {
29764
+ if (isProfileKey(cfg.forceProfile)) return cfg.forceProfile;
29765
+ if (cfg.resolveProfile) try {
29766
+ const key = cfg.resolveProfile(info);
29767
+ if (isProfileKey(key)) return key;
29768
+ } catch {}
29769
+ return automaticBrushProfileKey(info);
29770
+ }
29771
+ //#endregion
29772
+ //#region src/actions/brush-tool/tunables/descriptors.ts
29773
+ const profile = (key, spec) => ({
29774
+ key,
29775
+ scope: "profile",
29776
+ path: key.split("."),
29777
+ ...spec
29778
+ });
29779
+ const look = (key, spec) => ({
29780
+ key,
29781
+ scope: "look",
29782
+ path: key.split("."),
29783
+ ...spec
29784
+ });
29785
+ /** Every setting accepted by an input profile. */
29786
+ const BRUSH_PROFILE_TUNABLES = [
29787
+ profile("capture.source", {
29788
+ stage: "capture",
29789
+ group: "Capture",
29790
+ kind: "capture",
29791
+ type: "enum",
29792
+ default: "legacy",
29793
+ options: ["legacy", "coalesced"],
29794
+ help: "legacy keeps one position per event; coalesced keeps every batched pen sample with its own position."
29795
+ }),
29796
+ profile("capture.clock", {
29797
+ stage: "capture",
29798
+ group: "Capture",
29799
+ kind: "capture",
29800
+ type: "enum",
29801
+ default: "now",
29802
+ options: ["now", "event"],
29803
+ help: "Time base of time-dependent filters: processing time or the time reported by each sample."
29804
+ }),
29805
+ profile("capture.minMovePx", {
29806
+ stage: "capture",
29807
+ group: "Capture",
29808
+ kind: "capture",
29809
+ type: "number",
29810
+ default: 0,
29811
+ min: 0,
29812
+ max: 3,
29813
+ step: .1,
29814
+ unit: "px",
29815
+ help: "Samples closer than this to the last recorded one are skipped."
29816
+ }),
29817
+ profile("capture.predicted", {
29818
+ stage: "capture",
29819
+ group: "Capture",
29820
+ kind: "capture",
29821
+ type: "enum",
29822
+ default: "tail",
29823
+ options: ["off", "tail"],
29824
+ help: "Draw the browser-predicted tail ahead of the pen to hide latency."
29825
+ }),
29826
+ profile("capture.event", {
29827
+ stage: "capture",
29828
+ group: "Capture",
29829
+ kind: "capture",
29830
+ type: "enum",
29831
+ default: "pointermove",
29832
+ options: ["pointermove", "pointerrawupdate"],
29833
+ help: "Event that delivers samples. pointerrawupdate lowers latency where supported.",
29834
+ advanced: true
29835
+ }),
29836
+ profile("pressure.zero", {
29837
+ stage: "pressure",
29838
+ group: "Pressure",
29839
+ kind: "capture",
29840
+ type: "enum",
29841
+ default: "legacy",
29842
+ options: [
29843
+ "legacy",
29844
+ "keep",
29845
+ "holdLast"
29846
+ ],
29847
+ help: "How a pen pressure of 0 is treated: legacy replaces it with 0.5, keep records it, holdLast repeats the last non-zero value."
29848
+ }),
29849
+ profile("pressure.filter", {
29850
+ stage: "pressure",
29851
+ group: "Pressure",
29852
+ kind: "capture",
29853
+ type: "enum",
29854
+ default: "legacy",
29855
+ options: [
29856
+ "legacy",
29857
+ "timeEma",
29858
+ "oneEuro",
29859
+ "none"
29860
+ ],
29861
+ help: "Pressure smoothing: the legacy velocity-adaptive filter, a time-based one, the One Euro filter, or none."
29862
+ }),
29863
+ profile("pressure.timeConstantMs", {
29864
+ stage: "pressure",
29865
+ group: "Pressure",
29866
+ kind: "capture",
29867
+ type: "number",
29868
+ default: 40,
29869
+ min: 1,
29870
+ max: 500,
29871
+ step: 1,
29872
+ unit: "ms",
29873
+ help: "Time constant of the timeEma filter; larger is smoother and slower."
29874
+ }),
29875
+ profile("pressure.oneEuro.minCutoff", {
29876
+ stage: "pressure",
29877
+ group: "Pressure",
29878
+ kind: "capture",
29879
+ type: "number",
29880
+ default: 1,
29881
+ min: .01,
29882
+ max: 10,
29883
+ step: .01,
29884
+ unit: "Hz",
29885
+ help: "One Euro minimum cutoff: lower removes more jitter at slow speed.",
29886
+ advanced: true
29887
+ }),
29888
+ profile("pressure.oneEuro.beta", {
29889
+ stage: "pressure",
29890
+ group: "Pressure",
29891
+ kind: "capture",
29892
+ type: "number",
29893
+ default: .01,
29894
+ min: 0,
29895
+ max: 1,
29896
+ step: .001,
29897
+ help: "One Euro speed coefficient: higher removes more lag at high speed.",
29898
+ advanced: true
29899
+ }),
29900
+ profile("pressure.floor", {
29901
+ stage: "pressure",
29902
+ group: "Pressure",
29903
+ kind: "capture",
29904
+ type: "number",
29905
+ default: .15,
29906
+ min: 0,
29907
+ max: .5,
29908
+ step: .01,
29909
+ unit: "fraction",
29910
+ help: "Lowest pressure that is recorded; prevents invisible strokes."
29911
+ }),
29912
+ profile("pressure.start", {
29913
+ stage: "pressure",
29914
+ group: "Pressure",
29915
+ kind: "capture",
29916
+ type: "enum",
29917
+ default: "backfill",
29918
+ options: [
29919
+ "backfill",
29920
+ "average",
29921
+ "none"
29922
+ ],
29923
+ help: "First point: backfill takes the next real sample, average takes the mean of the first movement readings, none keeps the contact reading."
29924
+ }),
29925
+ profile("pressure.startSamples", {
29926
+ stage: "pressure",
29927
+ group: "Pressure",
29928
+ kind: "capture",
29929
+ type: "number",
29930
+ default: 3,
29931
+ min: 1,
29932
+ max: 10,
29933
+ step: 1,
29934
+ integer: true,
29935
+ unit: "count",
29936
+ help: "Number of readings averaged by the average start policy.",
29937
+ advanced: true
29938
+ }),
29939
+ profile("pressure.end", {
29940
+ stage: "pressure",
29941
+ group: "Pressure",
29942
+ kind: "capture",
29943
+ type: "enum",
29944
+ default: "backfill",
29945
+ options: ["backfill", "none"],
29946
+ help: "Last point: backfill takes the second-to-last pressure, none keeps the lift-off reading."
29947
+ }),
29948
+ profile("stabilizer.type", {
29949
+ stage: "stabilizer",
29950
+ group: "Stabilizer",
29951
+ kind: "capture",
29952
+ type: "enum",
29953
+ default: "none",
29954
+ options: [
29955
+ "none",
29956
+ "oneEuro",
29957
+ "pulledString",
29958
+ "streamline"
29959
+ ],
29960
+ help: "Smooths the pen position while drawing."
29961
+ }),
29962
+ profile("stabilizer.minCutoff", {
29963
+ stage: "stabilizer",
29964
+ group: "Stabilizer",
29965
+ kind: "capture",
29966
+ type: "number",
29967
+ default: 3,
29968
+ min: .1,
29969
+ max: 20,
29970
+ step: .1,
29971
+ unit: "Hz",
29972
+ help: "One Euro minimum cutoff: lower removes more jitter at slow speed."
29973
+ }),
29974
+ profile("stabilizer.beta", {
29975
+ stage: "stabilizer",
29976
+ group: "Stabilizer",
29977
+ kind: "capture",
29978
+ type: "number",
29979
+ default: .01,
29980
+ min: 0,
29981
+ max: 1,
29982
+ step: .001,
29983
+ help: "One Euro speed coefficient: higher removes more lag at high speed."
29984
+ }),
29985
+ profile("stabilizer.radiusPx", {
29986
+ stage: "stabilizer",
29987
+ group: "Stabilizer",
29988
+ kind: "capture",
29989
+ type: "number",
29990
+ default: 4,
29991
+ min: 0,
29992
+ max: 40,
29993
+ step: .5,
29994
+ unit: "px",
29995
+ help: "Pulled string leash length: the pen must move this far before the line follows."
29996
+ }),
29997
+ profile("stabilizer.amount", {
29998
+ stage: "stabilizer",
29999
+ group: "Stabilizer",
30000
+ kind: "capture",
30001
+ type: "number",
30002
+ default: .3,
30003
+ min: 0,
30004
+ max: 1,
30005
+ step: .01,
30006
+ unit: "fraction",
30007
+ help: "Streamline strength: 0 follows the pen exactly, 1 is the strongest smoothing."
30008
+ }),
30009
+ profile("stabilizer.zoomAware", {
30010
+ stage: "stabilizer",
30011
+ group: "Stabilizer",
30012
+ kind: "capture",
30013
+ type: "boolean",
30014
+ default: false,
30015
+ help: "Interpret the distances in screen pixels, so the effect on screen is the same at every zoom level. Off: canvas units."
30016
+ }),
30017
+ profile("stabilizer.catchUpOnEnd", {
30018
+ stage: "stabilizer",
30019
+ group: "Stabilizer",
30020
+ kind: "capture",
30021
+ type: "boolean",
30022
+ default: true,
30023
+ help: "Complete the stroke at the position where the pen was lifted."
30024
+ }),
30025
+ profile("finalize.mode", {
30026
+ stage: "finalize",
30027
+ group: "Finish",
30028
+ kind: "capture",
30029
+ type: "enum",
30030
+ default: "dp",
30031
+ options: [
30032
+ "dp",
30033
+ "dpPressure",
30034
+ "bezierFit",
30035
+ "none"
30036
+ ],
30037
+ help: "How points are reduced when the stroke ends: simplification, simplification keeping pressure detail, curve fitting, or nothing."
30038
+ }),
30039
+ profile("finalize.tolerance", {
30040
+ stage: "finalize",
30041
+ group: "Finish",
30042
+ kind: "capture",
30043
+ type: "number",
30044
+ default: null,
30045
+ nullable: true,
30046
+ min: 0,
30047
+ max: 20,
30048
+ step: .05,
30049
+ help: "Tolerance of the finishing step. Empty uses the tool-level tolerance."
30050
+ }),
30051
+ profile("finalize.toleranceUnit", {
30052
+ stage: "finalize",
30053
+ group: "Finish",
30054
+ kind: "capture",
30055
+ type: "enum",
30056
+ default: "canvas",
30057
+ options: ["canvas", "screen"],
30058
+ help: "canvas: tolerance in canvas units. screen: in pixels, converted with the zoom when the stroke ends."
30059
+ }),
30060
+ profile("finalize.pressureWeight", {
30061
+ stage: "finalize",
30062
+ group: "Finish",
30063
+ kind: "capture",
30064
+ type: "number",
30065
+ default: 20,
30066
+ min: 0,
30067
+ max: 200,
30068
+ step: 1,
30069
+ help: "Distance units per pressure unit when simplification keeps pressure detail.",
30070
+ advanced: true
30071
+ }),
30072
+ profile("finalize.resampleSpacingPx", {
30073
+ stage: "finalize",
30074
+ group: "Finish",
30075
+ kind: "capture",
30076
+ type: "number",
30077
+ default: 2,
30078
+ min: .5,
30079
+ max: 20,
30080
+ step: .5,
30081
+ unit: "px",
30082
+ help: "Spacing of the points a fitted curve is resampled to.",
30083
+ advanced: true
30084
+ }),
30085
+ profile("touchGuard.waitMs", {
30086
+ stage: "touch",
30087
+ group: "Finger",
30088
+ kind: "capture",
30089
+ type: "number",
30090
+ default: 0,
30091
+ min: 0,
30092
+ max: 500,
30093
+ step: 10,
30094
+ unit: "ms",
30095
+ help: "Wait before a finger stroke is committed, so a second finger can cancel it."
30096
+ }),
30097
+ profile("touchGuard.movePx", {
30098
+ stage: "touch",
30099
+ group: "Finger",
30100
+ kind: "capture",
30101
+ type: "number",
30102
+ default: 0,
30103
+ min: 0,
30104
+ max: 40,
30105
+ step: 1,
30106
+ unit: "px",
30107
+ help: "Movement that commits the stroke before the wait ends."
30108
+ }),
30109
+ profile("touchGuard.cancelOnSecondTouch", {
30110
+ stage: "touch",
30111
+ group: "Finger",
30112
+ kind: "capture",
30113
+ type: "boolean",
30114
+ default: false,
30115
+ help: "Cancel the stroke when a second finger lands during the wait."
30116
+ }),
30117
+ profile("touchGuard.ignoreAfterPen", {
30118
+ stage: "touch",
30119
+ group: "Finger",
30120
+ kind: "capture",
30121
+ type: "boolean",
30122
+ default: false,
30123
+ help: "Finger input does not draw once a pen has been used."
30124
+ })
30125
+ ];
30126
+ /** Every setting accepted by a stroke look. */
30127
+ const BRUSH_LOOK_TUNABLES = [
30128
+ look("outline", {
30129
+ stage: "outline",
30130
+ group: "Outline",
30131
+ kind: "draw",
30132
+ type: "enum",
30133
+ default: "legacy",
30134
+ options: [
30135
+ "legacy",
30136
+ "round",
30137
+ "freehand"
30138
+ ],
30139
+ help: "Shape of the stroke: legacy ribbon, round (discs and segments, no spikes) or freehand."
30140
+ }),
30141
+ look("curve", {
30142
+ stage: "curve",
30143
+ group: "Curve",
30144
+ kind: "draw",
30145
+ type: "enum",
30146
+ default: "uniform",
30147
+ options: [
30148
+ "uniform",
30149
+ "centripetal",
30150
+ "none"
30151
+ ],
30152
+ help: "Curve through the points. centripetal does not overshoot when points are unevenly spaced."
30153
+ }),
30154
+ look("pressureInterp", {
30155
+ stage: "curve",
30156
+ group: "Curve",
30157
+ kind: "draw",
30158
+ type: "enum",
30159
+ default: "catmullRom",
30160
+ options: ["catmullRom", "linear"],
30161
+ help: "How pressure is interpolated between points. linear never bulges."
30162
+ }),
30163
+ look("zoomAware", {
30164
+ stage: "detail",
30165
+ group: "Detail",
30166
+ kind: "draw",
30167
+ type: "boolean",
30168
+ default: false,
30169
+ help: "Choose spacing and subdivision in screen pixels so smoothness does not depend on zoom."
30170
+ }),
30171
+ look("spacingPx", {
30172
+ stage: "detail",
30173
+ group: "Detail",
30174
+ kind: "draw",
30175
+ type: "number",
30176
+ default: 2,
30177
+ min: .5,
30178
+ max: 10,
30179
+ step: .5,
30180
+ unit: "px",
30181
+ help: "Minimum spacing between control points.",
30182
+ advanced: true
30183
+ }),
30184
+ look("edgePx", {
30185
+ stage: "detail",
30186
+ group: "Detail",
30187
+ kind: "draw",
30188
+ type: "number",
30189
+ default: 1,
30190
+ min: .25,
30191
+ max: 5,
30192
+ step: .25,
30193
+ unit: "px",
30194
+ help: "Detail of the curve. The centripetal curve keeps every edge within a tenth of this of the true curve; the uniform curve uses it as the target length of an edge.",
30195
+ advanced: true
30196
+ }),
30197
+ look("caps", {
30198
+ stage: "ends",
30199
+ group: "Ends",
30200
+ kind: "draw",
30201
+ type: "enum",
30202
+ default: "separate",
30203
+ options: ["separate", "merged"],
30204
+ help: "merged draws the round ends inside the same fill, so translucent colours stay uniform."
30205
+ }),
30206
+ look("width.min", {
30207
+ stage: "width",
30208
+ group: "Width",
30209
+ kind: "draw",
30210
+ type: "number",
30211
+ default: 0,
30212
+ min: 0,
30213
+ max: 1,
30214
+ step: .01,
30215
+ unit: "fraction",
30216
+ help: "Width at pressure 0, as a fraction of the stroke width."
30217
+ }),
30218
+ look("width.max", {
30219
+ stage: "width",
30220
+ group: "Width",
30221
+ kind: "draw",
30222
+ type: "number",
30223
+ default: 1,
30224
+ min: 0,
30225
+ max: 1,
30226
+ step: .01,
30227
+ unit: "fraction",
30228
+ help: "Width at pressure 1, as a fraction of the stroke width."
30229
+ }),
30230
+ look("width.gamma", {
30231
+ stage: "width",
30232
+ group: "Width",
30233
+ kind: "draw",
30234
+ type: "number",
30235
+ default: 1,
30236
+ min: .2,
30237
+ max: 3,
30238
+ step: .05,
30239
+ help: "Response curve: below 1 reacts faster to light pressure, above 1 needs a firmer press."
30240
+ }),
30241
+ look("width.taperStart", {
30242
+ stage: "width",
30243
+ group: "Width",
30244
+ kind: "draw",
30245
+ type: "number",
30246
+ default: 0,
30247
+ min: 0,
30248
+ max: 200,
30249
+ step: 1,
30250
+ unit: "px",
30251
+ help: "Distance over which the start narrows; 0 for none."
30252
+ }),
30253
+ look("width.taperEnd", {
30254
+ stage: "width",
30255
+ group: "Width",
30256
+ kind: "draw",
30257
+ type: "number",
30258
+ default: 0,
30259
+ min: 0,
30260
+ max: 200,
30261
+ step: 1,
30262
+ unit: "px",
30263
+ help: "Distance over which the end narrows; 0 for none."
30264
+ })
30265
+ ];
30266
+ /** Every tunable setting. */
30267
+ const BRUSH_TUNABLES = [...BRUSH_PROFILE_TUNABLES, ...BRUSH_LOOK_TUNABLES];
30268
+ /** Finds a tunable by scope and key. */
30269
+ function getBrushTunable(scope, key) {
30270
+ return BRUSH_TUNABLES.find((t) => t.scope === scope && t.key === key);
30271
+ }
30272
+ //#endregion
30273
+ //#region src/actions/brush-tool/tunables/validate.ts
30274
+ /**
30275
+ * Checks a value against a descriptor. Returns a message when the value is
30276
+ * invalid, or `null` when it is accepted.
30277
+ */
30278
+ function validateBrushTunableValue(tunable, value) {
30279
+ const name = `${tunable.scope} setting "${tunable.key}"`;
30280
+ if (value === null || value === void 0) return tunable.nullable ? null : `${name} cannot be empty`;
30281
+ switch (tunable.type) {
30282
+ case "boolean": return typeof value === "boolean" ? null : `${name} must be true or false`;
30283
+ case "enum": return typeof value === "string" && tunable.options?.includes(value) ? null : `${name} must be one of: ${tunable.options?.join(", ")}`;
30284
+ case "number":
30285
+ if (typeof value !== "number" || !Number.isFinite(value)) return `${name} must be a finite number`;
30286
+ if (tunable.integer && !Number.isInteger(value)) return `${name} must be a whole number`;
30287
+ if (tunable.min !== void 0 && value < tunable.min) return `${name} must be at least ${tunable.min}`;
30288
+ if (tunable.max !== void 0 && value > tunable.max) return `${name} must be at most ${tunable.max}`;
30289
+ return null;
30290
+ }
30291
+ }
30292
+ function collectLeaves(obj, prefix = []) {
30293
+ return Object.entries(obj).flatMap(([k, v]) => v !== null && typeof v === "object" && !Array.isArray(v) ? collectLeaves(v, [...prefix, k]) : [{
30294
+ key: [...prefix, k].join("."),
30295
+ value: v
30296
+ }]);
30297
+ }
30298
+ /**
30299
+ * Validates a partial profile or look against the descriptors. Every leaf must
30300
+ * name a known setting and hold an accepted value. Returns the messages of all
30301
+ * problems found; an empty list means the patch is valid.
30302
+ */
30303
+ function validateBrushTunablePatch(scope, patch) {
30304
+ if (patch === null || typeof patch !== "object" || Array.isArray(patch)) return [`A ${scope} change must be an object`];
30305
+ const errors = [];
30306
+ for (const { key, value } of collectLeaves(patch)) {
30307
+ const tunable = getBrushTunable(scope, key);
30308
+ if (!tunable) {
30309
+ errors.push(`Unknown ${scope} setting "${key}"`);
30310
+ continue;
30311
+ }
30312
+ const error = validateBrushTunableValue(tunable, value);
30313
+ if (error) errors.push(error);
30314
+ }
30315
+ return errors;
30316
+ }
30317
+ //#endregion
30318
+ //#region src/actions/brush-tool/tunables/merge.ts
30319
+ const isPlain = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
30320
+ /** Deep clone of plain data. */
30321
+ function cloneData(value) {
30322
+ if (Array.isArray(value)) return value.map(cloneData);
30323
+ if (isPlain(value)) return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, cloneData(v)]));
30324
+ return value;
30325
+ }
30326
+ /** Returns a new object: `base` with every defined value of `patch` merged in. */
30327
+ function mergeData(base, patch) {
30328
+ const result = cloneData(base);
30329
+ if (!isPlain(patch)) return result;
30330
+ for (const [k, v] of Object.entries(patch)) {
30331
+ if (v === void 0) continue;
30332
+ result[k] = isPlain(v) && isPlain(result[k]) ? mergeData(result[k], v) : cloneData(v);
30333
+ }
30334
+ return result;
30335
+ }
30336
+ /** Freezes plain data recursively. */
30337
+ function deepFreeze(value) {
30338
+ if (isPlain(value) || Array.isArray(value)) {
30339
+ for (const v of Object.values(value)) deepFreeze(v);
30340
+ Object.freeze(value);
30341
+ }
30342
+ return value;
30343
+ }
30344
+ //#endregion
30345
+ //#region src/actions/brush-tool/tunables/presets.ts
30346
+ const FIXES = {
30347
+ capture: {
30348
+ source: "coalesced",
30349
+ clock: "event"
30350
+ },
30351
+ pressure: { zero: "keep" }
30352
+ };
30353
+ const withFixes = (patch) => mergeData(FIXES, patch);
30354
+ const PEN_SMOOTH = withFixes({
30355
+ pressure: {
30356
+ filter: "oneEuro",
30357
+ oneEuro: {
30358
+ minCutoff: 1.5,
30359
+ beta: .02
30360
+ },
30361
+ start: "average"
30362
+ },
30363
+ stabilizer: {
30364
+ type: "oneEuro",
30365
+ minCutoff: 6,
30366
+ beta: .05,
30367
+ zoomAware: true
30368
+ },
30369
+ finalize: {
30370
+ mode: "bezierFit",
30371
+ toleranceUnit: "screen",
30372
+ tolerance: 2,
30373
+ resampleSpacingPx: 8
30374
+ }
30375
+ });
30376
+ const PEN_ILLUSTRATOR = withFixes({
30377
+ pressure: {
30378
+ filter: "timeEma",
30379
+ timeConstantMs: 30,
30380
+ start: "average"
30381
+ },
30382
+ stabilizer: {
30383
+ type: "streamline",
30384
+ amount: .25,
30385
+ zoomAware: true
30386
+ },
30387
+ finalize: {
30388
+ mode: "bezierFit",
30389
+ toleranceUnit: "screen",
30390
+ tolerance: 2,
30391
+ resampleSpacingPx: 8
30392
+ }
30393
+ });
30394
+ const TOUCH_GUARD = withFixes({ touchGuard: {
30395
+ waitMs: 100,
30396
+ movePx: 6,
30397
+ cancelOnSecondTouch: true
30398
+ } });
30399
+ const legacy = () => ({
30400
+ id: "legacy",
30401
+ label: "Legacy",
30402
+ description: "Behaviour of earlier releases, point for point.",
30403
+ patch: {}
30404
+ });
30405
+ const fixed = () => ({
30406
+ id: "fixed",
30407
+ label: "Fixed",
30408
+ description: "Keeps every pen sample, uses event time, keeps zero pressure.",
30409
+ patch: FIXES
30410
+ });
30411
+ const penPresets = () => [
30412
+ legacy(),
30413
+ fixed(),
30414
+ {
30415
+ id: "smooth",
30416
+ label: "Smooth",
30417
+ description: "Fixed, plus One Euro smoothing of pressure and position and a Bézier-fitted finish.",
30418
+ patch: PEN_SMOOTH
30419
+ },
30420
+ {
30421
+ id: "illustrator",
30422
+ label: "Illustrator-like",
30423
+ description: "Fixed, plus streamline stabilizing and a Bézier-fitted finish in screen pixels.",
30424
+ patch: PEN_ILLUSTRATOR
30425
+ }
30426
+ ];
30427
+ const RAW_PROFILE_PRESETS = {
30428
+ mouse: [
30429
+ legacy(),
30430
+ fixed(),
30431
+ {
30432
+ id: "stabilized",
30433
+ label: "Stabilized",
30434
+ description: "Fixed, plus One Euro position smoothing that follows the zoom and a Bézier-fitted finish.",
30435
+ patch: withFixes({
30436
+ stabilizer: {
30437
+ type: "oneEuro",
30438
+ minCutoff: 2,
30439
+ beta: .01,
30440
+ zoomAware: true
30441
+ },
30442
+ finalize: {
30443
+ mode: "bezierFit",
30444
+ toleranceUnit: "screen",
30445
+ tolerance: 2,
30446
+ resampleSpacingPx: 8
30447
+ }
30448
+ })
30449
+ },
30450
+ {
30451
+ id: "pulledString",
30452
+ label: "Pulled string",
30453
+ description: "Fixed, plus a pulled-string stabilizer.",
30454
+ patch: withFixes({ stabilizer: {
30455
+ type: "pulledString",
30456
+ radiusPx: 6,
30457
+ zoomAware: true
30458
+ } })
30459
+ }
30460
+ ],
30461
+ touch: [
30462
+ legacy(),
30463
+ fixed(),
30464
+ {
30465
+ id: "guarded",
30466
+ label: "Guarded",
30467
+ description: "Fixed, plus a short wait that a second finger can cancel.",
30468
+ patch: TOUCH_GUARD
30469
+ },
30470
+ {
30471
+ id: "smooth",
30472
+ label: "Smooth",
30473
+ description: "Guarded, plus One Euro position smoothing and a Bézier-fitted finish.",
30474
+ patch: mergeData(TOUCH_GUARD, {
30475
+ stabilizer: {
30476
+ type: "oneEuro",
30477
+ minCutoff: 3,
30478
+ beta: .02,
30479
+ zoomAware: true
30480
+ },
30481
+ finalize: {
30482
+ mode: "bezierFit",
30483
+ toleranceUnit: "screen",
30484
+ tolerance: 2,
30485
+ resampleSpacingPx: 8
30486
+ }
30487
+ })
30488
+ }
30489
+ ],
30490
+ "pen.applePencil": penPresets(),
30491
+ "pen.desktop": penPresets()
30492
+ };
30493
+ const WIDTH_CURVE = { width: {
30494
+ min: .2,
30495
+ gamma: .8
30496
+ } };
30497
+ const ROUND = {
30498
+ ...WIDTH_CURVE,
30499
+ outline: "round",
30500
+ caps: "merged",
30501
+ curve: "centripetal",
30502
+ pressureInterp: "linear",
30503
+ zoomAware: true
30504
+ };
30505
+ const RAW_LOOK_PRESETS = [
30506
+ {
30507
+ id: "legacy",
30508
+ label: "Legacy",
30509
+ description: "No look is stored: strokes are drawn as in earlier releases.",
30510
+ patch: null
30511
+ },
30512
+ {
30513
+ id: "widthCurve",
30514
+ label: "Width curve",
30515
+ description: "Legacy outline with a minimum width and a pressure curve.",
30516
+ patch: WIDTH_CURVE
30517
+ },
30518
+ {
30519
+ id: "round",
30520
+ label: "Round",
30521
+ description: "Round outline: no spikes at turns, uniform translucent colour.",
30522
+ patch: ROUND
30523
+ },
30524
+ {
30525
+ id: "freehand",
30526
+ label: "Freehand",
30527
+ description: "Freehand outline.",
30528
+ patch: {
30529
+ ...WIDTH_CURVE,
30530
+ outline: "freehand",
30531
+ caps: "merged",
30532
+ zoomAware: true
30533
+ }
30534
+ },
30535
+ {
30536
+ id: "illustrator",
30537
+ label: "Illustrator-like",
30538
+ description: "Round outline with a Size ± Variation style width range.",
30539
+ patch: {
30540
+ ...ROUND,
30541
+ width: {
30542
+ min: .4,
30543
+ max: 1,
30544
+ gamma: 1
30545
+ }
30546
+ }
30547
+ }
30548
+ ];
30549
+ const RAW_SCENARIOS = [
30550
+ {
30551
+ id: "A",
30552
+ label: "A: legacy",
30553
+ description: "Today's behaviour, identical point for point.",
30554
+ profiles: {
30555
+ mouse: "legacy",
30556
+ touch: "legacy",
30557
+ "pen.applePencil": "legacy",
30558
+ "pen.desktop": "legacy"
30559
+ },
30560
+ look: "legacy"
30561
+ },
30562
+ {
30563
+ id: "B",
30564
+ label: "B: fixes",
30565
+ description: "Every pen sample, event time and zero pressure kept.",
30566
+ profiles: {
30567
+ mouse: "fixed",
30568
+ touch: "fixed",
30569
+ "pen.applePencil": "fixed",
30570
+ "pen.desktop": "fixed"
30571
+ },
30572
+ look: "legacy"
30573
+ },
30574
+ {
30575
+ id: "C",
30576
+ label: "C: smooth",
30577
+ description: "B plus smoothing and a pressure curve.",
30578
+ profiles: {
30579
+ mouse: "stabilized",
30580
+ touch: "smooth",
30581
+ "pen.applePencil": "smooth",
30582
+ "pen.desktop": "smooth"
30583
+ },
30584
+ look: "widthCurve"
30585
+ },
30586
+ {
30587
+ id: "D",
30588
+ label: "D: round",
30589
+ description: "C plus the round outline.",
30590
+ profiles: {
30591
+ mouse: "stabilized",
30592
+ touch: "smooth",
30593
+ "pen.applePencil": "smooth",
30594
+ "pen.desktop": "smooth"
30595
+ },
30596
+ look: "round"
30597
+ },
30598
+ {
30599
+ id: "E",
30600
+ label: "E: freehand",
30601
+ description: "B plus the freehand outline.",
30602
+ profiles: {
30603
+ mouse: "fixed",
30604
+ touch: "fixed",
30605
+ "pen.applePencil": "fixed",
30606
+ "pen.desktop": "fixed"
30607
+ },
30608
+ look: "freehand"
30609
+ },
30610
+ {
30611
+ id: "F",
30612
+ label: "F: Illustrator-like",
30613
+ description: "Bézier-fitted finish, streamline stabilizing and a Size ± Variation width.",
30614
+ profiles: {
30615
+ mouse: "stabilized",
30616
+ touch: "smooth",
30617
+ "pen.applePencil": "illustrator",
30618
+ "pen.desktop": "illustrator"
30619
+ },
30620
+ look: "illustrator"
30621
+ }
30622
+ ];
30623
+ /** Factory profile presets per profile. Read-only. */
30624
+ const BRUSH_PROFILE_PRESETS = deepFreeze(RAW_PROFILE_PRESETS);
30625
+ /** Factory look presets. Read-only. */
30626
+ const BRUSH_LOOK_PRESETS = deepFreeze(RAW_LOOK_PRESETS);
30627
+ /** Factory scenarios. Read-only. */
30628
+ const BRUSH_SCENARIOS = deepFreeze(RAW_SCENARIOS);
30629
+ /** Full profile configuration of a preset: the legacy profile with its patch. */
30630
+ function resolveBrushProfilePatch(patch) {
30631
+ return mergeData(BRUSH_LEGACY_INPUT_PROFILE, patch);
30632
+ }
30633
+ /** Full look of a preset, or `null` when the preset stores no look. */
30634
+ function resolveBrushLookPatch(patch) {
30635
+ return patch === null ? null : mergeData(WEAVE_STROKE_LEGACY_LOOK, patch);
30636
+ }
30637
+ function getBrushScenario(id) {
30638
+ return BRUSH_SCENARIOS.find((s) => s.id === id);
30639
+ }
30640
+ /**
30641
+ * Registry of profile and look presets: the read-only factory presets plus
30642
+ * custom ones. Factory presets cannot be changed or removed; a copy under a
30643
+ * new name can be made and edited. Custom presets live in memory; the
30644
+ * application decides whether to keep them.
30645
+ */
30646
+ var WeaveBrushPresetRegistry = class WeaveBrushPresetRegistry {
30647
+ customProfiles = /* @__PURE__ */ new Map();
30648
+ customLooks = /* @__PURE__ */ new Map();
30649
+ static profileKey(key, id) {
30650
+ return `${key}/${id}`;
30651
+ }
30652
+ listProfilePresets(key) {
30653
+ const custom = [...this.customProfiles.entries()].filter(([k]) => k.startsWith(`${key}/`)).map(([, v]) => v);
30654
+ return [...BRUSH_PROFILE_PRESETS[key], ...custom];
30655
+ }
30656
+ getProfilePreset(key, id) {
30657
+ return this.listProfilePresets(key).find((p) => p.id === id);
30658
+ }
30659
+ isFactoryProfilePreset(key, id) {
30660
+ return BRUSH_PROFILE_PRESETS[key].some((p) => p.id === id);
30661
+ }
30662
+ /** Changes a custom profile preset; a factory preset is rejected. */
30663
+ setProfilePreset(key, id, patch) {
30664
+ if (this.isFactoryProfilePreset(key, id)) return {
30665
+ ok: false,
30666
+ error: `"${id}" is a factory preset and cannot be changed; copy it first`
30667
+ };
30668
+ const existing = this.customProfiles.get(WeaveBrushPresetRegistry.profileKey(key, id));
30669
+ if (!existing) return {
30670
+ ok: false,
30671
+ error: `Unknown preset "${id}"`
30672
+ };
30673
+ this.customProfiles.set(WeaveBrushPresetRegistry.profileKey(key, id), {
30674
+ ...existing,
30675
+ patch: mergeData(existing.patch, patch)
30676
+ });
30677
+ return { ok: true };
30678
+ }
30679
+ /** Copies a preset under a new id. */
30680
+ copyProfilePreset(key, fromId, newId, label = newId) {
30681
+ const from = this.getProfilePreset(key, fromId);
30682
+ if (!from) return {
30683
+ ok: false,
30684
+ error: `Unknown preset "${fromId}"`
30685
+ };
30686
+ if (this.getProfilePreset(key, newId)) return {
30687
+ ok: false,
30688
+ error: `A preset "${newId}" already exists`
30689
+ };
30690
+ this.customProfiles.set(WeaveBrushPresetRegistry.profileKey(key, newId), {
30691
+ id: newId,
30692
+ label,
30693
+ description: `Copy of ${from.label}`,
30694
+ patch: mergeData(from.patch, {})
30695
+ });
30696
+ return { ok: true };
30697
+ }
30698
+ removeProfilePreset(key, id) {
30699
+ if (this.isFactoryProfilePreset(key, id)) return {
30700
+ ok: false,
30701
+ error: `"${id}" is a factory preset and cannot be removed`
30702
+ };
30703
+ return this.customProfiles.delete(WeaveBrushPresetRegistry.profileKey(key, id)) ? { ok: true } : {
30704
+ ok: false,
30705
+ error: `Unknown preset "${id}"`
30706
+ };
30707
+ }
30708
+ listLookPresets() {
30709
+ return [...BRUSH_LOOK_PRESETS, ...this.customLooks.values()];
30710
+ }
30711
+ getLookPreset(id) {
30712
+ return this.listLookPresets().find((p) => p.id === id);
30713
+ }
30714
+ isFactoryLookPreset(id) {
30715
+ return BRUSH_LOOK_PRESETS.some((p) => p.id === id);
30716
+ }
30717
+ /** Changes a custom look preset; a factory preset is rejected. */
30718
+ setLookPreset(id, patch) {
30719
+ if (this.isFactoryLookPreset(id)) return {
30720
+ ok: false,
30721
+ error: `"${id}" is a factory preset and cannot be changed; copy it first`
30722
+ };
30723
+ const existing = this.customLooks.get(id);
30724
+ if (!existing) return {
30725
+ ok: false,
30726
+ error: `Unknown preset "${id}"`
30727
+ };
30728
+ this.customLooks.set(id, {
30729
+ ...existing,
30730
+ patch: mergeData(existing.patch ?? {}, patch)
30731
+ });
30732
+ return { ok: true };
30733
+ }
30734
+ copyLookPreset(fromId, newId, label = newId) {
30735
+ const from = this.getLookPreset(fromId);
30736
+ if (!from) return {
30737
+ ok: false,
30738
+ error: `Unknown preset "${fromId}"`
30739
+ };
30740
+ if (this.getLookPreset(newId)) return {
30741
+ ok: false,
30742
+ error: `A preset "${newId}" already exists`
30743
+ };
30744
+ this.customLooks.set(newId, {
30745
+ id: newId,
30746
+ label,
30747
+ description: `Copy of ${from.label}`,
30748
+ patch: from.patch === null ? null : mergeData(from.patch, {})
30749
+ });
30750
+ return { ok: true };
30751
+ }
30752
+ removeLookPreset(id) {
30753
+ if (this.isFactoryLookPreset(id)) return {
30754
+ ok: false,
30755
+ error: `"${id}" is a factory preset and cannot be removed`
30756
+ };
30757
+ return this.customLooks.delete(id) ? { ok: true } : {
30758
+ ok: false,
30759
+ error: `Unknown preset "${id}"`
30760
+ };
30761
+ }
30762
+ };
30763
+ //#endregion
30764
+ //#region src/actions/brush-tool/tunables/config-io.ts
30765
+ /** Version of the exported configuration document. */
30766
+ const BRUSH_CONFIG_DOCUMENT_VERSION = 1;
30767
+ const isObject$1 = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
30768
+ /**
30769
+ * Reads and validates a configuration document, given as an object or as JSON
30770
+ * text. Nothing is applied: the result holds fully resolved values, or every
30771
+ * problem found.
30772
+ */
30773
+ function parseBrushConfigDocument(input) {
30774
+ let doc = input;
30775
+ if (typeof input === "string") try {
30776
+ doc = JSON.parse(input);
30777
+ } catch {
30778
+ return {
30779
+ ok: false,
30780
+ errors: ["The document is not valid JSON"]
30781
+ };
30782
+ }
30783
+ if (!isObject$1(doc)) return {
30784
+ ok: false,
30785
+ errors: ["The document must be an object"]
30786
+ };
30787
+ if (doc["version"] !== 1) return {
30788
+ ok: false,
30789
+ errors: [`Unsupported document version "${String(doc["version"])}"; expected 1`]
30790
+ };
30791
+ const errors = [];
30792
+ const profiles = {};
30793
+ if (doc["profiles"] !== void 0) {
30794
+ if (!isObject$1(doc["profiles"])) errors.push("\"profiles\" must be an object");
30795
+ else for (const [key, value] of Object.entries(doc["profiles"])) {
30796
+ if (!BRUSH_INPUT_PROFILE_KEYS.includes(key)) {
30797
+ errors.push(`Unknown profile "${key}"`);
30798
+ continue;
30799
+ }
30800
+ const problems = validateBrushTunablePatch("profile", value);
30801
+ if (problems.length > 0) errors.push(...problems.map((p) => `${key}: ${p}`));
30802
+ else profiles[key] = resolveBrushProfilePatch(value);
30803
+ }
30804
+ }
30805
+ let look;
30806
+ if (doc["look"] !== void 0) {
30807
+ if (doc["look"] === null) look = null;
30808
+ else if (!isObject$1(doc["look"])) errors.push("\"look\" must be an object or null");
30809
+ else {
30810
+ const { version, ...settings } = doc["look"];
30811
+ if (version !== WEAVE_STROKE_LEGACY_LOOK.version) errors.push(`Unsupported look version "${String(version)}"`);
30812
+ else {
30813
+ const problems = validateBrushTunablePatch("look", settings);
30814
+ if (problems.length > 0) errors.push(...problems.map((p) => `look: ${p}`));
30815
+ else look = mergeData(WEAVE_STROKE_LEGACY_LOOK, settings);
30816
+ }
30817
+ }
30818
+ }
30819
+ return errors.length > 0 ? {
30820
+ ok: false,
30821
+ errors
30822
+ } : {
30823
+ ok: true,
30824
+ profiles,
30825
+ look
30826
+ };
30827
+ }
30828
+ //#endregion
30829
+ //#region src/actions/brush-tool/brush-tool.ts
30830
+ var WeaveBrushToolAction = class extends WeaveAction {
30831
+ config;
30832
+ initialized = false;
30833
+ state;
30834
+ clickPoint;
30835
+ strokeId;
30836
+ isSpacePressed = false;
30837
+ isEraser;
30838
+ container;
30839
+ measureContainer;
30840
+ cancelAction;
30841
+ prevTouchAction;
30842
+ penActive = false;
30843
+ lastSmoothedPressure = .5;
30844
+ hasPressureSample = false;
30845
+ lastPointerPos = null;
30846
+ lastPointerTime = 0;
30847
+ predictedCount = 0;
30848
+ activeProfile;
30849
+ activeProfileKey = "mouse";
30850
+ look = null;
30851
+ captureState = createCaptureState();
30852
+ pendingTouch = null;
30853
+ /** Preset each profile currently holds: a preset id, `custom` or `imported`. */
30854
+ presetIds = {
30855
+ mouse: "legacy",
30856
+ touch: "legacy",
30857
+ "pen.applePencil": "legacy",
30858
+ "pen.desktop": "legacy"
30859
+ };
30860
+ /** Scenario applied last, until a profile is changed on its own. */
30861
+ scenarioId = null;
30862
+ /** Pointer that is drawing the stroke in progress. */
30863
+ strokePointerId = null;
30864
+ /** A pen has been used since the tool was activated. */
30865
+ penSeen = false;
30866
+ /** Last position the pen reported, before any stabilizing. */
30867
+ lastRawPoint = null;
30868
+ stabilizer;
30869
+ pressureStateView;
30870
+ onPropsChange = void 0;
30871
+ onInit = void 0;
30872
+ constructor(params) {
30873
+ super();
30874
+ this.config = mergeExceptArrays(BRUSH_TOOL_DEFAULT_CONFIG, params?.config ?? {});
30875
+ for (const key of Object.keys(params?.config?.input?.profiles ?? {})) this.presetIds[key] = "custom";
30876
+ this.initialize();
30877
+ }
30878
+ initialize() {
30879
+ this.initialized = false;
30880
+ this.state = BRUSH_TOOL_STATE.INACTIVE;
30881
+ this.strokeId = null;
30882
+ this.clickPoint = null;
30883
+ this.container = void 0;
30884
+ this.isEraser = false;
30885
+ this.measureContainer = void 0;
30886
+ this.props = this.initProps();
30887
+ this.isSpacePressed = false;
30888
+ this.setActiveProfile("mouse");
30889
+ }
30890
+ setActiveProfile(key) {
30891
+ this.activeProfileKey = key;
30892
+ this.activeProfile = this.config.input.profiles[key];
30893
+ this.stabilizer = createStabilizer(this.activeProfile.stabilizer);
30894
+ }
30895
+ /** Key of the profile used by the stroke in progress, or the last one. */
30896
+ getActiveProfileKey() {
30897
+ return this.activeProfileKey;
30898
+ }
30899
+ /** Preset a profile currently holds: a preset id, `custom` or `imported`. */
30900
+ getPresetId(key) {
30901
+ return this.presetIds[key];
30902
+ }
30903
+ /** Scenario applied last, or `null` once a profile was changed on its own. */
30904
+ getScenarioId() {
30905
+ return this.scenarioId;
30906
+ }
30907
+ /**
30908
+ * How a stroke was drawn, recorded on it only while the lab plugin is
30909
+ * active, so ordinary documents carry no such data.
30910
+ */
30911
+ getInputMeta() {
30912
+ const lab = this.instance.getPlugin(BRUSH_LAB_PLUGIN_NAME);
30913
+ if (!lab || lab.isEnabled?.() === false) return void 0;
30914
+ return {
30915
+ profile: this.activeProfileKey,
30916
+ preset: this.presetIds[this.activeProfileKey],
30917
+ ...this.scenarioId ? { scenario: this.scenarioId } : {}
30918
+ };
30919
+ }
30920
+ /** The lab plugin, when it is registered and on; it only listens. */
30921
+ getTraceSink() {
30922
+ const lab = this.instance.getPlugin(BRUSH_LAB_PLUGIN_NAME);
30923
+ if (!lab || lab.isEnabled?.() === false) return null;
30924
+ return typeof lab.onSample === "function" ? lab : null;
30925
+ }
30926
+ /** Current configuration of a profile (a copy). */
30927
+ getInputProfile(key) {
30928
+ return mergeExceptArrays(this.config.input.profiles[key], {});
30929
+ }
30930
+ /**
30931
+ * Changes a profile's configuration. The change applies from the next
30932
+ * stroke: the profile object is replaced, never mutated, so a stroke in
30933
+ * progress keeps the configuration it started with.
30934
+ */
30935
+ setInputProfile(key, patch) {
30936
+ if (!(key in this.config.input.profiles)) return {
30937
+ applied: false,
30938
+ errors: [`Unknown profile "${key}"`]
30939
+ };
30940
+ const errors = validateBrushTunablePatch("profile", patch);
30941
+ if (errors.length > 0) return {
30942
+ applied: false,
30943
+ errors
30944
+ };
30945
+ this.config.input.profiles[key] = mergeExceptArrays(this.config.input.profiles[key], patch);
30946
+ this.presetIds[key] = "custom";
30947
+ this.scenarioId = null;
30948
+ return {
30949
+ applied: true,
30950
+ errors: []
30951
+ };
30952
+ }
30953
+ /** The profiles and the look as a portable, versioned document. */
30954
+ exportConfig() {
30955
+ return {
30956
+ version: 1,
30957
+ profiles: {
30958
+ mouse: this.getInputProfile("mouse"),
30959
+ touch: this.getInputProfile("touch"),
30960
+ "pen.applePencil": this.getInputProfile("pen.applePencil"),
30961
+ "pen.desktop": this.getInputProfile("pen.desktop")
30962
+ },
30963
+ look: this.getLook()
30964
+ };
30965
+ }
30966
+ /** The configuration as JSON text. */
30967
+ exportConfigJson() {
30968
+ return JSON.stringify(this.exportConfig(), null, 2);
30969
+ }
30970
+ /**
30971
+ * Restores a configuration exported with `exportConfig`, given as an object
30972
+ * or JSON text. A document with an unsupported version or invalid values is
30973
+ * rejected as a whole and nothing changes.
30974
+ */
30975
+ importConfig(input) {
30976
+ const parsed = parseBrushConfigDocument(input);
30977
+ if (!parsed.ok) return {
30978
+ applied: false,
30979
+ errors: parsed.errors
30980
+ };
30981
+ Object.assign(this.config.input.profiles, parsed.profiles);
30982
+ for (const key of Object.keys(parsed.profiles)) this.presetIds[key] = "imported";
30983
+ this.scenarioId = null;
30984
+ if (parsed.look !== void 0) this.look = parsed.look;
30985
+ return {
30986
+ applied: true,
30987
+ errors: []
30988
+ };
30989
+ }
30990
+ /** Look saved on every new stroke, or `null` when none is saved. */
30991
+ getLook() {
30992
+ return this.look ? mergeData(this.look, {}) : null;
30993
+ }
30994
+ /**
30995
+ * Changes the look saved on new strokes, or clears it with `null` so new
30996
+ * strokes carry no look and are drawn as in earlier releases. Strokes
30997
+ * already drawn keep the look they were saved with.
30998
+ */
30999
+ setLook(patch) {
31000
+ if (patch === null) {
31001
+ this.look = null;
31002
+ return {
31003
+ applied: true,
31004
+ errors: []
31005
+ };
31006
+ }
31007
+ const errors = validateBrushTunablePatch("look", patch);
31008
+ if (errors.length > 0) return {
31009
+ applied: false,
31010
+ errors
31011
+ };
31012
+ this.look = mergeData(this.look ?? WEAVE_STROKE_LEGACY_LOOK, patch);
31013
+ return {
31014
+ applied: true,
31015
+ errors: []
31016
+ };
31017
+ }
31018
+ /**
31019
+ * Applies a scenario: one preset for each profile and one look preset.
31020
+ * Custom presets are looked up in `registry` when given.
31021
+ */
31022
+ applyScenario(scenario, registry = new WeaveBrushPresetRegistry()) {
31023
+ const resolved = typeof scenario === "string" ? getBrushScenario(scenario) : scenario;
31024
+ if (!resolved) return {
31025
+ applied: false,
31026
+ errors: [`Unknown scenario "${scenario}"`]
31027
+ };
31028
+ const errors = [];
31029
+ const profiles = {};
31030
+ for (const key of Object.keys(this.config.input.profiles)) {
31031
+ const preset = registry.getProfilePreset(key, resolved.profiles[key]);
31032
+ if (!preset) {
31033
+ errors.push(`Unknown preset "${resolved.profiles[key]}" for profile "${key}"`);
31034
+ continue;
31035
+ }
31036
+ profiles[key] = resolveBrushProfilePatch(preset.patch);
31037
+ }
31038
+ const lookPreset = registry.getLookPreset(resolved.look);
31039
+ if (!lookPreset) errors.push(`Unknown look preset "${resolved.look}"`);
31040
+ if (errors.length > 0 || !lookPreset) return {
31041
+ applied: false,
31042
+ errors
31043
+ };
31044
+ Object.assign(this.config.input.profiles, profiles);
31045
+ this.presetIds = { ...resolved.profiles };
31046
+ this.scenarioId = resolved.id;
31047
+ this.look = resolveBrushLookPatch(lookPreset.patch);
31048
+ return {
31049
+ applied: true,
31050
+ errors: []
31051
+ };
31052
+ }
31053
+ /** Forces a profile for every stroke, or `null` to resolve automatically. */
31054
+ setForcedProfile(key) {
31055
+ this.config.input.forceProfile = key;
31056
+ }
31057
+ /** Replaces the automatic profile resolution; `undefined` restores it. */
31058
+ setProfileResolver(resolver) {
31059
+ this.config.input.resolveProfile = resolver;
31060
+ }
31061
+ /**
31062
+ * The pressure-smoothing state, as a view over this action's own fields so
31063
+ * the stage functions mutate the same state the action exposes.
31064
+ */
31065
+ get pressureState() {
31066
+ if (!this.pressureStateView) {
31067
+ const self = this;
31068
+ this.pressureStateView = {
31069
+ get lastSmoothedPressure() {
31070
+ return self.lastSmoothedPressure;
31071
+ },
31072
+ set lastSmoothedPressure(v) {
31073
+ self.lastSmoothedPressure = v;
31074
+ },
31075
+ get hasPressureSample() {
31076
+ return self.hasPressureSample;
31077
+ },
31078
+ set hasPressureSample(v) {
31079
+ self.hasPressureSample = v;
31080
+ },
31081
+ get lastPointerPos() {
31082
+ return self.lastPointerPos;
31083
+ },
31084
+ set lastPointerPos(v) {
31085
+ self.lastPointerPos = v;
31086
+ },
31087
+ get lastPointerTime() {
31088
+ return self.lastPointerTime;
31089
+ },
31090
+ set lastPointerTime(v) {
31091
+ self.lastPointerTime = v;
31092
+ },
31093
+ get predictedCount() {
31094
+ return self.predictedCount;
31095
+ },
31096
+ set predictedCount(v) {
31097
+ self.predictedCount = v;
31098
+ },
31099
+ lastDerivative: 0,
31100
+ lastNonZeroPressure: null
31101
+ };
31102
+ }
31103
+ return this.pressureStateView;
31104
+ }
31105
+ getName() {
31106
+ return BRUSH_TOOL_ACTION_NAME;
31107
+ }
31108
+ initProps() {
31109
+ return {
31110
+ stroke: "#000000ff",
31111
+ strokeWidth: 1,
31112
+ opacity: 1
31113
+ };
31114
+ }
31115
+ /**
31116
+ * Smooths one raw pressure sample with the filter of the active profile.
31117
+ *
31118
+ * This is the one place pressure smoothing happens: every sample that
31119
+ * becomes a stroke point, including the high-frequency coalesced samples a
31120
+ * stylus reports, goes through it so the smoothing state advances for later
31121
+ * samples.
31122
+ */
31123
+ smoothPressureSample(raw, clientX, clientY, time) {
31124
+ return smoothPressure(this.pressureState, this.activeProfile.pressure, raw, clientX, clientY, this.sampleTime(time));
31125
+ }
31126
+ /**
31127
+ * Time of a sample, in ms. With the `event` clock it is the time the sample's
31128
+ * own event reported, so processing a batch in one step does not change the
31129
+ * time between its samples; otherwise (or when the event reports no usable
31130
+ * time) it is the time of processing.
31131
+ */
31132
+ sampleTime(eventTime) {
31133
+ if (this.activeProfile.capture.clock === "event" && typeof eventTime === "number" && Number.isFinite(eventTime)) return eventTime;
31134
+ return performance.now();
31135
+ }
31136
+ /**
28279
31137
  * Raw pressure a pointer sample reports: the device's own reading for a
28280
31138
  * pen, a neutral constant for devices that do not sense pressure.
28281
31139
  */
28282
- getRawPressure(pointerType, pressure) {
28283
- if (pointerType === "pen") return (typeof pressure === "number" ? pressure : 0) || .5;
28284
- return .5;
31140
+ getRawPressure(pointerType, pressure, commit = true) {
31141
+ return getRawPressure(pointerType, pressure, this.activeProfile.pressure.zero, this.pressureState, commit);
28285
31142
  }
28286
31143
  getEventPressure(e) {
28287
- return this.smoothPressureSample(this.getRawPressure(e.evt.pointerType, e.evt.pressure), e.evt.clientX, e.evt.clientY);
31144
+ return this.smoothPressureSample(this.getRawPressure(e.evt.pointerType, e.evt.pressure), e.evt.clientX, e.evt.clientY, e.evt.timeStamp);
28288
31145
  }
28289
31146
  /**
28290
- * Resets the pressure-smoothing state (smoothed pressure, last known pointer
28291
- * position/time, and predicted-point bookkeeping) to a neutral baseline.
28292
- *
28293
- * This state is scoped to a single stroke: it must be reset before the
28294
- * first pressure sample of a new stroke is computed (in `handlePointerDown`,
28295
- * ahead of the `getEventPressure` call for point 0), not only afterwards -
28296
- * otherwise that first sample can inherit smoothing state left behind by
28297
- * the previous stroke (its ending pressure, pointer position and time),
28298
- * inflating the very first point's pressure.
31147
+ * Resets the stroke-scoped state (pressure smoothing, predicted-point
31148
+ * bookkeeping and the position stabilizer) to a neutral baseline.
31149
+ *
31150
+ * This state belongs to a single stroke: it must be reset before the first
31151
+ * pressure sample of a new stroke is computed (in `handlePointerDown`, ahead
31152
+ * of the `getEventPressure` call for point 0), not only afterwards -
31153
+ * otherwise that first sample can inherit state left behind by the previous
31154
+ * stroke, inflating the very first point's pressure.
28299
31155
  */
28300
31156
  resetPressureSmoothingState() {
28301
- this.lastSmoothedPressure = .5;
28302
- this.hasPressureSample = false;
28303
- this.lastPointerPos = null;
28304
- this.lastPointerTime = 0;
28305
- this.predictedCount = 0;
31157
+ resetPressureState(this.pressureState);
31158
+ this.captureState = createCaptureState();
31159
+ this.lastRawPoint = null;
31160
+ this.stabilizer.reset();
31161
+ }
31162
+ /**
31163
+ * Maps a client position to the stroke container the same way the stage maps
31164
+ * its own pointer position (content rectangle, then the inverse of the
31165
+ * container's absolute transform), without changing any stage state.
31166
+ */
31167
+ clientToContainer(clientX, clientY) {
31168
+ const content = this.instance.getStage().getContent();
31169
+ const rect = content.getBoundingClientRect();
31170
+ const scaleX = rect.width / content.clientWidth || 1;
31171
+ const scaleY = rect.height / content.clientHeight || 1;
31172
+ const transform = this.measureContainer.getAbsoluteTransform().copy();
31173
+ transform.invert();
31174
+ return transform.point({
31175
+ x: (clientX - rect.left) / scaleX,
31176
+ y: (clientY - rect.top) / scaleY
31177
+ });
31178
+ }
31179
+ /** Captures the samples of one pointer move into the stroke in progress. */
31180
+ handleMoveEvent(e) {
31181
+ const deps = {
31182
+ smooth: (raw, clientX, clientY, time) => this.smoothPressureSample(raw, clientX, clientY, time),
31183
+ rawPressure: (pointerType, pressure, commit) => this.getRawPressure(pointerType, pressure, commit),
31184
+ predictedPressure: (raw) => peekPredictedPressure(this.pressureState, this.activeProfile.pressure, raw)
31185
+ };
31186
+ if (this.activeProfile.capture.source === "coalesced") this.applyMovementSamples(captureCoalescedSamples(e.evt, {
31187
+ ...deps,
31188
+ toContainerPoint: (clientX, clientY) => this.clientToContainer(clientX, clientY)
31189
+ }, this.activeProfile.capture, this.captureState));
31190
+ else {
31191
+ const samples = captureLegacySamples(e.evt, deps);
31192
+ if (samples) this.applyMovementSamples(samples);
31193
+ else {
31194
+ const pointPressure = this.getEventPressure(e);
31195
+ this.handleMovement(pointPressure, void 0, false);
31196
+ }
31197
+ }
28306
31198
  }
28307
31199
  setupEvents() {
28308
31200
  const stage = this.instance.getStage();
@@ -28329,52 +31221,65 @@ var WeaveBrushToolAction = class extends WeaveAction {
28329
31221
  }, { signal: this.instance.getEventsController().signal });
28330
31222
  const handlePointerDown = (e) => {
28331
31223
  if (this.state === BRUSH_TOOL_STATE.INACTIVE) return;
31224
+ if (this.pendingTouch && e.evt.pointerType === "touch" && e.evt.pointerId !== this.pendingTouch.pointerId) {
31225
+ if (this.activeProfile.touchGuard.cancelOnSecondTouch) this.cancelPendingTouch();
31226
+ return;
31227
+ }
28332
31228
  if (this.state !== BRUSH_TOOL_STATE.IDLE) return;
28333
31229
  if (this.getZoomPlugin()?.isPinching()) return;
28334
31230
  if (this.isSpacePressed) return;
28335
31231
  if (e?.evt?.button !== 0) return;
28336
31232
  if (e.evt.pointerType === "touch" && this.penActive) return;
28337
- if (e.evt.pointerType === "pen") this.penActive = true;
31233
+ if (e.evt.pointerType === "pen") {
31234
+ this.penActive = true;
31235
+ this.penSeen = true;
31236
+ }
31237
+ this.setActiveProfile(resolveBrushInputProfileKey({
31238
+ pointerType: e.evt.pointerType,
31239
+ platform: detectBrushPlatform(),
31240
+ persistentDeviceId: e.evt.persistentDeviceId
31241
+ }, this.config.input));
31242
+ const guard = this.activeProfile.touchGuard;
31243
+ if (e.evt.pointerType === "touch" && guard.ignoreAfterPen && this.penSeen) return;
28338
31244
  this.resetPressureSmoothingState();
28339
31245
  const pointPressure = this.getEventPressure(e);
28340
- this.handleStartStroke(pointPressure);
31246
+ this.strokePointerId = typeof e.evt.pointerId === "number" ? e.evt.pointerId : null;
31247
+ if (e.evt.pointerType === "touch" && (guard.waitMs > 0 || guard.movePx > 0)) {
31248
+ this.startPendingTouch(e, pointPressure);
31249
+ e.evt.stopPropagation();
31250
+ return;
31251
+ }
31252
+ this.handleStartStroke(pointPressure, e.evt.timeStamp);
31253
+ this.captureState.lastClient = {
31254
+ x: e.evt.clientX,
31255
+ y: e.evt.clientY
31256
+ };
28341
31257
  e.evt.stopPropagation();
28342
31258
  };
28343
31259
  stage.on("pointerdown", handlePointerDown);
28344
31260
  const handlePointerMove = (e) => {
28345
31261
  if (this.state === BRUSH_TOOL_STATE.INACTIVE) return;
28346
31262
  this.setCursor();
31263
+ if (this.pendingTouch && this.state === BRUSH_TOOL_STATE.IDLE) {
31264
+ this.handlePendingTouchMove(e);
31265
+ return;
31266
+ }
28347
31267
  if (this.state !== BRUSH_TOOL_STATE.DEFINE_STROKE) return;
28348
31268
  if (this.getZoomPlugin()?.isPinching()) return;
28349
- const coalescedEvents = e.evt.getCoalescedEvents ? e.evt.getCoalescedEvents() : [];
28350
- if (coalescedEvents.length > 1) {
28351
- const samples = coalescedEvents.map((ce) => ({
28352
- pressure: this.smoothPressureSample(this.getRawPressure(ce.pointerType, ce.pressure), ce.clientX, ce.clientY),
28353
- isPredicted: false
28354
- }));
28355
- const predictedEvents = e.evt.getPredictedEvents ? e.evt.getPredictedEvents() : [];
28356
- if (predictedEvents.length > 0) {
28357
- const last = predictedEvents[predictedEvents.length - 1];
28358
- const predPressure = Math.max(.15 * this.getRawPressure(last.pointerType, last.pressure) + .85 * this.lastSmoothedPressure, .15);
28359
- samples.push({
28360
- pressure: predPressure,
28361
- sourceEvent: last,
28362
- isPredicted: true
28363
- });
28364
- }
28365
- this.applyMovementSamples(samples);
28366
- } else {
28367
- const pointPressure = this.getEventPressure(e);
28368
- this.handleMovement(pointPressure, void 0, false);
28369
- }
31269
+ this.handleMoveEvent(e);
28370
31270
  e.evt.stopPropagation();
28371
31271
  };
28372
31272
  stage.on("pointermove", handlePointerMove);
28373
31273
  const handlePointerUp = (e) => {
28374
31274
  this.penActive = false;
31275
+ if (this.pendingTouch && this.state === BRUSH_TOOL_STATE.IDLE) {
31276
+ if (e.evt.pointerId !== this.pendingTouch.pointerId) return;
31277
+ this.commitPendingTouch();
31278
+ }
28375
31279
  if (this.state !== BRUSH_TOOL_STATE.DEFINE_STROKE) return;
28376
- if (this.getZoomPlugin()?.isPinching()) return;
31280
+ if (this.strokePointerId !== null && typeof e.evt.pointerId === "number" && e.evt.pointerId !== this.strokePointerId) return;
28377
31281
  this.handleEndStroke();
31282
+ this.strokePointerId = null;
28378
31283
  e.evt.stopPropagation();
28379
31284
  };
28380
31285
  stage.on("pointerup", handlePointerUp);
@@ -28407,9 +31312,101 @@ var WeaveBrushToolAction = class extends WeaveAction {
28407
31312
  height: maxY - minY
28408
31313
  };
28409
31314
  }
28410
- handleStartStroke(pressure) {
31315
+ /** Puts a finger stroke on hold until it is clear it is not a gesture. */
31316
+ startPendingTouch(e, pressure) {
31317
+ const guard = this.activeProfile.touchGuard;
31318
+ const pending = {
31319
+ pointerId: e.evt.pointerId,
31320
+ startClient: {
31321
+ x: e.evt.clientX,
31322
+ y: e.evt.clientY
31323
+ },
31324
+ pressure,
31325
+ time: e.evt.timeStamp,
31326
+ start: this.instance.getMousePointer(),
31327
+ samples: [],
31328
+ timer: null
31329
+ };
31330
+ if (guard.waitMs > 0) pending.timer = setTimeout(() => this.commitPendingTouch(), guard.waitMs);
31331
+ this.pendingTouch = pending;
31332
+ }
31333
+ /** Drops a waiting finger stroke without leaving a mark. */
31334
+ cancelPendingTouch() {
31335
+ if (this.pendingTouch?.timer) clearTimeout(this.pendingTouch.timer);
31336
+ this.pendingTouch = null;
31337
+ this.strokePointerId = null;
31338
+ }
31339
+ /**
31340
+ * Starts the stroke of a waiting finger at the point where it touched down,
31341
+ * then replays the moves that were held back, so the path is not lost.
31342
+ */
31343
+ commitPendingTouch() {
31344
+ const pending = this.pendingTouch;
31345
+ if (!pending) return;
31346
+ if (pending.timer) clearTimeout(pending.timer);
31347
+ this.pendingTouch = null;
31348
+ if (this.state !== BRUSH_TOOL_STATE.IDLE) return;
31349
+ this.handleStartStroke(pending.pressure, pending.time, pending.start);
31350
+ this.captureState.lastClient = pending.startClient;
31351
+ if (this.activeProfile.capture.source === "coalesced") for (const move of pending.samples) this.handleMoveEvent({ evt: {
31352
+ pointerId: move.pointerId,
31353
+ pointerType: move.pointerType,
31354
+ clientX: move.clientX,
31355
+ clientY: move.clientY,
31356
+ pressure: move.pressure,
31357
+ timeStamp: move.timeStamp,
31358
+ getCoalescedEvents: () => move.coalesced,
31359
+ getPredictedEvents: () => [],
31360
+ stopPropagation: () => void 0
31361
+ } });
31362
+ }
31363
+ /** Holds a move of the waiting finger, and commits once it has moved enough. */
31364
+ handlePendingTouchMove(e) {
31365
+ const pending = this.pendingTouch;
31366
+ if (!pending || e.evt.pointerId !== pending.pointerId) return;
31367
+ const guard = this.activeProfile.touchGuard;
31368
+ if (this.getZoomPlugin()?.isPinching()) {
31369
+ if (guard.cancelOnSecondTouch) this.cancelPendingTouch();
31370
+ return;
31371
+ }
31372
+ const batch = e.evt.getCoalescedEvents ? e.evt.getCoalescedEvents() : [];
31373
+ const source = batch.length > 0 ? batch : [e.evt];
31374
+ pending.samples.push({
31375
+ pointerId: e.evt.pointerId,
31376
+ pointerType: e.evt.pointerType,
31377
+ clientX: e.evt.clientX,
31378
+ clientY: e.evt.clientY,
31379
+ pressure: e.evt.pressure,
31380
+ timeStamp: e.evt.timeStamp,
31381
+ coalesced: source.map((c) => ({
31382
+ pointerType: c.pointerType,
31383
+ pressure: c.pressure,
31384
+ clientX: c.clientX,
31385
+ clientY: c.clientY,
31386
+ timeStamp: c.timeStamp
31387
+ }))
31388
+ });
31389
+ if (guard.movePx > 0 && Math.hypot(e.evt.clientX - pending.startClient.x, e.evt.clientY - pending.startClient.y) >= guard.movePx) this.commitPendingTouch();
31390
+ e.evt.stopPropagation();
31391
+ }
31392
+ handleStartStroke(pressure, time, start) {
28411
31393
  this.resetPressureSmoothingState();
28412
- const { mousePoint, container, measureContainer } = this.instance.getMousePointer();
31394
+ const { mousePoint, container, measureContainer } = start ?? this.instance.getMousePointer();
31395
+ if (mousePoint) {
31396
+ const self = this;
31397
+ this.stabilizer.seed(mousePoint, {
31398
+ get now() {
31399
+ return self.sampleTime(time);
31400
+ },
31401
+ get zoom() {
31402
+ return self.instance.getStage().scaleX();
31403
+ }
31404
+ });
31405
+ this.lastRawPoint = {
31406
+ x: mousePoint.x,
31407
+ y: mousePoint.y
31408
+ };
31409
+ }
28413
31410
  this.clickPoint = mousePoint;
28414
31411
  this.container = container;
28415
31412
  this.measureContainer = measureContainer;
@@ -28422,8 +31419,11 @@ var WeaveBrushToolAction = class extends WeaveAction {
28422
31419
  y: mousePoint.y,
28423
31420
  pressure
28424
31421
  });
31422
+ const inputMeta = this.getInputMeta();
28425
31423
  const node = nodeHandler.create(this.strokeId, {
28426
31424
  ...this.props,
31425
+ ...this.look ? { look: this.look } : {},
31426
+ ...inputMeta ? { inputMeta } : {},
28427
31427
  isEraser: this.isEraser,
28428
31428
  strokeScaleEnabled: true,
28429
31429
  x: 0,
@@ -28435,6 +31435,14 @@ var WeaveBrushToolAction = class extends WeaveAction {
28435
31435
  const nodeInstance = nodeHandler.onRender(node.props);
28436
31436
  this.measureContainer?.add(nodeInstance);
28437
31437
  }
31438
+ this.getTraceSink()?.onStrokeStart({
31439
+ profile: this.activeProfileKey,
31440
+ preset: this.presetIds[this.activeProfileKey],
31441
+ ...this.scenarioId ? { scenario: this.scenarioId } : {},
31442
+ profileConfig: this.activeProfile,
31443
+ strokeWidth: this.props.strokeWidth ?? 1,
31444
+ zoom: this.instance.getStage().scaleX()
31445
+ });
28438
31446
  this.setState(BRUSH_TOOL_STATE.DEFINE_STROKE);
28439
31447
  }
28440
31448
  /**
@@ -28454,23 +31462,50 @@ var WeaveBrushToolAction = class extends WeaveAction {
28454
31462
  const stage = this.instance.getStage();
28455
31463
  const tempStroke = stage.findOne(`#${this.strokeId}`);
28456
31464
  if (!this.measureContainer || !tempStroke) return;
31465
+ const sink = this.getTraceSink();
31466
+ const batchStart = sink ? performance.now() : 0;
28457
31467
  let newStrokeElements = [...tempStroke.getAttrs().strokeElements];
31468
+ const skipPredicted = this.activeProfile.stabilizer.type !== "none";
28458
31469
  for (const sample of samples) {
31470
+ if (sample.isPredicted && skipPredicted) continue;
28459
31471
  if (sample.sourceEvent) stage.setPointersPositions(sample.sourceEvent);
28460
- const { mousePoint } = this.instance.getMousePointerRelativeToContainer(this.measureContainer);
31472
+ const mousePoint = sample.point ?? this.instance.getMousePointerRelativeToContainer(this.measureContainer).mousePoint;
31473
+ if (!sample.isPredicted) this.lastRawPoint = {
31474
+ x: mousePoint.x,
31475
+ y: mousePoint.y
31476
+ };
31477
+ const sampleTime = () => this.sampleTime(sample.time);
31478
+ const stabilized = this.stabilizer.apply(mousePoint, {
31479
+ get now() {
31480
+ return sampleTime();
31481
+ },
31482
+ get zoom() {
31483
+ return stage.scaleX();
31484
+ }
31485
+ });
31486
+ sink?.onSample({
31487
+ raw: {
31488
+ x: mousePoint.x,
31489
+ y: mousePoint.y
31490
+ },
31491
+ stabilized: {
31492
+ x: stabilized.x,
31493
+ y: stabilized.y
31494
+ },
31495
+ pressure: sample.pressure,
31496
+ time: sampleTime(),
31497
+ predicted: sample.isPredicted
31498
+ });
28461
31499
  const currentPoint = {
28462
- x: mousePoint.x - tempStroke.x(),
28463
- y: mousePoint.y - tempStroke.y(),
31500
+ x: stabilized.x - tempStroke.x(),
31501
+ y: stabilized.y - tempStroke.y(),
28464
31502
  pressure: sample.pressure
28465
31503
  };
28466
31504
  if (!sample.isPredicted && this.predictedCount > 0) {
28467
31505
  newStrokeElements = newStrokeElements.slice(0, -1 * this.predictedCount);
28468
31506
  this.predictedCount = 0;
28469
31507
  }
28470
- if (!sample.isPredicted && newStrokeElements.length === 1) newStrokeElements[0] = {
28471
- ...newStrokeElements[0],
28472
- pressure: currentPoint.pressure
28473
- };
31508
+ applyStartPolicy(newStrokeElements, currentPoint, sample.isPredicted, this.activeProfile.pressure);
28474
31509
  newStrokeElements.push(currentPoint);
28475
31510
  if (sample.isPredicted) this.predictedCount++;
28476
31511
  }
@@ -28484,6 +31519,10 @@ var WeaveBrushToolAction = class extends WeaveAction {
28484
31519
  });
28485
31520
  const nodeHandler = this.instance.getNodeHandler("stroke");
28486
31521
  if (nodeHandler) nodeHandler.onUpdate(tempStroke, tempStroke.getAttrs());
31522
+ sink?.onBatch({
31523
+ ms: performance.now() - batchStart,
31524
+ samples: samples.length
31525
+ });
28487
31526
  }
28488
31527
  handleMovement(pressure, predictedEvent, isPredicted = false) {
28489
31528
  this.applyMovementSamples([{
@@ -28492,26 +31531,60 @@ var WeaveBrushToolAction = class extends WeaveAction {
28492
31531
  isPredicted
28493
31532
  }]);
28494
31533
  }
31534
+ /**
31535
+ * The point that completes a stabilized stroke at the pen's last position,
31536
+ * or `null` when the stroke already ends there or has no stabilizer.
31537
+ */
31538
+ getStrokeCompletion(elements, tempStroke) {
31539
+ const cfg = this.activeProfile.stabilizer;
31540
+ if (cfg.type === "none" || !cfg.catchUpOnEnd) return null;
31541
+ if (!this.lastRawPoint || elements.length === 0) return null;
31542
+ const last = elements[elements.length - 1];
31543
+ const x = this.lastRawPoint.x - tempStroke.x();
31544
+ const y = this.lastRawPoint.y - tempStroke.y();
31545
+ if (x === last.x && y === last.y) return null;
31546
+ return {
31547
+ x,
31548
+ y,
31549
+ pressure: last.pressure
31550
+ };
31551
+ }
28495
31552
  finalizeStroke(tempStroke, nodeHandler) {
28496
- const box = this.getBoundingBox(tempStroke.getAttrs().strokeElements);
31553
+ let box = this.getBoundingBox(tempStroke.getAttrs().strokeElements);
28497
31554
  let newStrokeElements = [...tempStroke.getAttrs().strokeElements];
28498
31555
  if (this.predictedCount > 0) {
28499
31556
  newStrokeElements = newStrokeElements.slice(0, -1 * this.predictedCount);
28500
31557
  this.predictedCount = 0;
28501
31558
  }
28502
- if (newStrokeElements.length >= 2) {
28503
- const lastIndex = newStrokeElements.length - 1;
28504
- newStrokeElements[lastIndex] = {
28505
- ...newStrokeElements[lastIndex],
28506
- pressure: newStrokeElements[lastIndex - 1].pressure
28507
- };
31559
+ const completion = this.getStrokeCompletion(newStrokeElements, tempStroke);
31560
+ if (completion) {
31561
+ newStrokeElements = [...newStrokeElements, completion];
31562
+ box = this.getBoundingBox([...tempStroke.getAttrs().strokeElements, completion]);
28508
31563
  }
31564
+ newStrokeElements = applyEndPolicy(newStrokeElements, this.activeProfile.pressure);
28509
31565
  newStrokeElements = newStrokeElements.map((point) => ({
28510
31566
  ...point,
28511
31567
  x: point.x - box.x,
28512
31568
  y: point.y - box.y
28513
31569
  }));
28514
- const compressedPoints = simplify(newStrokeElements, this.config.simplifyTolerance, true);
31570
+ const finalize = this.activeProfile.finalize;
31571
+ const zoom = finalize.toleranceUnit === "screen" || finalize.mode === "bezierFit" || finalize.mode === "dpPressure" ? this.instance.getStage().scaleX() : 1;
31572
+ const finalizeStart = performance.now();
31573
+ const compressedPoints = finalizePoints(newStrokeElements, finalize, this.config.simplifyTolerance, zoom);
31574
+ this.getTraceSink()?.onStrokeEnd({
31575
+ beforeFinalize: newStrokeElements.map((p) => ({
31576
+ x: p.x + box.x,
31577
+ y: p.y + box.y,
31578
+ pressure: p.pressure
31579
+ })),
31580
+ finalPoints: compressedPoints.map((p) => ({
31581
+ x: p.x + box.x,
31582
+ y: p.y + box.y,
31583
+ pressure: p.pressure
31584
+ })),
31585
+ strokeWidth: tempStroke.getAttrs().strokeWidth ?? 1,
31586
+ finalizeMs: performance.now() - finalizeStart
31587
+ });
28515
31588
  const sw = tempStroke.getAttrs().strokeWidth ?? 1;
28516
31589
  const finalWidth = Math.max(box.width, sw);
28517
31590
  const finalHeight = Math.max(box.height, sw);
@@ -28584,6 +31657,8 @@ var WeaveBrushToolAction = class extends WeaveAction {
28584
31657
  stage.container().focus();
28585
31658
  this.cancelAction = cancel;
28586
31659
  if (selectionPlugin) selectionPlugin.setSelectedNodes([]);
31660
+ this.cancelPendingTouch();
31661
+ this.penSeen = false;
28587
31662
  this.props = this.initProps();
28588
31663
  this.setState(BRUSH_TOOL_STATE.IDLE);
28589
31664
  this.instance.emitEvent("onAddingBrush");
@@ -28604,6 +31679,7 @@ var WeaveBrushToolAction = class extends WeaveAction {
28604
31679
  if (node) selectionPlugin.setSelectedNodes([node]);
28605
31680
  this.instance.triggerAction(SELECTION_TOOL_ACTION_NAME);
28606
31681
  }
31682
+ this.cancelPendingTouch();
28607
31683
  this.clickPoint = null;
28608
31684
  this.setState(BRUSH_TOOL_STATE.INACTIVE);
28609
31685
  }
@@ -37298,4 +40374,4 @@ function getJSONFromYjsBinary(actualState) {
37298
40374
  return JSON.parse(actualStateString);
37299
40375
  }
37300
40376
  //#endregion
37301
- export { ALIGN_NODES_ALIGN_TO, ALIGN_NODES_TOOL_ACTION_NAME, ALIGN_NODES_TOOL_STATE, BRUSH_TOOL_ACTION_NAME, BRUSH_TOOL_DEFAULT_CONFIG, BRUSH_TOOL_STATE, CONNECTOR_TOOL_ACTION_NAME, CONNECTOR_TOOL_DEFAULT_CONFIG, CONNECTOR_TOOL_STATE, COPY_PASTE_NODES_PLUGIN_STATE, DEFAULT_GUIDE_TOOL_ACTION_CONFIG, DEFAULT_SNAPPING_MANAGER_CONFIG, ELLIPSE_TOOL_ACTION_NAME, ELLIPSE_TOOL_STATE, ERASER_TOOL_ACTION_NAME, ERASER_TOOL_STATE, FRAME_TOOL_ACTION_NAME, FRAME_TOOL_STATE, GUIDE_DISTANCE_NAME, GUIDE_DISTANCE_ORIGIN, GUIDE_KIND, GUIDE_NAME, GUIDE_ORIENTATION, GUIDE_STATE, GUIDE_TOOL_ACTION_NAME, GUIDE_TOOL_STATE, LINE_TOOL_ACTION_NAME, LINE_TOOL_DEFAULT_CONFIG, LINE_TOOL_STATE, MEASURE_TOOL_ACTION_NAME, MEASURE_TOOL_STATE, MOVE_ORIENTATION, MOVE_TOOL_ACTION_NAME, MOVE_TOOL_STATE, PEN_TOOL_ACTION_NAME, PEN_TOOL_STATE, POLYGON_TOOL_ACTION_NAME, POLYGON_TOOL_STATE, PREVIEW_HANDOVER_MAX_FRAMES, PREVIEW_ID_SUFFIX, RECTANGLE_TOOL_ACTION_NAME, RECTANGLE_TOOL_STATE, REGULAR_POLYGON_TOOL_ACTION_NAME, REGULAR_POLYGON_TOOL_STATE, SELECTION_TOOL_ACTION_NAME, SELECTION_TOOL_STATE, STAGE_MINIMAP_DEFAULT_CONFIG, STAR_TOOL_ACTION_NAME, STAR_TOOL_STATE, TEXT_LAYOUT, TEXT_TOOL_ACTION_NAME, TEXT_TOOL_STATE, TextLayoutEngine, VIDEO_TOOL_ACTION_NAME, VIDEO_TOOL_STATE, WEAVE_ARROW_NODE_TYPE, WEAVE_ARROW_TOOL_ACTION_NAME, WEAVE_ARROW_TOOL_STATE, WEAVE_COMMENTS_RENDERER_KEY, WEAVE_COMMENTS_TOOL_LAYER_ID, WEAVE_COMMENT_CREATE_ACTION, WEAVE_COMMENT_NODE_ACTION, WEAVE_COMMENT_NODE_DEFAULTS, WEAVE_COMMENT_NODE_TYPE, WEAVE_COMMENT_STATUS, WEAVE_COMMENT_TOOL_ACTION_NAME, WEAVE_COMMENT_TOOL_DEFAULT_CONFIG, WEAVE_COMMENT_TOOL_STATE, WEAVE_COMMENT_VIEW_ACTION, WEAVE_CONNECTOR_NODE_ANCHOR_ORIGIN, WEAVE_CONNECTOR_NODE_DECORATOR_TYPE, WEAVE_CONNECTOR_NODE_DEFAULT_CONFIG, WEAVE_CONNECTOR_NODE_LINE_ORIGIN, WEAVE_CONNECTOR_NODE_LINE_TYPE, WEAVE_CONNECTOR_NODE_TYPE, WEAVE_COPY_PASTE_CONFIG_DEFAULT, WEAVE_COPY_PASTE_NODES_KEY, WEAVE_COPY_PASTE_PASTE_CATCHER_ID, WEAVE_COPY_PASTE_PASTE_MODES, WEAVE_DEFAULT_USER_INFO_FUNCTION, WEAVE_ELLIPSE_NODE_TYPE, WEAVE_FRAME_DEFAULT_BACKGROUND_COLOR, WEAVE_FRAME_NODE_DEFAULT_CONFIG, WEAVE_FRAME_NODE_DEFAULT_PROPS, WEAVE_FRAME_NODE_TYPE, WEAVE_GRID_DEFAULT_CONFIG, WEAVE_GRID_DOT_TYPES, WEAVE_GRID_LAYER_ID, WEAVE_GRID_TYPES, WEAVE_GROUP_NODE_TYPE, WEAVE_IMAGES_TOOL_ACTION_NAME, WEAVE_IMAGES_TOOL_DEFAULT_CONFIG, WEAVE_IMAGES_TOOL_STATE, WEAVE_IMAGES_TOOL_UPLOAD_TYPE, WEAVE_IMAGE_CROP_ANCHOR_POSITION, WEAVE_IMAGE_CROP_END_TYPE, WEAVE_IMAGE_DEFAULT_CONFIG, WEAVE_IMAGE_NODE_TYPE, WEAVE_IMAGE_TOOL_ACTION_NAME, WEAVE_IMAGE_TOOL_CONFIG_DEFAULT, WEAVE_IMAGE_TOOL_STATE, WEAVE_IMAGE_TOOL_UPLOAD_TYPE, WEAVE_LAYER_NODE_TYPE, WEAVE_LINE_NODE_DEFAULT_CONFIG, WEAVE_LINE_NODE_TYPE, WEAVE_MEASURE_NODE_DEFAULT_CONFIG, WEAVE_MEASURE_NODE_TYPE, WEAVE_MEASURE_TOOL_DEFAULT_CONFIG, WEAVE_NODES_MULTI_SELECTION_FEEDBACK_PLUGIN_DEFAULT_CONFIG, WEAVE_NODES_MULTI_SELECTION_FEEDBACK_PLUGIN_KEY, WEAVE_NODES_MULTI_SELECTION_FEEDBACK_PLUGIN_LAYER_ID, WEAVE_NODES_SELECTION_DEFAULT_CONFIG, WEAVE_NODES_SELECTION_KEY, WEAVE_NODES_SELECTION_LAYER_ID, WEAVE_NODES_SNAPPING_PLUGIN_KEY, WEAVE_POLYGON_NODE_TYPE, WEAVE_POLYGON_PRESETS, WEAVE_RECTANGLE_NODE_TYPE, WEAVE_REGULAR_POLYGON_NODE_TYPE, WEAVE_SHAPE_LABEL_DEFAULTS, WEAVE_STAGE_DEFAULT_MODE, WEAVE_STAGE_DROP_AREA_KEY, WEAVE_STAGE_GRID_PLUGIN_KEY, WEAVE_STAGE_IMAGE_CROPPING_MODE, WEAVE_STAGE_KEYBOARD_MOVE_DEFAULT_CONFIG, WEAVE_STAGE_KEYBOARD_MOVE_KEY, WEAVE_STAGE_KEYBOARD_MOVE_ORIENTATION, WEAVE_STAGE_MINIMAP_KEY, WEAVE_STAGE_NODE_TYPE, WEAVE_STAGE_PANNING_DEFAULT_CONFIG, WEAVE_STAGE_PANNING_KEY, WEAVE_STAGE_PANNING_THROTTLE_MS, WEAVE_STAGE_SHAPE_LABEL_EDITION_MODE, WEAVE_STAGE_TEXT_EDITION_MODE, WEAVE_STAGE_ZOOM_DEFAULT_CONFIG, WEAVE_STAGE_ZOOM_KEY, WEAVE_STAGE_ZOOM_TYPE, WEAVE_STAR_NODE_TYPE, WEAVE_STROKE_NODE_DEFAULT_CONFIG, WEAVE_STROKE_NODE_TYPE, WEAVE_STROKE_SINGLE_NODE_DEFAULT_CONFIG, WEAVE_STROKE_SINGLE_NODE_TIP_SIDE, WEAVE_STROKE_SINGLE_NODE_TIP_TYPE, WEAVE_STROKE_SINGLE_NODE_TYPE, WEAVE_STROKE_TOOL_ACTION_NAME, WEAVE_STROKE_TOOL_ACTION_NAME_ALIASES, WEAVE_STROKE_TOOL_DEFAULT_CONFIG, WEAVE_STROKE_TOOL_STATE, WEAVE_TEXT_LINK_ALLOWED_PROTOCOLS, WEAVE_TEXT_NODE_DEFAULT_CONFIG, WEAVE_TEXT_NODE_TYPE, WEAVE_USERS_POINTERS_CONFIG_DEFAULT_PROPS, WEAVE_USERS_POINTERS_KEY, WEAVE_USERS_PRESENCE_CONFIG_DEFAULT_PROPS, WEAVE_USERS_PRESENCE_PLUGIN_KEY, WEAVE_USERS_SELECTION_KEY, WEAVE_USER_POINTER_KEY, WEAVE_USER_PRESENCE_KEY, WEAVE_USER_SELECTION_KEY, WEAVE_VIDEO_DEFAULT_CONFIG, WEAVE_VIDEO_NODE_TYPE, Weave, WeaveAction, WeaveAlignNodesToolAction, WeaveArrowNode, WeaveArrowToolAction, WeaveBrushToolAction, WeaveCommentNode, WeaveCommentToolAction, WeaveCommentsRendererPlugin, WeaveConnectedUsersPlugin, WeaveConnectorNode, WeaveConnectorToolAction, WeaveContextMenuPlugin, WeaveCopyPasteNodesPlugin, WeaveEllipseNode, WeaveEllipseToolAction, WeaveEraserToolAction, WeaveExportNodesToolAction, WeaveExportStageToolAction, WeaveFitToScreenToolAction, WeaveFitToSelectionToolAction, WeaveFrameNode, WeaveFrameToolAction, WeaveGroupNode, WeaveGuideToolAction, WeaveImageNode, WeaveImageToolAction, WeaveImagesToolAction, WeaveLayerNode, WeaveLineNode, WeaveLineToolAction, WeaveMeasureNode, WeaveMeasureToolAction, WeaveMoveToolAction, WeaveNode, WeaveNodesMultiSelectionFeedbackPlugin, WeaveNodesSelectionPlugin, WeaveNodesSnappingPlugin, WeavePenToolAction, WeavePlugin, WeavePolygonNode, WeavePolygonToolAction, WeaveRectangleNode, WeaveRectangleToolAction, WeaveRegularPolygonNode, WeaveRegularPolygonToolAction, WeaveRenderer, WeaveSelectionToolAction, WeaveStageDropAreaPlugin, WeaveStageGridPlugin, WeaveStageKeyboardMovePlugin, WeaveStageMinimapPlugin, WeaveStageNode, WeaveStagePanningPlugin, WeaveStageResizePlugin, WeaveStageZoomPlugin, WeaveStarNode, WeaveStarToolAction, WeaveStateManipulation, WeaveStore, WeaveStrokeNode, WeaveStrokeSingleNode, WeaveStrokeToolAction, WeaveTextNode, WeaveTextToolAction, WeaveTextV2Node, WeaveTextV2Shape, WeaveUsersPointersPlugin, WeaveUsersPresencePlugin, WeaveUsersSelectionPlugin, WeaveVideoNode, WeaveVideoToolAction, WeaveZoomInToolAction, WeaveZoomOutToolAction, applyLabelBlocksState, buildAncestorGroupIds, canComposite, clearContainerTargets, computeEllipseLabelMinSize, computePolygonLabelMinSize, computeRectangleLabelMinSize, containerOverCursor, containsNodeDeep, defaultInitialState, downscaleImageFile, downscaleImageFromURL, getBoundingBox, getDownscaleRatio, getExportBoundingBox, getImageSizeFromFile, getJSONFromYjsBinary, getSelectedNodesMetadata, getShapeLabelSchemaFields, getStageClickPoint, getTargetAndSkipNodes, getTargetedNode, getTopmostShadowHost, getVisibleNodes, getVisibleNodesInViewport, hasFrames, hasImages, instantiatePreset, intersectArrays, isArray, isIOS, isInShadowDOM, isNodeInSelection, isNumber, isObject, isServer, labelId, loadImageSource, mapJsonToYjsArray, mapJsonToYjsElements, mapJsonToYjsMap, memoize, mergeExceptArrays, moveNodeToContainer, moveNodeToContainerNT, resetScale, resolveLabelBlocks, sleep, spreadLabelProps, weavejsToYjsBinary };
40377
+ export { ALIGN_NODES_ALIGN_TO, ALIGN_NODES_TOOL_ACTION_NAME, ALIGN_NODES_TOOL_STATE, BRUSH_CONFIG_DOCUMENT_VERSION, BRUSH_INPUT_PROFILE_KEYS, BRUSH_LAB_PLUGIN_NAME, BRUSH_LEGACY_INPUT_PROFILE, BRUSH_LOOK_PRESETS, BRUSH_LOOK_TUNABLES, BRUSH_PROFILE_PRESETS, BRUSH_PROFILE_TUNABLES, BRUSH_SCENARIOS, BRUSH_TOOL_ACTION_NAME, BRUSH_TOOL_DEFAULT_CONFIG, BRUSH_TOOL_STATE, BRUSH_TUNABLES, CONNECTOR_TOOL_ACTION_NAME, CONNECTOR_TOOL_DEFAULT_CONFIG, CONNECTOR_TOOL_STATE, COPY_PASTE_NODES_PLUGIN_STATE, DEFAULT_GUIDE_TOOL_ACTION_CONFIG, DEFAULT_SNAPPING_MANAGER_CONFIG, ELLIPSE_TOOL_ACTION_NAME, ELLIPSE_TOOL_STATE, ERASER_TOOL_ACTION_NAME, ERASER_TOOL_STATE, FRAME_TOOL_ACTION_NAME, FRAME_TOOL_STATE, GUIDE_DISTANCE_NAME, GUIDE_DISTANCE_ORIGIN, GUIDE_KIND, GUIDE_NAME, GUIDE_ORIENTATION, GUIDE_STATE, GUIDE_TOOL_ACTION_NAME, GUIDE_TOOL_STATE, LINE_TOOL_ACTION_NAME, LINE_TOOL_DEFAULT_CONFIG, LINE_TOOL_STATE, MEASURE_TOOL_ACTION_NAME, MEASURE_TOOL_STATE, MOVE_ORIENTATION, MOVE_TOOL_ACTION_NAME, MOVE_TOOL_STATE, PEN_TOOL_ACTION_NAME, PEN_TOOL_STATE, POLYGON_TOOL_ACTION_NAME, POLYGON_TOOL_STATE, PREVIEW_HANDOVER_MAX_FRAMES, PREVIEW_ID_SUFFIX, RECTANGLE_TOOL_ACTION_NAME, RECTANGLE_TOOL_STATE, REGULAR_POLYGON_TOOL_ACTION_NAME, REGULAR_POLYGON_TOOL_STATE, SELECTION_TOOL_ACTION_NAME, SELECTION_TOOL_STATE, STAGE_MINIMAP_DEFAULT_CONFIG, STAR_TOOL_ACTION_NAME, STAR_TOOL_STATE, TEXT_LAYOUT, TEXT_TOOL_ACTION_NAME, TEXT_TOOL_STATE, TextLayoutEngine, VIDEO_TOOL_ACTION_NAME, VIDEO_TOOL_STATE, WEAVE_ARROW_NODE_TYPE, WEAVE_ARROW_TOOL_ACTION_NAME, WEAVE_ARROW_TOOL_STATE, WEAVE_COMMENTS_RENDERER_KEY, WEAVE_COMMENTS_TOOL_LAYER_ID, WEAVE_COMMENT_CREATE_ACTION, WEAVE_COMMENT_NODE_ACTION, WEAVE_COMMENT_NODE_DEFAULTS, WEAVE_COMMENT_NODE_TYPE, WEAVE_COMMENT_STATUS, WEAVE_COMMENT_TOOL_ACTION_NAME, WEAVE_COMMENT_TOOL_DEFAULT_CONFIG, WEAVE_COMMENT_TOOL_STATE, WEAVE_COMMENT_VIEW_ACTION, WEAVE_CONNECTOR_NODE_ANCHOR_ORIGIN, WEAVE_CONNECTOR_NODE_DECORATOR_TYPE, WEAVE_CONNECTOR_NODE_DEFAULT_CONFIG, WEAVE_CONNECTOR_NODE_LINE_ORIGIN, WEAVE_CONNECTOR_NODE_LINE_TYPE, WEAVE_CONNECTOR_NODE_TYPE, WEAVE_COPY_PASTE_CONFIG_DEFAULT, WEAVE_COPY_PASTE_NODES_KEY, WEAVE_COPY_PASTE_PASTE_CATCHER_ID, WEAVE_COPY_PASTE_PASTE_MODES, WEAVE_DEFAULT_USER_INFO_FUNCTION, WEAVE_ELLIPSE_NODE_TYPE, WEAVE_FRAME_DEFAULT_BACKGROUND_COLOR, WEAVE_FRAME_NODE_DEFAULT_CONFIG, WEAVE_FRAME_NODE_DEFAULT_PROPS, WEAVE_FRAME_NODE_TYPE, WEAVE_GRID_DEFAULT_CONFIG, WEAVE_GRID_DOT_TYPES, WEAVE_GRID_LAYER_ID, WEAVE_GRID_TYPES, WEAVE_GROUP_NODE_TYPE, WEAVE_IMAGES_TOOL_ACTION_NAME, WEAVE_IMAGES_TOOL_DEFAULT_CONFIG, WEAVE_IMAGES_TOOL_STATE, WEAVE_IMAGES_TOOL_UPLOAD_TYPE, WEAVE_IMAGE_CROP_ANCHOR_POSITION, WEAVE_IMAGE_CROP_END_TYPE, WEAVE_IMAGE_DEFAULT_CONFIG, WEAVE_IMAGE_NODE_TYPE, WEAVE_IMAGE_TOOL_ACTION_NAME, WEAVE_IMAGE_TOOL_CONFIG_DEFAULT, WEAVE_IMAGE_TOOL_STATE, WEAVE_IMAGE_TOOL_UPLOAD_TYPE, WEAVE_LAYER_NODE_TYPE, WEAVE_LINE_NODE_DEFAULT_CONFIG, WEAVE_LINE_NODE_TYPE, WEAVE_MEASURE_NODE_DEFAULT_CONFIG, WEAVE_MEASURE_NODE_TYPE, WEAVE_MEASURE_TOOL_DEFAULT_CONFIG, WEAVE_NODES_MULTI_SELECTION_FEEDBACK_PLUGIN_DEFAULT_CONFIG, WEAVE_NODES_MULTI_SELECTION_FEEDBACK_PLUGIN_KEY, WEAVE_NODES_MULTI_SELECTION_FEEDBACK_PLUGIN_LAYER_ID, WEAVE_NODES_SELECTION_DEFAULT_CONFIG, WEAVE_NODES_SELECTION_KEY, WEAVE_NODES_SELECTION_LAYER_ID, WEAVE_NODES_SNAPPING_PLUGIN_KEY, WEAVE_POLYGON_NODE_TYPE, WEAVE_POLYGON_PRESETS, WEAVE_RECTANGLE_NODE_TYPE, WEAVE_REGULAR_POLYGON_NODE_TYPE, WEAVE_SHAPE_LABEL_DEFAULTS, WEAVE_STAGE_DEFAULT_MODE, WEAVE_STAGE_DROP_AREA_KEY, WEAVE_STAGE_GRID_PLUGIN_KEY, WEAVE_STAGE_IMAGE_CROPPING_MODE, WEAVE_STAGE_KEYBOARD_MOVE_DEFAULT_CONFIG, WEAVE_STAGE_KEYBOARD_MOVE_KEY, WEAVE_STAGE_KEYBOARD_MOVE_ORIENTATION, WEAVE_STAGE_MINIMAP_KEY, WEAVE_STAGE_NODE_TYPE, WEAVE_STAGE_PANNING_DEFAULT_CONFIG, WEAVE_STAGE_PANNING_KEY, WEAVE_STAGE_PANNING_THROTTLE_MS, WEAVE_STAGE_SHAPE_LABEL_EDITION_MODE, WEAVE_STAGE_TEXT_EDITION_MODE, WEAVE_STAGE_ZOOM_DEFAULT_CONFIG, WEAVE_STAGE_ZOOM_KEY, WEAVE_STAGE_ZOOM_TYPE, WEAVE_STAR_NODE_TYPE, WEAVE_STROKE_LEGACY_LOOK, WEAVE_STROKE_NODE_DEFAULT_CONFIG, WEAVE_STROKE_NODE_TYPE, WEAVE_STROKE_SINGLE_NODE_DEFAULT_CONFIG, WEAVE_STROKE_SINGLE_NODE_TIP_SIDE, WEAVE_STROKE_SINGLE_NODE_TIP_TYPE, WEAVE_STROKE_SINGLE_NODE_TYPE, WEAVE_STROKE_TOOL_ACTION_NAME, WEAVE_STROKE_TOOL_ACTION_NAME_ALIASES, WEAVE_STROKE_TOOL_DEFAULT_CONFIG, WEAVE_STROKE_TOOL_STATE, WEAVE_TEXT_LINK_ALLOWED_PROTOCOLS, WEAVE_TEXT_NODE_DEFAULT_CONFIG, WEAVE_TEXT_NODE_TYPE, WEAVE_USERS_POINTERS_CONFIG_DEFAULT_PROPS, WEAVE_USERS_POINTERS_KEY, WEAVE_USERS_PRESENCE_CONFIG_DEFAULT_PROPS, WEAVE_USERS_PRESENCE_PLUGIN_KEY, WEAVE_USERS_SELECTION_KEY, WEAVE_USER_POINTER_KEY, WEAVE_USER_PRESENCE_KEY, WEAVE_USER_SELECTION_KEY, WEAVE_VIDEO_DEFAULT_CONFIG, WEAVE_VIDEO_NODE_TYPE, Weave, WeaveAction, WeaveAlignNodesToolAction, WeaveArrowNode, WeaveArrowToolAction, WeaveBrushPresetRegistry, WeaveBrushToolAction, WeaveCommentNode, WeaveCommentToolAction, WeaveCommentsRendererPlugin, WeaveConnectedUsersPlugin, WeaveConnectorNode, WeaveConnectorToolAction, WeaveContextMenuPlugin, WeaveCopyPasteNodesPlugin, WeaveEllipseNode, WeaveEllipseToolAction, WeaveEraserToolAction, WeaveExportNodesToolAction, WeaveExportStageToolAction, WeaveFitToScreenToolAction, WeaveFitToSelectionToolAction, WeaveFrameNode, WeaveFrameToolAction, WeaveGroupNode, WeaveGuideToolAction, WeaveImageNode, WeaveImageToolAction, WeaveImagesToolAction, WeaveLayerNode, WeaveLineNode, WeaveLineToolAction, WeaveMeasureNode, WeaveMeasureToolAction, WeaveMoveToolAction, WeaveNode, WeaveNodesMultiSelectionFeedbackPlugin, WeaveNodesSelectionPlugin, WeaveNodesSnappingPlugin, WeavePenToolAction, WeavePlugin, WeavePolygonNode, WeavePolygonToolAction, WeaveRectangleNode, WeaveRectangleToolAction, WeaveRegularPolygonNode, WeaveRegularPolygonToolAction, WeaveRenderer, WeaveSelectionToolAction, WeaveStageDropAreaPlugin, WeaveStageGridPlugin, WeaveStageKeyboardMovePlugin, WeaveStageMinimapPlugin, WeaveStageNode, WeaveStagePanningPlugin, WeaveStageResizePlugin, WeaveStageZoomPlugin, WeaveStarNode, WeaveStarToolAction, WeaveStateManipulation, WeaveStore, WeaveStrokeNode, WeaveStrokeSingleNode, WeaveStrokeToolAction, WeaveTextNode, WeaveTextToolAction, WeaveTextV2Node, WeaveTextV2Shape, WeaveUsersPointersPlugin, WeaveUsersPresencePlugin, WeaveUsersSelectionPlugin, WeaveVideoNode, WeaveVideoToolAction, WeaveZoomInToolAction, WeaveZoomOutToolAction, applyLabelBlocksState, automaticBrushProfileKey, buildAncestorGroupIds, canComposite, clearContainerTargets, computeEllipseLabelMinSize, computePolygonLabelMinSize, computeRectangleLabelMinSize, containerOverCursor, containsNodeDeep, defaultInitialState, detectBrushPlatform, downscaleImageFile, downscaleImageFromURL, getBoundingBox, getBrushScenario, getBrushTunable, getDownscaleRatio, getExportBoundingBox, getImageSizeFromFile, getJSONFromYjsBinary, getSelectedNodesMetadata, getShapeLabelSchemaFields, getStageClickPoint, getTargetAndSkipNodes, getTargetedNode, getTopmostShadowHost, getVisibleNodes, getVisibleNodesInViewport, hasFrames, hasImages, instantiatePreset, intersectArrays, isArray, isIOS, isInShadowDOM, isNodeInSelection, isNumber, isObject, isServer, labelId, loadImageSource, mapJsonToYjsArray, mapJsonToYjsElements, mapJsonToYjsMap, memoize, mergeExceptArrays, moveNodeToContainer, moveNodeToContainerNT, parseBrushConfigDocument, resetScale, resolveBrushInputProfileKey, resolveBrushLookPatch, resolveBrushProfilePatch, resolveLabelBlocks, sleep, spreadLabelProps, validateBrushTunablePatch, validateBrushTunableValue, weavejsToYjsBinary };