@pixerate/editor 0.39.0 → 0.40.0

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.
@@ -829,7 +829,11 @@ var capExtent = (style, width) => style.strokeLinecap === "round" || style.strok
829
829
  function arrowheadWeight(style, end) {
830
830
  const scale = end === "start" ? style.startArrowScale : style.endArrowScale;
831
831
  const s = typeof scale === "number" && scale > 0 && Number.isFinite(scale) ? scale : 1;
832
- return Math.max(0.25, (style.strokeWidth ?? 1) * s);
832
+ const width = style.strokeWidth ?? 1;
833
+ const profile = strokeWidthProfileOf(style);
834
+ const at2 = profile ? widthProfileAt(profile, end === "start" ? 0 : 1) : null;
835
+ const here = at2 ? Math.max(width, width * (at2.left + at2.right) / 2) : width;
836
+ return Math.max(0.25, here * s);
833
837
  }
834
838
  function arrowheadInset(kind, hw, cap) {
835
839
  const l = 4 * hw;
@@ -4078,6 +4082,12 @@ function vectorStrokeWidthGeometry(node) {
4078
4082
  const parts = vectorStrokeParts(subpathsToBezier(node.subpaths), node);
4079
4083
  return parts.line.length ? { line: parts.line, unit: parts.drawWidth / 2 } : null;
4080
4084
  }
4085
+ function shiftWidthPoints(profile, indices, dt) {
4086
+ const moving = new Set(indices);
4087
+ const tagged = profile.map((p, i) => ({ p: moving.has(i) ? { ...p, t: Math.min(Math.max(p.t + dt, 0), 1) } : { ...p }, i }));
4088
+ tagged.sort((a, b) => a.p.t - b.p.t || a.i - b.i);
4089
+ return { points: tagged.map((x) => x.p), renumber: new Map(tagged.map((x, k) => [x.i, k])) };
4090
+ }
4081
4091
  function getVectorWidthHandles(state) {
4082
4092
  if (state.tool !== "width") return [];
4083
4093
  const ids = /* @__PURE__ */ new Set();
@@ -4087,7 +4097,7 @@ function getVectorWidthHandles(state) {
4087
4097
  }
4088
4098
  if (state.hover?.nodeId) ids.add(state.hover.nodeId);
4089
4099
  if (state.interaction?.type === "width") ids.add(state.interaction.ref.nodeId);
4090
- if (state.selectedWidthPoint) ids.add(state.selectedWidthPoint.nodeId);
4100
+ for (const r of state.selectedWidthPoints) ids.add(r.nodeId);
4091
4101
  const out = [];
4092
4102
  for (const id of ids) {
4093
4103
  const node = findVectorNode(state.document.nodes, id);
@@ -4106,7 +4116,6 @@ function getVectorWidthHandles(state) {
4106
4116
  const p = applyMatrix(m, c);
4107
4117
  const l = applyMatrix(m, { x: c.x + n.x * w.left * g.unit, y: c.y + n.y * w.left * g.unit });
4108
4118
  const r = applyMatrix(m, { x: c.x - n.x * w.right * g.unit, y: c.y - n.y * w.right * g.unit });
4109
- const sel = state.selectedWidthPoint;
4110
4119
  out.push({
4111
4120
  ref: { nodeId: id, index, subpath: sp },
4112
4121
  x: p.x,
@@ -4116,7 +4125,7 @@ function getVectorWidthHandles(state) {
4116
4125
  t: w.t,
4117
4126
  leftScale: w.left,
4118
4127
  rightScale: w.right,
4119
- selected: !!sel && sel.nodeId === id && sel.index === index
4128
+ selected: state.selectedWidthPoints.some((r2) => r2.nodeId === id && r2.index === index)
4120
4129
  });
4121
4130
  });
4122
4131
  }
@@ -4312,7 +4321,8 @@ var VectorEditorController = class {
4312
4321
  doc;
4313
4322
  selection = [];
4314
4323
  anchors = [];
4315
- widthPoint = null;
4324
+ widthPoints = [];
4325
+ widthEdit = null;
4316
4326
  editingId = null;
4317
4327
  pen = null;
4318
4328
  tool;
@@ -4398,7 +4408,8 @@ var VectorEditorController = class {
4398
4408
  tool: this.tool,
4399
4409
  selection: this.selection,
4400
4410
  selectedAnchors: this.anchors,
4401
- selectedWidthPoint: this.widthPoint,
4411
+ selectedWidthPoints: this.widthPoints,
4412
+ widthPointEdit: this.widthEdit,
4402
4413
  editingId: this.editingId,
4403
4414
  selectionBounds: this.boundsOf(this.selection),
4404
4415
  selectionFrame: this.frameOf(this.selectedRoots()),
@@ -4469,7 +4480,7 @@ var VectorEditorController = class {
4469
4480
  case "pen":
4470
4481
  return h?.anchor ? "pointer" : "crosshair";
4471
4482
  case "width":
4472
- if (this.drag?.kind === "width") return this.drag.mode === "move" ? "grabbing" : "ew-resize";
4483
+ if (this.drag?.kind === "width") return this.drag.mode === "width" ? "ew-resize" : "grabbing";
4473
4484
  if (h?.widthPoint) return h.widthPoint.part === "center" ? "grab" : "ew-resize";
4474
4485
  return h?.strokeSpot ? "copy" : "default";
4475
4486
  case "shapeBuilder":
@@ -4489,6 +4500,7 @@ var VectorEditorController = class {
4489
4500
  }
4490
4501
  restore(s) {
4491
4502
  const docChanged = s.doc !== this.doc || this.committing;
4503
+ if (!this.committing) this.widthEdit = null;
4492
4504
  this.committing = false;
4493
4505
  this.applying = true;
4494
4506
  try {
@@ -4497,6 +4509,13 @@ var VectorEditorController = class {
4497
4509
  this.anchors = s.anchors.filter((r) => getAnchor(this.doc, r));
4498
4510
  this.editingId = s.editingId && this.getNode(s.editingId) ? s.editingId : null;
4499
4511
  this.pen = s.pen && this.getNode(s.pen.nodeId)?.subpaths?.[s.pen.subpath] ? s.pen : null;
4512
+ if (this.widthPoints.length) {
4513
+ const kept = this.widthPoints.filter((r) => {
4514
+ const n = this.getNode(r.nodeId);
4515
+ return n?.type === "path" && !!n.strokeWidthProfile?.[r.index];
4516
+ });
4517
+ if (kept.length !== this.widthPoints.length) this.widthPoints = kept;
4518
+ }
4500
4519
  } finally {
4501
4520
  this.applying = false;
4502
4521
  }
@@ -4602,7 +4621,8 @@ var VectorEditorController = class {
4602
4621
  this.tool = tool;
4603
4622
  this.hover = null;
4604
4623
  this.guides = null;
4605
- this.widthPoint = null;
4624
+ this.widthPoints = [];
4625
+ this.widthEdit = null;
4606
4626
  this.builderPreview = null;
4607
4627
  this.builderHoverKey = null;
4608
4628
  if (tool !== "direct") {
@@ -4878,18 +4898,143 @@ var VectorEditorController = class {
4878
4898
  const p = profile;
4879
4899
  points = p.taper || p.wave ? generateWidthProfile(p) : [];
4880
4900
  }
4881
- this.widthPoint = null;
4901
+ this.widthPoints = [];
4902
+ this.widthEdit = null;
4882
4903
  return this.setStyle({ strokeWidthProfile: points.length ? points : null }, options.ids ?? this.selection, { label: points.length ? "Width Profile" : "Uniform Width" });
4883
4904
  }
4884
- /** Removes one width point of a path's profile (the width tool's Delete). */
4905
+ /** Removes one width point of a path's profile. */
4885
4906
  removeWidthPoint(ref) {
4886
- const node = this.getNode(ref.nodeId);
4887
- if (node?.type !== "path" || !node.strokeWidthProfile?.[ref.index]) return { ok: false, message: "There is no such width point." };
4888
- const rest = removeWidthPoints(sanitizeWidthProfile(node.strokeWidthProfile), [ref.index]);
4889
- this.widthPoint = null;
4890
- this.change("Delete Width Point", () => this.replaceNode(node.id, withStyle(node, { strokeWidthProfile: rest.length ? rest : null })));
4907
+ return this.removeWidthPoints([ref]);
4908
+ }
4909
+ /** Removes width points (default: the selected ones — the width tool's Delete), as one undo step. */
4910
+ removeWidthPoints(refs = this.widthPoints) {
4911
+ const byNode = /* @__PURE__ */ new Map();
4912
+ for (const r of refs) {
4913
+ const node = this.getNode(r.nodeId);
4914
+ if (node?.type !== "path" || !node.strokeWidthProfile?.[r.index]) continue;
4915
+ byNode.set(r.nodeId, [...byNode.get(r.nodeId) ?? [], r.index]);
4916
+ }
4917
+ if (!byNode.size) return { ok: false, message: "There is no such width point." };
4918
+ const count = [...byNode.values()].reduce((n, l) => n + new Set(l).size, 0);
4919
+ this.widthPoints = [];
4920
+ this.widthEdit = null;
4921
+ this.change(count > 1 ? "Delete Width Points" : "Delete Width Point", () => {
4922
+ for (const [id, indices] of byNode) {
4923
+ const node = this.getNode(id);
4924
+ const rest = removeWidthPoints(sanitizeWidthProfile(node.strokeWidthProfile), indices);
4925
+ this.replaceNode(id, withStyle(node, { strokeWidthProfile: rest.length ? rest : null }));
4926
+ }
4927
+ });
4928
+ return { ok: true };
4929
+ }
4930
+ /** Selects width points (width tool); points on other paths than the first are dropped. */
4931
+ selectWidthPoints(refs) {
4932
+ const first = refs[0]?.nodeId;
4933
+ const next = [];
4934
+ for (const r of refs) {
4935
+ if (r.nodeId !== first || next.some((q) => q.index === r.index)) continue;
4936
+ const n = this.getNode(r.nodeId);
4937
+ if (n?.type === "path" && n.strokeWidthProfile?.[r.index]) next.push({ nodeId: r.nodeId, index: r.index, subpath: r.subpath ?? 0 });
4938
+ }
4939
+ this.widthPoints = next;
4940
+ this.notify();
4941
+ }
4942
+ /**
4943
+ * Sets one width point numerically: `index` names an existing point of path `nodeId`, null
4944
+ * (or left out) adds one at `values.t`. `left` / `right` are side widths in document units
4945
+ * (how far the stroke reaches on each side of the path), `t` the position along each subpath
4946
+ * (0–1). A point moved onto another one's `t` makes a step there. Selects the point and
4947
+ * refreshes an open width-point editor; one undo step. Ported from ArtCraft vectorcraft's
4948
+ * Width Point Edit dialog (`crates/tools/src/distort/width.rs`, MIT OR Apache-2.0).
4949
+ */
4950
+ setWidthPoint(target, values) {
4951
+ const node = this.getNode(target.nodeId);
4952
+ if (node?.type !== "path") return { ok: false, message: "There is no such path." };
4953
+ const g = vectorStrokeWidthGeometry(node);
4954
+ if (!g || !(g.unit > 0)) return { ok: false, message: "The path has no visible stroke." };
4955
+ const num = (v2) => typeof v2 === "number" && Number.isFinite(v2) ? v2 : void 0;
4956
+ const t = num(values.t);
4957
+ const left = num(values.left);
4958
+ const right = num(values.right);
4959
+ const scale = (w) => w === void 0 ? void 0 : Math.max(0, w) / g.unit;
4960
+ const profile = sanitizeWidthProfile(node.strokeWidthProfile);
4961
+ let points;
4962
+ let index;
4963
+ const existing = target.index ?? null;
4964
+ if (existing === null) {
4965
+ if (t === void 0) return { ok: false, message: "A new width point needs a position t." };
4966
+ if (profile.length >= VECTOR_WIDTH_PROFILE_MAX_POINTS) return { ok: false, message: "The profile has too many width points." };
4967
+ ({ points, index } = insertWidthPoint(profile.length ? profile : null, t, { left: scale(left), right: scale(right) }, { step: true }));
4968
+ } else {
4969
+ const p = profile[existing];
4970
+ if (!p) return { ok: false, message: "There is no such width point." };
4971
+ const sides2 = { left: scale(left) ?? p.left, right: scale(right) ?? p.right };
4972
+ const tt = t === void 0 ? p.t : Math.min(Math.max(t, 0), 1);
4973
+ if (Math.abs(tt - p.t) <= VECTOR_WIDTH_SAME_T) {
4974
+ points = setWidthPoint(profile, existing, sides2);
4975
+ index = existing;
4976
+ } else {
4977
+ const rest = removeWidthPoints(profile, [existing]);
4978
+ if (rest.length) ({ points, index } = insertWidthPoint(rest, tt, sides2, { step: true }));
4979
+ else {
4980
+ points = [{ t: tt, ...sides2 }];
4981
+ index = 0;
4982
+ }
4983
+ }
4984
+ }
4985
+ const subpath = this.widthEdit?.nodeId === node.id ? this.widthEdit.subpath : this.widthPoints.find((r) => r.nodeId === node.id)?.subpath ?? 0;
4986
+ const edit = this.widthEdit;
4987
+ this.change("Width Point", () => this.replaceNode(node.id, withStyle(node, { strokeWidthProfile: points })));
4988
+ this.widthPoints = [{ nodeId: node.id, index, subpath }];
4989
+ if (edit?.nodeId === node.id && (edit.index === existing || existing === null)) this.widthEdit = this.widthEditFor(node.id, subpath, index, null);
4990
+ this.notify();
4891
4991
  return { ok: true };
4892
4992
  }
4993
+ /**
4994
+ * Opens the width-point editor (what a double-click with the width tool does) on a width
4995
+ * point, or on a spot of a stroke (`t` without `index`: applying it adds a point there).
4996
+ * Returns false when the path has no visible stroke.
4997
+ */
4998
+ openWidthPointEdit(target) {
4999
+ const edit = this.widthEditFor(target.nodeId, target.subpath ?? 0, target.index ?? null, target.t ?? null);
5000
+ if (!edit) return false;
5001
+ this.widthEdit = edit;
5002
+ this.widthPoints = edit.index === null ? [] : [{ nodeId: edit.nodeId, index: edit.index, subpath: edit.subpath }];
5003
+ this.notify();
5004
+ return true;
5005
+ }
5006
+ /** Closes the width-point editor without changing anything. */
5007
+ closeWidthPointEdit() {
5008
+ if (!this.widthEdit) return;
5009
+ this.widthEdit = null;
5010
+ this.notify();
5011
+ }
5012
+ /** Applies `values` to the width point being edited (see `setWidthPoint`) and closes the editor. */
5013
+ commitWidthPointEdit(values) {
5014
+ const edit = this.widthEdit;
5015
+ if (!edit) return { ok: false, message: "No width point is being edited." };
5016
+ const r = this.setWidthPoint({ nodeId: edit.nodeId, index: edit.index }, edit.index === null ? { t: edit.t, ...values } : values);
5017
+ if (r.ok) this.closeWidthPointEdit();
5018
+ return r;
5019
+ }
5020
+ /** The editor's view of point `index` (or of the stroke at `t` when index is null) of `nodeId`. */
5021
+ widthEditFor(nodeId, subpath, index, t) {
5022
+ const node = this.getNode(nodeId);
5023
+ if (node?.type !== "path") return null;
5024
+ const g = vectorStrokeWidthGeometry(node);
5025
+ if (!g) return null;
5026
+ const profile = sanitizeWidthProfile(node.strokeWidthProfile);
5027
+ const p = index === null ? null : profile[index];
5028
+ if (index !== null && !p) return null;
5029
+ const tt = p ? p.t : Math.min(Math.max(t ?? 0, 0), 1);
5030
+ const sides2 = p ?? widthProfileAt(profile.length ? profile : null, tt);
5031
+ const sp = Math.min(Math.max(0, subpath), Math.max(0, node.subpaths.length - 1));
5032
+ const at2 = strokePositionPoint(g.line, sp, tt);
5033
+ const round = (v2) => Math.round(v2 * 1e6) / 1e6;
5034
+ const q = at2 ? applyMatrix(this.worldOf(nodeId), { x: at2.point[0], y: at2.point[1] }) : { x: 0, y: 0 };
5035
+ const point = { x: round(q.x), y: round(q.y) };
5036
+ return { nodeId, subpath: sp, index, t: round(tt), left: round(sides2.left * g.unit), right: round(sides2.right * g.unit), point };
5037
+ }
4893
5038
  /**
4894
5039
  * Sets the live corner radius (and optionally the kind) of anchors: `refs`, else the selected
4895
5040
  * anchors (direct tool), else every anchor of the `ids` / selected paths. Radius 0 makes the
@@ -6298,13 +6443,19 @@ var VectorEditorController = class {
6298
6443
  }
6299
6444
  }
6300
6445
  widthView() {
6301
- return { document: this.doc, selection: this.selection, tool: this.tool, hover: this.hover, interaction: this.interaction, selectedWidthPoint: this.widthPoint };
6446
+ return { document: this.doc, selection: this.selection, tool: this.tool, hover: this.hover, interaction: this.interaction, selectedWidthPoints: this.widthPoints };
6302
6447
  }
6303
6448
  /** The width point centre or side end under `screen` (width tool). */
6304
6449
  hitWidthHandle(screen) {
6450
+ const shown = this.hitWidthHandleIn(screen, this.widthView());
6451
+ if (shown) return shown;
6452
+ const spot = this.strokeSpotAt(this.screenToDocument(screen));
6453
+ return spot && spot.nodeId !== this.hover?.nodeId ? this.hitWidthHandleIn(screen, { ...this.widthView(), hover: { nodeId: spot.nodeId } }) : null;
6454
+ }
6455
+ hitWidthHandleIn(screen, view) {
6305
6456
  let best = null;
6306
6457
  let bestD = Infinity;
6307
- for (const h of getVectorWidthHandles(this.widthView())) {
6458
+ for (const h of getVectorWidthHandles(view)) {
6308
6459
  for (const [part, q] of [["center", h], ["left", h.left], ["right", h.right]]) {
6309
6460
  const s = this.documentToScreen(q);
6310
6461
  const d = Math.hypot(s.x - screen.x, s.y - screen.y);
@@ -6357,11 +6508,10 @@ var VectorEditorController = class {
6357
6508
  const nodeId = hit?.ref.nodeId ?? spot?.nodeId;
6358
6509
  const node = nodeId ? this.getNode(nodeId) : void 0;
6359
6510
  const g = node?.type === "path" ? vectorStrokeWidthGeometry(node) : null;
6511
+ if (this.widthEdit) this.widthEdit = null;
6360
6512
  if (!node || node.type !== "path" || !g) {
6361
- if (this.widthPoint) {
6362
- this.widthPoint = null;
6363
- this.notify();
6364
- }
6513
+ if (!input.shift) this.widthPoints = [];
6514
+ this.notify();
6365
6515
  return;
6366
6516
  }
6367
6517
  const before = this.snapshot();
@@ -6370,6 +6520,8 @@ var VectorEditorController = class {
6370
6520
  let ref;
6371
6521
  let mode = "width";
6372
6522
  let side = "both";
6523
+ let group = [];
6524
+ let shift = false;
6373
6525
  let t;
6374
6526
  let subpath;
6375
6527
  if (hit) {
@@ -6377,8 +6529,20 @@ var VectorEditorController = class {
6377
6529
  ref = { nodeId: node.id, index: hit.ref.index, subpath: hit.ref.subpath };
6378
6530
  t = profile[ref.index]?.t ?? 0;
6379
6531
  subpath = hit.ref.subpath;
6380
- if (hit.ref.part === "center") mode = "move";
6532
+ if (hit.ref.part === "center") mode = input.alt && profile.length < VECTOR_WIDTH_PROFILE_MAX_POINTS ? "copy" : "move";
6381
6533
  else if (input.alt) side = hit.ref.part;
6534
+ const same = this.widthPoints.length > 0 && this.widthPoints[0].nodeId === node.id;
6535
+ const selected = same && this.widthPoints.some((r) => r.index === ref.index);
6536
+ if (input.shift && mode !== "copy") {
6537
+ if (selected) {
6538
+ this.widthPoints = this.widthPoints.filter((r) => r.index !== ref.index);
6539
+ this.notify();
6540
+ return;
6541
+ }
6542
+ this.widthPoints = same ? [...this.widthPoints, ref] : [ref];
6543
+ shift = true;
6544
+ } else if (mode === "copy" || !selected) this.widthPoints = [ref];
6545
+ if (mode !== "copy") group = this.widthPoints.filter((r) => r.index !== ref.index).map((r) => r.index);
6382
6546
  } else {
6383
6547
  const near2 = nearestStrokePosition(g.line, [local.x, local.y]);
6384
6548
  const inserted = insertWidthPoint(strokeWidthProfileOf(node), near2.t);
@@ -6387,17 +6551,21 @@ var VectorEditorController = class {
6387
6551
  t = near2.t;
6388
6552
  ref = { nodeId: node.id, index: inserted.index, subpath };
6389
6553
  if (input.alt) side = near2.side;
6554
+ this.widthPoints = [ref];
6390
6555
  }
6391
6556
  const at2 = strokePositionPoint(g.line, subpath, t);
6392
6557
  if (!at2) return;
6393
6558
  const base = { ...this.doc, nodes: mapTree(this.doc.nodes, (n) => n.id === node.id ? { ...n, strokeWidthProfile: profile } : n) };
6394
6559
  this.doc = base;
6395
- this.widthPoint = { nodeId: node.id, index: ref.index, subpath };
6396
6560
  this.drag = {
6397
6561
  kind: "width",
6398
6562
  ref,
6399
6563
  mode,
6400
6564
  side,
6565
+ source: ref.index,
6566
+ group,
6567
+ shift,
6568
+ renumber: null,
6401
6569
  origin: { x: at2.point[0], y: at2.point[1] },
6402
6570
  normal: { x: at2.tangent[1], y: -at2.tangent[0] },
6403
6571
  unit: g.unit,
@@ -6412,6 +6580,31 @@ var VectorEditorController = class {
6412
6580
  this.interaction = { type: "width", ref, mode, width: (w.left + w.right) * g.unit };
6413
6581
  this.notify(!hit);
6414
6582
  }
6583
+ /**
6584
+ * A width-tool slide to `t` on the dragged point's subpath lands exactly on another width
6585
+ * point (not `skip`) within the pick distance on screen: dropping a point onto another makes
6586
+ * a discontinuous point (two points at one `t`, the width steps there).
6587
+ */
6588
+ snapWidthT(drag, node, line2, t, skip) {
6589
+ const m = this.worldOf(node.id, drag.base);
6590
+ const at2 = strokePositionPoint(line2, drag.ref.subpath, t);
6591
+ if (!at2) return t;
6592
+ const here = this.documentToScreen(applyMatrix(m, { x: at2.point[0], y: at2.point[1] }));
6593
+ let best = t;
6594
+ let bestD = this.hitRadius;
6595
+ drag.profile.forEach((w, i) => {
6596
+ if (i === skip || drag.group.includes(i)) return;
6597
+ const q = strokePositionPoint(line2, drag.ref.subpath, w.t);
6598
+ if (!q) return;
6599
+ const s = this.documentToScreen(applyMatrix(m, { x: q.point[0], y: q.point[1] }));
6600
+ const d = Math.hypot(s.x - here.x, s.y - here.y);
6601
+ if (d <= bestD) {
6602
+ best = w.t;
6603
+ bestD = d;
6604
+ }
6605
+ });
6606
+ return best;
6607
+ }
6415
6608
  /** The nearest outline point of any visible, unlocked path within the pick distance of document point `p`. */
6416
6609
  nearestOutline(p) {
6417
6610
  let best = null;
@@ -6896,19 +7089,35 @@ var VectorEditorController = class {
6896
7089
  drag.moved = true;
6897
7090
  const node = findVectorNode(drag.base.nodes, drag.ref.nodeId);
6898
7091
  const local = this.toLocal(node.id, p, drag.base);
7092
+ const src = drag.profile[drag.source];
6899
7093
  let profile;
6900
- if (drag.mode === "move") {
7094
+ let index = drag.source;
7095
+ if (drag.mode !== "width") {
6901
7096
  const g = vectorStrokeWidthGeometry(node);
6902
7097
  const near2 = g ? nearestStrokePosition(g.line, [local.x, local.y], drag.ref.subpath) : null;
6903
- if (!near2) return;
6904
- profile = setWidthPoint(drag.profile, drag.ref.index, { t: near2.t });
7098
+ if (!g || !near2) return;
7099
+ const t = drag.group.length ? near2.t : this.snapWidthT(drag, node, g.line, near2.t, drag.source);
7100
+ if (drag.mode === "copy") {
7101
+ ({ points: profile, index } = insertWidthPoint(drag.profile, t, { left: src.left, right: src.right }, { step: true }));
7102
+ } else if (drag.group.length) {
7103
+ const shifted = shiftWidthPoints(drag.profile, [drag.source, ...drag.group], t - src.t);
7104
+ profile = shifted.points;
7105
+ drag.renumber = shifted.renumber;
7106
+ index = shifted.renumber.get(drag.source) ?? drag.source;
7107
+ } else profile = setWidthPoint(drag.profile, drag.source, { t });
6905
7108
  } else {
6906
7109
  const off = ((local.x - drag.origin.x) * drag.normal.x + (local.y - drag.origin.y) * drag.normal.y) / drag.unit;
6907
7110
  const patch = drag.side === "both" ? { left: Math.abs(off), right: Math.abs(off) } : drag.side === "left" ? { left: Math.max(0, off) } : { right: Math.max(0, -off) };
6908
- profile = setWidthPoint(drag.profile, drag.ref.index, patch);
7111
+ profile = setWidthPoint(drag.profile, drag.source, patch);
7112
+ if (drag.group.length) {
7113
+ const dl = profile[drag.source].left - src.left;
7114
+ const dr = profile[drag.source].right - src.right;
7115
+ for (const i of drag.group) profile = setWidthPoint(profile, i, { left: Math.max(0, drag.profile[i].left + dl), right: Math.max(0, drag.profile[i].right + dr) });
7116
+ }
6909
7117
  }
6910
7118
  this.doc = { ...drag.base, nodes: mapTree(drag.base.nodes, (n) => n.id === node.id ? { ...n, strokeWidthProfile: profile } : n) };
6911
- const w = profile[drag.ref.index];
7119
+ drag.ref = { ...drag.ref, index };
7120
+ const w = profile[index];
6912
7121
  this.interaction = { type: "width", ref: drag.ref, mode: drag.mode, width: (w.left + w.right) * drag.unit };
6913
7122
  this.notify(true);
6914
7123
  return;
@@ -7054,12 +7263,23 @@ var VectorEditorController = class {
7054
7263
  return;
7055
7264
  }
7056
7265
  case "width": {
7057
- if (drag.moved) return this.commit(drag.mode === "move" ? "Move Width Point" : "Width Point", drag.before);
7266
+ if (drag.moved) {
7267
+ const several = drag.group.length > 0;
7268
+ if (drag.mode === "copy") this.widthPoints = [drag.ref];
7269
+ else if (drag.mode === "move" && several) {
7270
+ this.widthPoints = [drag.source, ...drag.group].map((i) => ({ ...drag.ref, index: drag.renumber?.get(i) ?? i }));
7271
+ } else if (!several) this.widthPoints = [drag.ref];
7272
+ const label = drag.mode === "copy" ? "Copy Width Point" : drag.mode === "move" ? several ? "Move Width Points" : "Move Width Point" : several ? "Width Points" : "Width Point";
7273
+ return this.commit(label, drag.before);
7274
+ }
7058
7275
  if (drag.created) {
7059
7276
  this.doc = drag.before.doc;
7060
- this.widthPoint = null;
7277
+ this.widthPoints = [];
7061
7278
  this.notify(true);
7062
- } else this.notify();
7279
+ } else {
7280
+ if (!drag.shift && drag.group.length) this.widthPoints = [drag.ref];
7281
+ this.notify();
7282
+ }
7063
7283
  return;
7064
7284
  }
7065
7285
  case "builder":
@@ -7148,7 +7368,10 @@ var VectorEditorController = class {
7148
7368
  }
7149
7369
  this.restore(drag.before);
7150
7370
  }
7151
- /** Double click: select tool enters path editing, direct tool toggles an anchor or adds one. */
7371
+ /**
7372
+ * Double click: select tool enters path editing, direct tool toggles an anchor or adds one,
7373
+ * width tool opens the width-point editor (`widthPointEdit`).
7374
+ */
7152
7375
  doubleClick(screen) {
7153
7376
  const p = this.screenToDocument(screen);
7154
7377
  if (this.tool === "select") {
@@ -7176,6 +7399,16 @@ var VectorEditorController = class {
7176
7399
  }
7177
7400
  return;
7178
7401
  }
7402
+ if (this.tool === "width") {
7403
+ const hit = this.hitWidthHandle(screen);
7404
+ if (hit) {
7405
+ this.openWidthPointEdit(hit.ref);
7406
+ return;
7407
+ }
7408
+ const spot = this.strokeSpotAt(p);
7409
+ if (spot) this.openWidthPointEdit({ nodeId: spot.nodeId, subpath: spot.subpath, t: spot.t });
7410
+ return;
7411
+ }
7179
7412
  if (this.tool === "pen") this.finishPath();
7180
7413
  }
7181
7414
  // -------------------------------------------------------------------------
@@ -7236,8 +7469,9 @@ var VectorEditorController = class {
7236
7469
  switch (key) {
7237
7470
  case "Escape":
7238
7471
  if (this.drag) this.cancelDrag();
7239
- else if (this.tool === "width" && this.widthPoint) {
7240
- this.widthPoint = null;
7472
+ else if (this.widthEdit) this.closeWidthPointEdit();
7473
+ else if (this.tool === "width" && this.widthPoints.length) {
7474
+ this.widthPoints = [];
7241
7475
  this.notify();
7242
7476
  } else if (this.pen) this.finishPath();
7243
7477
  else if (this.tool === "direct" && this.anchors.length) {
@@ -7260,7 +7494,7 @@ var VectorEditorController = class {
7260
7494
  return false;
7261
7495
  case "Delete":
7262
7496
  case "Backspace":
7263
- if (this.tool === "width" && this.widthPoint) return this.removeWidthPoint(this.widthPoint).ok;
7497
+ if (this.tool === "width" && this.widthPoints.length) return this.removeWidthPoints().ok;
7264
7498
  if (this.pen) {
7265
7499
  const node = this.getNode(this.pen.nodeId);
7266
7500
  const sp = node.subpaths[this.pen.subpath];
@@ -7545,7 +7779,7 @@ function getVectorOverlayRenderData(state) {
7545
7779
  });
7546
7780
  const spot = state.tool === "width" && !it ? state.hover?.strokeSpot : null;
7547
7781
  const widthSpot = spot ? { ...sc(spot.point), left: sc(spot.left), right: sc(spot.right) } : null;
7548
- if (it?.type === "width") status = it.mode === "move" ? "Move width point" : `W ${fmt(it.width)}`;
7782
+ if (it?.type === "width") status = it.mode === "move" ? "Move width point" : it.mode === "copy" ? "Copy width point" : `W ${fmt(it.width)}`;
7549
7783
  let builder = null;
7550
7784
  if (state.builder) {
7551
7785
  const v2 = state.viewport;
@@ -7964,6 +8198,31 @@ function createVectorCommands(controller, options = {}) {
7964
8198
  return { ok: true, points: node?.type === "path" ? node.strokeWidthProfile ?? [] : [] };
7965
8199
  }
7966
8200
  },
8201
+ {
8202
+ ...base,
8203
+ id: `${prefix}setWidthPoint`,
8204
+ label: "Width Point\u2026",
8205
+ keywords: ["width point", "variable width", "stroke width at", "taper", "profile"],
8206
+ description: "Sets one width point of a path's stroke profile numerically: `index` names an existing point (see `vector.setWidthProfile`'s result), leave it out to add one at `t`. `left` / `right` are side widths in document units (how far the stroke reaches on each side), `t` the position along each subpath (0\u20131); a point moved onto another's `t` makes a step. Returns the profile.",
8207
+ params: {
8208
+ type: "object",
8209
+ properties: {
8210
+ id: { type: "string", description: "The path." },
8211
+ index: { type: "integer", minimum: 0 },
8212
+ t: { type: "number", minimum: 0, maximum: 1 },
8213
+ left: { type: "number", minimum: 0 },
8214
+ right: { type: "number", minimum: 0 }
8215
+ },
8216
+ required: ["id"]
8217
+ },
8218
+ undoable: true,
8219
+ run: (_ctx, params) => {
8220
+ if (!params?.id) fail("Pass the path `id`.");
8221
+ check(controller.setWidthPoint({ nodeId: params.id, index: params.index ?? null }, { t: params.t, left: params.left, right: params.right }));
8222
+ const node = controller.getNode(params.id);
8223
+ return { ok: true, points: node?.type === "path" ? node.strokeWidthProfile ?? [] : [] };
8224
+ }
8225
+ },
7967
8226
  {
7968
8227
  ...base,
7969
8228
  id: `${prefix}outlineStroke`,