@grida/svg-editor 1.0.0-alpha.13 → 1.0.0-alpha.15

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.
@@ -1,2176 +0,0 @@
1
- import cmath from "@grida/cmath";
2
- import { SVGPathData, SVGPathDataTransformer } from "@grida/svg/pathdata";
3
- import { svg_parse } from "@grida/svg/parse";
4
- //#region src/util/dom.ts
5
- /**
6
- * `true` when the document's active element is a text-input-like control
7
- * (input / textarea / contentEditable). Used by keymap + gesture defaults
8
- * to avoid hijacking keystrokes while the user is typing.
9
- */
10
- function is_text_input_focused() {
11
- if (typeof document === "undefined") return false;
12
- const el = document.activeElement;
13
- if (!el) return false;
14
- const tag = el.tagName;
15
- if (tag === "INPUT" || tag === "TEXTAREA") return true;
16
- if (el.isContentEditable) return true;
17
- return false;
18
- }
19
- //#endregion
20
- //#region src/core/group.ts
21
- /**
22
- * Tags that may be valid children of a `<g>` element. Wrapping any other
23
- * tag in `<g>` would produce content-model-invalid SVG (e.g. `<tspan>`
24
- * must live inside `<text>`; `<stop>` must live inside a gradient).
25
- */
26
- const STRUCTURAL_GRAPHICS_SET = new Set([
27
- "g",
28
- "defs",
29
- "svg",
30
- "use",
31
- "image",
32
- "switch",
33
- "foreignObject",
34
- "path",
35
- "rect",
36
- "circle",
37
- "ellipse",
38
- "line",
39
- "polyline",
40
- "polygon",
41
- "text",
42
- "a"
43
- ]);
44
- /**
45
- * Tags whose content model is constrained — a freshly-inserted `<g>`
46
- * here would either be invalid (text-content / gradient / filter
47
- * parents) or semantically meaningless (defs, symbol).
48
- */
49
- const CONSTRAINED_PARENT_SET = new Set([
50
- "text",
51
- "tspan",
52
- "defs",
53
- "clipPath",
54
- "mask",
55
- "pattern",
56
- "marker",
57
- "symbol",
58
- "filter",
59
- "linearGradient",
60
- "radialGradient",
61
- "animateMotion",
62
- "switch"
63
- ]);
64
- /**
65
- * Read-only policy gate. Returns a plan when grouping the given
66
- * selection is accepted; returns `null` (rejected) otherwise.
67
- *
68
- * Decision tree matches `packages/grida-svg-editor/docs/wg/feat-svg-editor/grouping.md`. Default
69
- * stance: "when unclear, reject."
70
- */
71
- function plan_group(doc, ids) {
72
- if (ids.length === 0) return null;
73
- const parent = doc.parent_of(ids[0]);
74
- if (parent === null) return null;
75
- for (const id of ids) {
76
- if (doc.parent_of(id) !== parent) return null;
77
- if (!STRUCTURAL_GRAPHICS_SET.has(doc.tag_of(id))) return null;
78
- }
79
- if (CONSTRAINED_PARENT_SET.has(doc.tag_of(parent))) return null;
80
- const siblings = doc.element_children_of(parent);
81
- const sibling_index = /* @__PURE__ */ new Map();
82
- for (let i = 0; i < siblings.length; i++) sibling_index.set(siblings[i], i);
83
- const indices = [];
84
- for (const id of ids) {
85
- const i = sibling_index.get(id);
86
- if (i === void 0) return null;
87
- indices.push(i);
88
- }
89
- const sorted = Array.from(new Set(indices)).sort((a, b) => a - b);
90
- for (let i = 1; i < sorted.length; i++) if (sorted[i] !== sorted[i - 1] + 1) return null;
91
- const children = sorted.map((i) => siblings[i]);
92
- const last_index = sorted[sorted.length - 1];
93
- const original_positions = /* @__PURE__ */ new Map();
94
- for (const i of sorted) original_positions.set(siblings[i], siblings[i + 1] ?? null);
95
- return {
96
- parent,
97
- insert_before: siblings[last_index + 1] ?? null,
98
- children,
99
- original_positions
100
- };
101
- }
102
- //#endregion
103
- //#region src/core/translate-pipeline/pipeline.ts
104
- /** The funnel. Threads `plan` through `stages` in order; aggregates
105
- * guide emissions. Pure: same inputs → same outputs. */
106
- function run_translate_pipeline(init, stages, ctx) {
107
- let plan = init;
108
- const guides = [];
109
- for (const stage of stages) {
110
- const out = stage.run(plan, ctx);
111
- plan = out.plan;
112
- if (out.emit?.guide) guides.push(out.emit.guide);
113
- }
114
- return {
115
- plan,
116
- guides
117
- };
118
- }
119
- //#endregion
120
- //#region src/core/transform/parse.ts
121
- /** SVG `<number>` production (spec-aligned subset). */
122
- const SVG_NUMBER_SRC = "[+-]?(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][+-]?\\d+)?";
123
- /** Recognized function names. Names are case-sensitive per SVG 1.1 §7.6. */
124
- const FUNCTION_RE = /\s*([A-Za-z]+)\s*\(([^)]*)\)\s*/y;
125
- /** Number tokens, comma- or whitespace-separated, inside a single call. */
126
- const NUMBER_GLOBAL_RE = new RegExp(SVG_NUMBER_SRC, "g");
127
- /** Identity / no-op string forms. SVG 2 §11.6.1 lets `transform="none"`
128
- * mean the identity transform. CSS-wide keywords are normalized at
129
- * parse time per SVG 2 cascade. */
130
- const IDENTITY_RE = /^\s*(?:none|inherit|unset|initial|revert|revert-layer)?\s*$/;
131
- function parse_args(args) {
132
- const tokens = args.match(NUMBER_GLOBAL_RE);
133
- if (!tokens) return [];
134
- const out = [];
135
- for (const t of tokens) {
136
- const n = parseFloat(t);
137
- if (!Number.isFinite(n)) return null;
138
- out.push(n);
139
- }
140
- return out;
141
- }
142
- function build_op(name, a) {
143
- switch (name) {
144
- case "matrix":
145
- if (a.length !== 6) return null;
146
- return {
147
- type: "matrix",
148
- a: a[0],
149
- b: a[1],
150
- c: a[2],
151
- d: a[3],
152
- e: a[4],
153
- f: a[5]
154
- };
155
- case "translate":
156
- if (a.length !== 1 && a.length !== 2) return null;
157
- return {
158
- type: "translate",
159
- tx: a[0],
160
- ty: a.length === 2 ? a[1] : 0
161
- };
162
- case "rotate":
163
- if (a.length !== 1 && a.length !== 3) return null;
164
- return {
165
- type: "rotate",
166
- angle: a[0],
167
- cx: a.length === 3 ? a[1] : 0,
168
- cy: a.length === 3 ? a[2] : 0,
169
- explicit_pivot: a.length === 3
170
- };
171
- case "scale":
172
- if (a.length !== 1 && a.length !== 2) return null;
173
- return {
174
- type: "scale",
175
- sx: a[0],
176
- sy: a.length === 2 ? a[1] : a[0]
177
- };
178
- case "skewX":
179
- if (a.length !== 1) return null;
180
- return {
181
- type: "skewX",
182
- angle: a[0]
183
- };
184
- case "skewY":
185
- if (a.length !== 1) return null;
186
- return {
187
- type: "skewY",
188
- angle: a[0]
189
- };
190
- default: return null;
191
- }
192
- }
193
- /**
194
- * Parse a `transform=""` attribute string into a list of typed ops.
195
- *
196
- * parse_transform_list(null) => []
197
- * parse_transform_list("") => []
198
- * parse_transform_list("none") => []
199
- * parse_transform_list("translate(10 20)") => [{type:"translate",tx:10,ty:20}]
200
- * parse_transform_list("rotate(30 50 50)") => [{type:"rotate",angle:30,cx:50,cy:50}]
201
- * parse_transform_list("foo(1)") => null // unknown function
202
- * parse_transform_list("matrix(1 0 0)") => null // wrong arg count
203
- */
204
- function parse_transform_list(input) {
205
- if (input === null) return [];
206
- if (IDENTITY_RE.test(input)) return [];
207
- const ops = [];
208
- FUNCTION_RE.lastIndex = 0;
209
- let pos = 0;
210
- while (pos < input.length) {
211
- FUNCTION_RE.lastIndex = pos;
212
- const m = FUNCTION_RE.exec(input);
213
- if (!m || m.index !== pos) return null;
214
- const name = m[1];
215
- const args = parse_args(m[2]);
216
- if (args === null) return null;
217
- const op = build_op(name, args);
218
- if (!op) return null;
219
- ops.push(op);
220
- pos = FUNCTION_RE.lastIndex;
221
- while (pos < input.length && /[\s,]/.test(input[pos])) pos++;
222
- }
223
- return ops;
224
- }
225
- //#endregion
226
- //#region src/core/transform/emit.ts
227
- function n(x) {
228
- return String(x);
229
- }
230
- function emit_op(op) {
231
- switch (op.type) {
232
- case "matrix": return `matrix(${n(op.a)} ${n(op.b)} ${n(op.c)} ${n(op.d)} ${n(op.e)} ${n(op.f)})`;
233
- case "translate": return `translate(${n(op.tx)} ${n(op.ty)})`;
234
- case "rotate": return `rotate(${n(op.angle)} ${n(op.cx)} ${n(op.cy)})`;
235
- case "scale": return `scale(${n(op.sx)} ${n(op.sy)})`;
236
- case "skewX": return `skewX(${n(op.angle)})`;
237
- case "skewY": return `skewY(${n(op.angle)})`;
238
- }
239
- }
240
- /** Concatenate ops with single-space separator. Returns `""` for empty
241
- * list (identity). Caller decides whether to write `transform=""`,
242
- * `transform="none"`, or remove the attribute entirely. */
243
- function emit_transform_list(ops) {
244
- return ops.map(emit_op).join(" ");
245
- }
246
- //#endregion
247
- //#region src/core/transform/classify.ts
248
- function is_identity_translate(op) {
249
- return op.type === "translate" && op.tx === 0 && op.ty === 0;
250
- }
251
- function is_identity_rotate(op) {
252
- return op.type === "rotate" && op.angle === 0;
253
- }
254
- function classify(ops) {
255
- const trimmed = ops.filter((op) => !is_identity_translate(op) && !is_identity_rotate(op));
256
- if (trimmed.length === 0) return "identity";
257
- if (trimmed.length === 1) {
258
- if (trimmed[0].type === "translate") return "leading_translate_only";
259
- if (trimmed[0].type === "rotate") return "single_rotate_only";
260
- return "mixed";
261
- }
262
- if (trimmed.length === 2 && trimmed[0].type === "translate" && trimmed[1].type === "rotate") return "leading_translate_then_single_rotate";
263
- return "mixed";
264
- }
265
- //#endregion
266
- //#region src/core/transform/recompose.ts
267
- function recompose_with_pivot(ops, new_cx, new_cy) {
268
- const tx_op = ops.find((op) => op.type === "translate");
269
- const tx = tx_op?.type === "translate" ? tx_op.tx : 0;
270
- const ty = tx_op?.type === "translate" ? tx_op.ty : 0;
271
- return emit_transform_list(ops.map((op) => op.type === "rotate" ? {
272
- type: "rotate",
273
- angle: op.angle,
274
- cx: new_cx - tx,
275
- cy: new_cy - ty,
276
- explicit_pivot: true
277
- } : op));
278
- }
279
- //#endregion
280
- //#region src/core/transform/project.ts
281
- function op_matrix(op) {
282
- switch (op.type) {
283
- case "matrix": return [[
284
- op.a,
285
- op.c,
286
- op.e
287
- ], [
288
- op.b,
289
- op.d,
290
- op.f
291
- ]];
292
- case "translate": return [[
293
- 1,
294
- 0,
295
- op.tx
296
- ], [
297
- 0,
298
- 1,
299
- op.ty
300
- ]];
301
- case "rotate": {
302
- const t = op.angle * Math.PI / 180;
303
- const cos = Math.cos(t);
304
- const sin = Math.sin(t);
305
- return [[
306
- cos,
307
- -sin,
308
- op.cx - op.cx * cos + op.cy * sin
309
- ], [
310
- sin,
311
- cos,
312
- op.cy - op.cx * sin - op.cy * cos
313
- ]];
314
- }
315
- case "scale": return [[
316
- op.sx,
317
- 0,
318
- 0
319
- ], [
320
- 0,
321
- op.sy,
322
- 0
323
- ]];
324
- case "skewX": {
325
- const t = op.angle * Math.PI / 180;
326
- return [[
327
- 1,
328
- Math.tan(t),
329
- 0
330
- ], [
331
- 0,
332
- 1,
333
- 0
334
- ]];
335
- }
336
- case "skewY": {
337
- const t = op.angle * Math.PI / 180;
338
- return [[
339
- 1,
340
- 0,
341
- 0
342
- ], [
343
- Math.tan(t),
344
- 1,
345
- 0
346
- ]];
347
- }
348
- }
349
- }
350
- /** Compose a transform-list into a single 2×3 affine. Ops compose
351
- * source-order = left-to-right multiplication: `transform="A B C"` maps
352
- * a column-vector point `p` as `A · B · C · p` (per SVG 1.1 §7.5). */
353
- function compose(ops) {
354
- let m = cmath.transform.identity;
355
- for (const op of ops) m = cmath.transform.multiply(m, op_matrix(op));
356
- return m;
357
- }
358
- /** Axis-aligned doc-space bounding box of `local` under `transform_str`.
359
- * Returns `local` unchanged when the transform is absent / empty /
360
- * unparseable (i.e. local-frame ≡ doc-space in those cases). */
361
- function project_local_bbox(local, transform_str) {
362
- if (!transform_str) return local;
363
- const ops = parse_transform_list(transform_str);
364
- if (ops === null || ops.length === 0) return local;
365
- const m = compose(ops);
366
- const projected = [
367
- [local.x, local.y],
368
- [local.x + local.width, local.y],
369
- [local.x + local.width, local.y + local.height],
370
- [local.x, local.y + local.height]
371
- ].map((p) => cmath.vector2.transform(p, m));
372
- return cmath.rect.fromPointsOrZero(projected);
373
- }
374
- //#endregion
375
- //#region src/core/hit-shape-svg.ts
376
- /** Tags that never participate in picking. Containers and non-rendering
377
- * metadata. The root `<svg>` is in this set — root pickability is a
378
- * host decision (measurement HUD wants it, selection doesn't), gated
379
- * by an explicit `allow_root` flag at the caller. */
380
- const TRANSPARENT_TAGS = new Set([
381
- "g",
382
- "svg",
383
- "defs",
384
- "symbol",
385
- "clipPath",
386
- "mask",
387
- "marker",
388
- "pattern",
389
- "linearGradient",
390
- "radialGradient",
391
- "stop",
392
- "filter",
393
- "title",
394
- "desc",
395
- "metadata",
396
- "style",
397
- "script"
398
- ]);
399
- function is_transparent_tag(tag) {
400
- return TRANSPARENT_TAGS.has(tag);
401
- }
402
- function num$1(doc, id, name, fallback = 0) {
403
- return svg_parse.parse_number(doc.get_attr(id, name), fallback);
404
- }
405
- /**
406
- * Hit-shape derived from document attributes. Returns `null` when the
407
- * node has no derivable shape (transparent tag, malformed attrs, or a
408
- * `transform=` we can't compose cleanly — matrix / scale / skew / mixed).
409
- *
410
- * For `transform=` values that classify to identity / leading-translate /
411
- * single-rotate / translate-then-rotate, the returned shape is the
412
- * post-transform polygon/segment (rotated rects become 4-corner polygons),
413
- * so picking lands on the rendered outline rather than the silent AABB
414
- * fallback in `SvgHitShapeDriver`.
415
- *
416
- * Caveat: see "Known issues" #1 at the top of this file — ancestor CTM
417
- * is still NOT composed here. Inside a `<g transform="...">` the shape
418
- * is still in the ancestor-relative frame.
419
- */
420
- function hit_shape_of_doc(doc, id) {
421
- const tag = doc.tag_of(id);
422
- const ops = parse_transform_list(doc.get_attr(id, "transform"));
423
- if (ops === null) return null;
424
- if (classify(ops) === "mixed") return null;
425
- const xform = pick_affine(ops);
426
- const has_rotation = xform.angle_rad !== 0;
427
- switch (tag) {
428
- case "rect": {
429
- const x = num$1(doc, id, "x");
430
- const y = num$1(doc, id, "y");
431
- const w = num$1(doc, id, "width");
432
- const h = num$1(doc, id, "height");
433
- if (!has_rotation) {
434
- const t = xform.translate;
435
- return {
436
- kind: "rect",
437
- x: x + t.x,
438
- y: y + t.y,
439
- width: w,
440
- height: h
441
- };
442
- }
443
- return {
444
- kind: "polygon",
445
- pts: [
446
- {
447
- x,
448
- y
449
- },
450
- {
451
- x: x + w,
452
- y
453
- },
454
- {
455
- x: x + w,
456
- y: y + h
457
- },
458
- {
459
- x,
460
- y: y + h
461
- }
462
- ].map((p) => transform_point(p, xform)),
463
- closed: true
464
- };
465
- }
466
- case "circle": {
467
- const cx = num$1(doc, id, "cx");
468
- const cy = num$1(doc, id, "cy");
469
- const r = num$1(doc, id, "r");
470
- const tc = transform_point({
471
- x: cx,
472
- y: cy
473
- }, xform);
474
- return {
475
- kind: "ellipse",
476
- cx: tc.x,
477
- cy: tc.y,
478
- rx: r,
479
- ry: r
480
- };
481
- }
482
- case "ellipse": {
483
- const cx = num$1(doc, id, "cx");
484
- const cy = num$1(doc, id, "cy");
485
- const rx = num$1(doc, id, "rx");
486
- const ry = num$1(doc, id, "ry");
487
- if (has_rotation) return null;
488
- const tc = transform_point({
489
- x: cx,
490
- y: cy
491
- }, xform);
492
- return {
493
- kind: "ellipse",
494
- cx: tc.x,
495
- cy: tc.y,
496
- rx,
497
- ry
498
- };
499
- }
500
- case "line": return {
501
- kind: "segment",
502
- a: transform_point({
503
- x: num$1(doc, id, "x1"),
504
- y: num$1(doc, id, "y1")
505
- }, xform),
506
- b: transform_point({
507
- x: num$1(doc, id, "x2"),
508
- y: num$1(doc, id, "y2")
509
- }, xform)
510
- };
511
- case "polyline": {
512
- const pts = svg_parse.parse_points(doc.get_attr(id, "points") ?? "");
513
- if (pts.length === 0) return null;
514
- return {
515
- kind: "polyline",
516
- pts: pts.map((q) => transform_point({
517
- x: q.x,
518
- y: q.y
519
- }, xform)),
520
- closed: false
521
- };
522
- }
523
- case "polygon": {
524
- const pts = svg_parse.parse_points(doc.get_attr(id, "points") ?? "");
525
- if (pts.length === 0) return null;
526
- return {
527
- kind: "polygon",
528
- pts: pts.map((q) => transform_point({
529
- x: q.x,
530
- y: q.y
531
- }, xform)),
532
- closed: true
533
- };
534
- }
535
- case "path": {
536
- const d = doc.get_attr(id, "d") ?? "";
537
- const pts = path_control_polyline(d);
538
- if (pts.length === 0) return null;
539
- return {
540
- kind: "path",
541
- pts: pts.map((p) => transform_point(p, xform)),
542
- closed: /[zZ]\s*$/.test(d)
543
- };
544
- }
545
- default: return null;
546
- }
547
- }
548
- function pick_affine(ops) {
549
- let tx = 0;
550
- let ty = 0;
551
- let pivot = {
552
- x: 0,
553
- y: 0
554
- };
555
- let angle_rad = 0;
556
- for (const op of ops) if (op.type === "translate") {
557
- tx = op.tx;
558
- ty = op.ty;
559
- } else if (op.type === "rotate") {
560
- pivot = {
561
- x: op.cx,
562
- y: op.cy
563
- };
564
- angle_rad = op.angle * Math.PI / 180;
565
- }
566
- return {
567
- translate: {
568
- x: tx,
569
- y: ty
570
- },
571
- pivot,
572
- angle_rad
573
- };
574
- }
575
- /** Apply (rotate around pivot by angle_rad, then translate). Mirrors the
576
- * SVG transform-list semantic order: `translate(...) rotate(...)` means
577
- * matrix product T·R applied to P → translation moves the *rotated*
578
- * point. */
579
- function transform_point(p, x) {
580
- let q = p;
581
- if (x.angle_rad !== 0) {
582
- const c = Math.cos(x.angle_rad);
583
- const s = Math.sin(x.angle_rad);
584
- const dx = q.x - x.pivot.x;
585
- const dy = q.y - x.pivot.y;
586
- q = {
587
- x: x.pivot.x + dx * c - dy * s,
588
- y: x.pivot.y + dx * s + dy * c
589
- };
590
- }
591
- if (x.translate.x !== 0 || x.translate.y !== 0) q = {
592
- x: q.x + x.translate.x,
593
- y: q.y + x.translate.y
594
- };
595
- return q;
596
- }
597
- /** Path-`d` → control-polyline approximation. Each command contributes
598
- * any control points it carries (Q: x1/y1; C: x1/y1 + x2/y2) plus its
599
- * endpoint. T/S shorthand and A arcs contribute only the endpoint at
600
- * this fidelity. Returns `[]` for unparseable input. */
601
- function path_control_polyline(d) {
602
- if (!d) return [];
603
- let path;
604
- try {
605
- path = new SVGPathData(d).toAbs();
606
- } catch {
607
- return [];
608
- }
609
- const out = [];
610
- for (const cmd of path.commands) {
611
- const c = cmd;
612
- if (typeof c.x1 === "number" && typeof c.y1 === "number") out.push({
613
- x: c.x1,
614
- y: c.y1
615
- });
616
- if (typeof c.x2 === "number" && typeof c.y2 === "number") out.push({
617
- x: c.x2,
618
- y: c.y2
619
- });
620
- if (typeof c.x === "number" && typeof c.y === "number") out.push({
621
- x: c.x,
622
- y: c.y
623
- });
624
- }
625
- return out;
626
- }
627
- //#endregion
628
- //#region src/core/intents.ts
629
- function num(doc, id, name, fallback = 0) {
630
- return svg_parse.parse_number(doc.get_attr(id, name), fallback);
631
- }
632
- function capture_translate_baseline(doc, id) {
633
- const tag = doc.tag_of(id);
634
- const own_transform = doc.get_attr(id, "transform");
635
- if (own_transform !== null || tag === "g") return {
636
- type: "viaTransform",
637
- transform: own_transform
638
- };
639
- switch (tag) {
640
- case "rect": return {
641
- type: "rect",
642
- x: num(doc, id, "x"),
643
- y: num(doc, id, "y")
644
- };
645
- case "circle": return {
646
- type: "circle",
647
- cx: num(doc, id, "cx"),
648
- cy: num(doc, id, "cy")
649
- };
650
- case "ellipse": return {
651
- type: "ellipse",
652
- cx: num(doc, id, "cx"),
653
- cy: num(doc, id, "cy")
654
- };
655
- case "line": return {
656
- type: "line",
657
- x1: num(doc, id, "x1"),
658
- y1: num(doc, id, "y1"),
659
- x2: num(doc, id, "x2"),
660
- y2: num(doc, id, "y2")
661
- };
662
- case "polyline": return {
663
- type: "polyline",
664
- points: doc.get_attr(id, "points") ?? ""
665
- };
666
- case "polygon": return {
667
- type: "polygon",
668
- points: doc.get_attr(id, "points") ?? ""
669
- };
670
- case "path": return {
671
- type: "path",
672
- d: doc.get_attr(id, "d") ?? ""
673
- };
674
- case "text": return {
675
- type: "text",
676
- x: num(doc, id, "x"),
677
- y: num(doc, id, "y")
678
- };
679
- case "tspan": return {
680
- type: "tspan",
681
- x: num(doc, id, "x"),
682
- y: num(doc, id, "y")
683
- };
684
- case "image": return {
685
- type: "image",
686
- x: num(doc, id, "x"),
687
- y: num(doc, id, "y")
688
- };
689
- case "use": return {
690
- type: "use",
691
- x: num(doc, id, "x"),
692
- y: num(doc, id, "y")
693
- };
694
- default: return { type: "unsupported" };
695
- }
696
- }
697
- /**
698
- * Batch variant of {@link capture_translate_baseline} — captures baselines
699
- * for a set of ids into a `ReadonlyMap`. Used wherever a translate
700
- * operation needs to remember the pre-translation state of multiple
701
- * nodes (drag gesture, RPC, dwell detection).
702
- */
703
- function capture_translate_baselines(doc, ids) {
704
- const out = /* @__PURE__ */ new Map();
705
- for (const id of ids) out.set(id, capture_translate_baseline(doc, id));
706
- return out;
707
- }
708
- /**
709
- * Representative anchor point of a `TranslateBaseline` — the attribute
710
- * coordinate `apply_translate` offsets. Used by callers that need to
711
- * align baselines to an external lattice (pixel-grid, custom snap).
712
- *
713
- * Rules per element kind:
714
- * - rect / text / tspan / image / use: `(x, y)`
715
- * - circle / ellipse: `(cx, cy)` (no radius subtracted — consistent
716
- * anchor across all kinds, not a true bounds top-left)
717
- * - line / polyline / polygon: min of endpoints / points
718
- * - path: first M/m command's coords (best-effort; the path-data
719
- * layer would be needed for a tight bbox)
720
- * - viaTransform / unsupported: `null` — no document-space anchor
721
- * available without the doc itself
722
- */
723
- function baseline_anchor(b) {
724
- switch (b.type) {
725
- case "rect":
726
- case "text":
727
- case "tspan":
728
- case "image":
729
- case "use": return {
730
- x: b.x,
731
- y: b.y
732
- };
733
- case "circle":
734
- case "ellipse": return {
735
- x: b.cx,
736
- y: b.cy
737
- };
738
- case "line": return {
739
- x: Math.min(b.x1, b.x2),
740
- y: Math.min(b.y1, b.y2)
741
- };
742
- case "polyline":
743
- case "polygon": return svg_parse.points_top_left(svg_parse.parse_points(b.points));
744
- case "path": return svg_parse.parse_path_first_move(b.d);
745
- case "viaTransform": {
746
- const ops = parse_transform_list(b.transform);
747
- if (ops === null) return null;
748
- for (const op of ops) {
749
- if (op.type === "translate") return {
750
- x: op.tx,
751
- y: op.ty
752
- };
753
- break;
754
- }
755
- return null;
756
- }
757
- case "unsupported": return null;
758
- }
759
- }
760
- /**
761
- * Top-left of the union over a collection of `TranslateBaseline`s.
762
- * Returns `null` when no baseline yields an anchor (e.g. all
763
- * `viaTransform` / `unsupported`). Callers fall through (no alignment).
764
- */
765
- function baseline_union_top_left(baselines) {
766
- const anchors = [];
767
- for (const b of baselines.values()) {
768
- const p = baseline_anchor(b);
769
- if (p) anchors.push(p);
770
- }
771
- return svg_parse.points_top_left(anchors);
772
- }
773
- function shift_points_string(points, dx, dy) {
774
- if (dx === 0 && dy === 0) return points;
775
- return svg_parse.parse_points(points).map((p) => `${p.x + dx},${p.y + dy}`).join(" ");
776
- }
777
- function compose_leading_translate(existing, dx, dy) {
778
- if (dx === 0 && dy === 0) return existing ? existing : null;
779
- if (!existing) return `translate(${dx} ${dy})`;
780
- const lead = svg_parse.parse_leading_translate(existing);
781
- if (lead) {
782
- const tx = lead.tx + dx;
783
- const ty = lead.ty + dy;
784
- return lead.rest ? `translate(${tx} ${ty}) ${lead.rest}` : `translate(${tx} ${ty})`;
785
- }
786
- return `translate(${dx} ${dy}) ${existing}`;
787
- }
788
- function shift_path_d(d, dx, dy) {
789
- if (dx === 0 && dy === 0) return d;
790
- try {
791
- return new SVGPathData(d).transform(SVGPathDataTransformer.TRANSLATE(dx, dy)).encode();
792
- } catch {
793
- return d;
794
- }
795
- }
796
- function apply_translate(doc, id, baseline, dx, dy) {
797
- switch (baseline.type) {
798
- case "viaTransform":
799
- doc.set_attr(id, "transform", compose_leading_translate(baseline.transform ?? "", dx, dy));
800
- return;
801
- case "rect":
802
- case "image":
803
- case "use":
804
- case "text":
805
- case "tspan":
806
- doc.set_attr(id, "x", String(baseline.x + dx));
807
- doc.set_attr(id, "y", String(baseline.y + dy));
808
- return;
809
- case "circle":
810
- case "ellipse":
811
- doc.set_attr(id, "cx", String(baseline.cx + dx));
812
- doc.set_attr(id, "cy", String(baseline.cy + dy));
813
- return;
814
- case "line":
815
- doc.set_attr(id, "x1", String(baseline.x1 + dx));
816
- doc.set_attr(id, "y1", String(baseline.y1 + dy));
817
- doc.set_attr(id, "x2", String(baseline.x2 + dx));
818
- doc.set_attr(id, "y2", String(baseline.y2 + dy));
819
- return;
820
- case "polyline":
821
- case "polygon":
822
- doc.set_attr(id, "points", shift_points_string(baseline.points, dx, dy));
823
- return;
824
- case "path":
825
- doc.set_attr(id, "d", shift_path_d(baseline.d, dx, dy));
826
- return;
827
- case "unsupported": return;
828
- }
829
- }
830
- function is_resizable(tag) {
831
- switch (tag) {
832
- case "rect":
833
- case "image":
834
- case "use":
835
- case "circle":
836
- case "ellipse":
837
- case "line":
838
- case "polyline":
839
- case "polygon":
840
- case "path":
841
- case "text": return true;
842
- default: return false;
843
- }
844
- }
845
- function capture_resize_baseline(doc, id, bbox) {
846
- const tag = doc.tag_of(id);
847
- let attrs;
848
- switch (tag) {
849
- case "rect":
850
- attrs = {
851
- kind: "rect",
852
- x: num(doc, id, "x"),
853
- y: num(doc, id, "y"),
854
- w: num(doc, id, "width", bbox.width),
855
- h: num(doc, id, "height", bbox.height)
856
- };
857
- break;
858
- case "image":
859
- attrs = {
860
- kind: "image",
861
- x: num(doc, id, "x"),
862
- y: num(doc, id, "y"),
863
- w: num(doc, id, "width", bbox.width),
864
- h: num(doc, id, "height", bbox.height)
865
- };
866
- break;
867
- case "use":
868
- attrs = {
869
- kind: "use",
870
- x: num(doc, id, "x"),
871
- y: num(doc, id, "y"),
872
- w: num(doc, id, "width", bbox.width),
873
- h: num(doc, id, "height", bbox.height)
874
- };
875
- break;
876
- case "circle":
877
- attrs = {
878
- kind: "circle",
879
- cx: num(doc, id, "cx"),
880
- cy: num(doc, id, "cy"),
881
- r: num(doc, id, "r")
882
- };
883
- break;
884
- case "ellipse":
885
- attrs = {
886
- kind: "ellipse",
887
- cx: num(doc, id, "cx"),
888
- cy: num(doc, id, "cy"),
889
- rx: num(doc, id, "rx"),
890
- ry: num(doc, id, "ry")
891
- };
892
- break;
893
- case "line":
894
- attrs = {
895
- kind: "line",
896
- x1: num(doc, id, "x1"),
897
- y1: num(doc, id, "y1"),
898
- x2: num(doc, id, "x2"),
899
- y2: num(doc, id, "y2")
900
- };
901
- break;
902
- case "polyline":
903
- attrs = {
904
- kind: "polyline",
905
- points: doc.get_attr(id, "points") ?? ""
906
- };
907
- break;
908
- case "polygon":
909
- attrs = {
910
- kind: "polygon",
911
- points: doc.get_attr(id, "points") ?? ""
912
- };
913
- break;
914
- case "path":
915
- attrs = {
916
- kind: "path",
917
- d: doc.get_attr(id, "d") ?? ""
918
- };
919
- break;
920
- case "text":
921
- attrs = {
922
- kind: "text",
923
- x: num(doc, id, "x"),
924
- y: num(doc, id, "y"),
925
- fontSize: parseFloat(doc.get_attr(id, "font-size") ?? "16") || 16
926
- };
927
- break;
928
- default: attrs = { kind: "unsupported" };
929
- }
930
- return {
931
- bbox,
932
- attrs
933
- };
934
- }
935
- function compute_resize_factors(baseline, dir, dx, dy, shift) {
936
- const b = baseline.bbox;
937
- let anchorX = 0;
938
- let anchorY = 0;
939
- let baseHX = 0;
940
- let baseHY = 0;
941
- let affectsX = true;
942
- let affectsY = true;
943
- switch (dir) {
944
- case "nw":
945
- anchorX = b.x + b.width;
946
- anchorY = b.y + b.height;
947
- baseHX = b.x;
948
- baseHY = b.y;
949
- break;
950
- case "n":
951
- anchorX = b.x + b.width / 2;
952
- anchorY = b.y + b.height;
953
- baseHX = b.x + b.width / 2;
954
- baseHY = b.y;
955
- affectsX = false;
956
- break;
957
- case "ne":
958
- anchorX = b.x;
959
- anchorY = b.y + b.height;
960
- baseHX = b.x + b.width;
961
- baseHY = b.y;
962
- break;
963
- case "e":
964
- anchorX = b.x;
965
- anchorY = b.y + b.height / 2;
966
- baseHX = b.x + b.width;
967
- baseHY = b.y + b.height / 2;
968
- affectsY = false;
969
- break;
970
- case "se":
971
- anchorX = b.x;
972
- anchorY = b.y;
973
- baseHX = b.x + b.width;
974
- baseHY = b.y + b.height;
975
- break;
976
- case "s":
977
- anchorX = b.x + b.width / 2;
978
- anchorY = b.y;
979
- baseHX = b.x + b.width / 2;
980
- baseHY = b.y + b.height;
981
- affectsX = false;
982
- break;
983
- case "sw":
984
- anchorX = b.x + b.width;
985
- anchorY = b.y;
986
- baseHX = b.x;
987
- baseHY = b.y + b.height;
988
- break;
989
- case "w":
990
- anchorX = b.x + b.width;
991
- anchorY = b.y + b.height / 2;
992
- baseHX = b.x;
993
- baseHY = b.y + b.height / 2;
994
- affectsY = false;
995
- break;
996
- }
997
- const newHX = baseHX + (affectsX ? dx : 0);
998
- const newHY = baseHY + (affectsY ? dy : 0);
999
- const denomX = baseHX - anchorX;
1000
- const denomY = baseHY - anchorY;
1001
- let sx = affectsX && denomX !== 0 ? (newHX - anchorX) / denomX : 1;
1002
- let sy = affectsY && denomY !== 0 ? (newHY - anchorY) / denomY : 1;
1003
- if (shift && affectsX && affectsY) {
1004
- const mag = Math.max(Math.abs(sx), Math.abs(sy));
1005
- sx = sx >= 0 ? mag : -mag;
1006
- sy = sy >= 0 ? mag : -mag;
1007
- }
1008
- sx = Math.max(.001, sx);
1009
- sy = Math.max(.001, sy);
1010
- return {
1011
- sx,
1012
- sy,
1013
- origin: {
1014
- x: anchorX,
1015
- y: anchorY
1016
- }
1017
- };
1018
- }
1019
- function scale_points_string(points, origin, sx, sy) {
1020
- return svg_parse.parse_points(points).map((p) => {
1021
- return `${origin.x + (p.x - origin.x) * sx},${origin.y + (p.y - origin.y) * sy}`;
1022
- }).join(" ");
1023
- }
1024
- function scale_path_d(d, origin, sx, sy) {
1025
- try {
1026
- const e = origin.x * (1 - sx);
1027
- const f = origin.y * (1 - sy);
1028
- return new SVGPathData(d).transform(SVGPathDataTransformer.MATRIX(sx, 0, 0, sy, e, f)).encode();
1029
- } catch {
1030
- return d;
1031
- }
1032
- }
1033
- function bbox_center(points) {
1034
- if (points.length === 0) return null;
1035
- const r = cmath.rect.fromPointsOrZero(points.map((p) => [p.x, p.y]));
1036
- return {
1037
- cx: r.x + r.width / 2,
1038
- cy: r.y + r.height / 2
1039
- };
1040
- }
1041
- function new_local_center(doc, id) {
1042
- switch (doc.tag_of(id)) {
1043
- case "rect":
1044
- case "image":
1045
- case "use": {
1046
- const x = num(doc, id, "x");
1047
- const y = num(doc, id, "y");
1048
- return {
1049
- cx: x + num(doc, id, "width") / 2,
1050
- cy: y + num(doc, id, "height") / 2
1051
- };
1052
- }
1053
- case "circle":
1054
- case "ellipse": return {
1055
- cx: num(doc, id, "cx"),
1056
- cy: num(doc, id, "cy")
1057
- };
1058
- case "line": {
1059
- const x1 = num(doc, id, "x1");
1060
- const y1 = num(doc, id, "y1");
1061
- const x2 = num(doc, id, "x2");
1062
- const y2 = num(doc, id, "y2");
1063
- return {
1064
- cx: (x1 + x2) / 2,
1065
- cy: (y1 + y2) / 2
1066
- };
1067
- }
1068
- case "polyline":
1069
- case "polygon": {
1070
- const points = doc.get_attr(id, "points");
1071
- if (!points) return null;
1072
- return bbox_center(svg_parse.parse_points(points));
1073
- }
1074
- case "path": {
1075
- const d = doc.get_attr(id, "d");
1076
- if (!d) return null;
1077
- return bbox_center(path_control_polyline(d));
1078
- }
1079
- default: return null;
1080
- }
1081
- }
1082
- /** Translate every local-frame coord of `id`'s geometry by (dx, dy). The
1083
- * primitive arms write intrinsic attrs directly — we can't route through
1084
- * `apply_translate` here because `capture_translate_baseline` returns
1085
- * `viaTransform` whenever the node has a `transform=`, and that branch
1086
- * prepends a `translate()` to the transform string, which would clobber
1087
- * the pivot rewrite the caller is about to do. */
1088
- function shift_geometry(doc, id, dx, dy) {
1089
- switch (doc.tag_of(id)) {
1090
- case "rect":
1091
- case "image":
1092
- case "use":
1093
- doc.set_attr(id, "x", String(num(doc, id, "x") + dx));
1094
- doc.set_attr(id, "y", String(num(doc, id, "y") + dy));
1095
- return;
1096
- case "circle":
1097
- case "ellipse":
1098
- doc.set_attr(id, "cx", String(num(doc, id, "cx") + dx));
1099
- doc.set_attr(id, "cy", String(num(doc, id, "cy") + dy));
1100
- return;
1101
- case "line":
1102
- doc.set_attr(id, "x1", String(num(doc, id, "x1") + dx));
1103
- doc.set_attr(id, "y1", String(num(doc, id, "y1") + dy));
1104
- doc.set_attr(id, "x2", String(num(doc, id, "x2") + dx));
1105
- doc.set_attr(id, "y2", String(num(doc, id, "y2") + dy));
1106
- return;
1107
- case "polyline":
1108
- case "polygon": {
1109
- const points = doc.get_attr(id, "points");
1110
- if (points) doc.set_attr(id, "points", shift_points_string(points, dx, dy));
1111
- return;
1112
- }
1113
- case "path": {
1114
- const d = doc.get_attr(id, "d");
1115
- if (d) doc.set_attr(id, "d", shift_path_d(d, dx, dy));
1116
- return;
1117
- }
1118
- }
1119
- }
1120
- /**
1121
- * Commit-only. Moves the rotate pivot to the new local center and shifts
1122
- * geometry by δ = (R − I) · Δc so the doc-space rendering is unchanged.
1123
- * Without the shift, changing the pivot offsets the rect in doc space and
1124
- * the HUD's stable-matrix chrome falls out of alignment with the element.
1125
- */
1126
- function renormalize_rotate_pivot(doc, id) {
1127
- const existing = doc.get_attr(id, "transform");
1128
- if (existing === null || existing.indexOf("rotate") === -1) return;
1129
- const ops = parse_transform_list(existing);
1130
- if (ops === null) return;
1131
- const cls = classify(ops);
1132
- if (cls !== "single_rotate_only" && cls !== "leading_translate_then_single_rotate") return;
1133
- const rot = find_op(ops, "rotate");
1134
- if (!rot || rot.explicit_pivot !== true) return;
1135
- const c_pre = new_local_center(doc, id);
1136
- if (!c_pre) return;
1137
- const dc_x = c_pre.cx - rot.cx;
1138
- const dc_y = c_pre.cy - rot.cy;
1139
- if (dc_x === 0 && dc_y === 0) return;
1140
- const theta = rot.angle * Math.PI / 180;
1141
- const cos = Math.cos(theta);
1142
- const sin = Math.sin(theta);
1143
- const dx = (cos - 1) * dc_x - sin * dc_y;
1144
- const dy = sin * dc_x + (cos - 1) * dc_y;
1145
- shift_geometry(doc, id, dx, dy);
1146
- const next = recompose_with_pivot(ops, c_pre.cx + dx, c_pre.cy + dy);
1147
- if (next === existing) return;
1148
- doc.set_attr(id, "transform", next);
1149
- }
1150
- function apply_resize(doc, id, baseline, sx, sy, origin, phase = "commit") {
1151
- const a = baseline.attrs;
1152
- switch (a.kind) {
1153
- case "rect":
1154
- case "image":
1155
- case "use":
1156
- doc.set_attr(id, "x", String(origin.x + (a.x - origin.x) * sx));
1157
- doc.set_attr(id, "y", String(origin.y + (a.y - origin.y) * sy));
1158
- doc.set_attr(id, "width", String(Math.max(.001, a.w * sx)));
1159
- doc.set_attr(id, "height", String(Math.max(.001, a.h * sy)));
1160
- break;
1161
- case "circle": {
1162
- const s = Math.min(sx, sy);
1163
- doc.set_attr(id, "cx", String(origin.x + (a.cx - origin.x) * s));
1164
- doc.set_attr(id, "cy", String(origin.y + (a.cy - origin.y) * s));
1165
- doc.set_attr(id, "r", String(Math.max(.001, a.r * s)));
1166
- break;
1167
- }
1168
- case "ellipse":
1169
- doc.set_attr(id, "cx", String(origin.x + (a.cx - origin.x) * sx));
1170
- doc.set_attr(id, "cy", String(origin.y + (a.cy - origin.y) * sy));
1171
- doc.set_attr(id, "rx", String(Math.max(.001, a.rx * sx)));
1172
- doc.set_attr(id, "ry", String(Math.max(.001, a.ry * sy)));
1173
- break;
1174
- case "line":
1175
- doc.set_attr(id, "x1", String(origin.x + (a.x1 - origin.x) * sx));
1176
- doc.set_attr(id, "y1", String(origin.y + (a.y1 - origin.y) * sy));
1177
- doc.set_attr(id, "x2", String(origin.x + (a.x2 - origin.x) * sx));
1178
- doc.set_attr(id, "y2", String(origin.y + (a.y2 - origin.y) * sy));
1179
- break;
1180
- case "polyline":
1181
- case "polygon":
1182
- doc.set_attr(id, "points", scale_points_string(a.points, origin, sx, sy));
1183
- break;
1184
- case "path":
1185
- doc.set_attr(id, "d", scale_path_d(a.d, origin, sx, sy));
1186
- break;
1187
- case "text": {
1188
- if (!(sx !== 1 && sy !== 1)) return;
1189
- const s = Math.min(sx, sy);
1190
- doc.set_attr(id, "x", String(origin.x + (a.x - origin.x) * s));
1191
- doc.set_attr(id, "y", String(origin.y + (a.y - origin.y) * s));
1192
- doc.set_attr(id, "font-size", String(Math.max(1, a.fontSize * s)));
1193
- return;
1194
- }
1195
- case "unsupported": return;
1196
- }
1197
- if (phase === "commit") renormalize_rotate_pivot(doc, id);
1198
- }
1199
- /**
1200
- * Inspect a node and decide whether the rotate gesture is safe to apply.
1201
- * Used by the rotate-orchestrator at gesture commit to drop the preview
1202
- * with a chip rather than emit a defensible-but-noisy `transform=`.
1203
- *
1204
- * Order of checks matches the order the user is most likely to see; the
1205
- * first failure short-circuits.
1206
- */
1207
- function is_rotatable(doc, id) {
1208
- const ops = parse_transform_list(doc.get_attr(id, "transform"));
1209
- if (ops === null) return {
1210
- kind: "refuse",
1211
- reason: "non-trivial-transform"
1212
- };
1213
- if (classify(ops) === "mixed") return {
1214
- kind: "refuse",
1215
- reason: "non-trivial-transform"
1216
- };
1217
- if (doc.tag_of(id) === "text" || doc.tag_of(id) === "tspan") {
1218
- const text_rotate = doc.get_attr(id, "rotate");
1219
- if (text_rotate !== null && text_rotate.trim() !== "") return {
1220
- kind: "refuse",
1221
- reason: "text-with-glyph-rotate"
1222
- };
1223
- }
1224
- const style = doc.get_attr(id, "style");
1225
- if (style && /(?:^|;)\s*transform\s*:/i.test(style)) return {
1226
- kind: "refuse",
1227
- reason: "css-property-transform"
1228
- };
1229
- for (const c of doc.children_of(id)) if (doc.is_element(c) && doc.tag_of(c) === "animateTransform") return {
1230
- kind: "refuse",
1231
- reason: "animated-transform"
1232
- };
1233
- return { kind: "yes" };
1234
- }
1235
- function find_op(ops, type) {
1236
- for (const op of ops) if (op.type === type) return op;
1237
- return null;
1238
- }
1239
- function capture_rotate_baseline(doc, id, pivot) {
1240
- const transform = doc.get_attr(id, "transform");
1241
- const ops = parse_transform_list(transform) ?? [];
1242
- const lead = find_op(ops, "translate");
1243
- const rot = find_op(ops, "rotate");
1244
- return {
1245
- transform,
1246
- leading_translate: lead ? {
1247
- x: lead.tx,
1248
- y: lead.ty
1249
- } : null,
1250
- current_rotation_deg: rot?.angle ?? 0,
1251
- pivot
1252
- };
1253
- }
1254
- function capture_rotate_baselines(doc, ids, pivot) {
1255
- const out = /* @__PURE__ */ new Map();
1256
- for (const id of ids) out.set(id, capture_rotate_baseline(doc, id, pivot));
1257
- return out;
1258
- }
1259
- const RAD_TO_DEG = 180 / Math.PI;
1260
- /** Snap trailing FP noise off the 10th decimal place. The rad→deg
1261
- * conversion + addition produce e.g. `29.999999999999996` for what the
1262
- * user dragged as "30°"; rounding to 1e-9 absorbs the noise without
1263
- * losing any user-meaningful precision (SVG coordinates rarely go past
1264
- * 4 decimals in authored content).
1265
- *
1266
- * Limits drift accumulation across a long gesture: each frame's emit
1267
- * re-renders from `current_rotation_deg + delta_deg`, where the delta
1268
- * is recomputed against the gesture-start anchor — not accumulated. So
1269
- * noise stays bounded per-frame. */
1270
- function fmt_angle(n) {
1271
- return String(Math.round(n * 1e9) / 1e9);
1272
- }
1273
- /**
1274
- * Compose `baseline.current_rotation_deg + degrees(angle_radians)` into a
1275
- * single `rotate(θ_total cx cy)` token, preserving any leading translate
1276
- * the baseline captured. Pivot is expressed in pre-translate local space
1277
- * (`pivot - leading_translate`), which is the correct space for
1278
- * `rotate(θ cx cy)` per SVG 1.1 §7.6.
1279
- *
1280
- * Identity-restore: when angle === 0 AND current_rotation === 0, restore
1281
- * the original `transform=` byte-equal (may have been null). This is the
1282
- * round-trip invariant the "rotated then rotated back" test locks down.
1283
- */
1284
- function apply_rotate(doc, id, baseline, angle_radians) {
1285
- const angle_deg = angle_radians * RAD_TO_DEG;
1286
- const total = baseline.current_rotation_deg + angle_deg;
1287
- if (total === 0 && angle_deg === 0) {
1288
- doc.set_attr(id, "transform", baseline.transform);
1289
- return;
1290
- }
1291
- const tx = baseline.leading_translate?.x ?? 0;
1292
- const ty = baseline.leading_translate?.y ?? 0;
1293
- const cx = baseline.pivot.x - tx;
1294
- const cy = baseline.pivot.y - ty;
1295
- const rotate_token = `rotate(${fmt_angle(total)} ${cx} ${cy})`;
1296
- const str = baseline.leading_translate ? `translate(${tx} ${ty}) ${rotate_token}` : rotate_token;
1297
- doc.set_attr(id, "transform", str);
1298
- }
1299
- /**
1300
- * Allows identity, leading-translate-only, and `(translate?) rotate(θ cx cy)`
1301
- * with an explicit 3-arg pivot. Refuses 1-arg `rotate(θ)`: re-emitting it
1302
- * would canonicalize the source and violate P1 round-trip.
1303
- */
1304
- function is_resizable_node(doc, id) {
1305
- if (!is_resizable(doc.tag_of(id))) return false;
1306
- const ops = parse_transform_list(doc.get_attr(id, "transform"));
1307
- if (ops === null) return false;
1308
- const cls = classify(ops);
1309
- if (cls === "identity" || cls === "leading_translate_only") return true;
1310
- if (cls === "single_rotate_only" || cls === "leading_translate_then_single_rotate") return find_op(ops, "rotate")?.explicit_pivot === true;
1311
- return false;
1312
- }
1313
- //#endregion
1314
- //#region src/core/translate-pipeline/stages.ts
1315
- /** Bridges `ctx.input.movement` (Movement) → `plan.delta` (Vec2),
1316
- * collapsing the lesser axis when `axis_lock === "by_dominance"`. */
1317
- const stage_axis_lock = {
1318
- name: "axis_lock",
1319
- run(plan, ctx) {
1320
- const m = ctx.input.movement;
1321
- const locked = ctx.modifiers.axis_lock === "by_dominance" ? cmath.ext.movement.axisLockedByDominance(m) : m;
1322
- const [x, y] = cmath.ext.movement.normalize(locked);
1323
- return { plan: {
1324
- ...plan,
1325
- delta: {
1326
- x,
1327
- y
1328
- }
1329
- } };
1330
- }
1331
- };
1332
- /** Consults `ctx.snap_session` for geometry-aligned correction; emits a
1333
- * guide per `ctx.snap_policy`. Identity on `force_disable_snap`, missing
1334
- * session, or `snap_enabled === false`. */
1335
- const stage_snap = {
1336
- name: "snap",
1337
- run(plan, ctx) {
1338
- if (ctx.modifiers.force_disable_snap) return { plan };
1339
- if (!ctx.snap_session) return { plan };
1340
- if (!ctx.options.snap_enabled) return { plan };
1341
- const r = ctx.snap_session.snap(plan.delta, {
1342
- enabled: true,
1343
- threshold_px: ctx.options.snap_threshold_px
1344
- }, ctx.snap_policy);
1345
- return {
1346
- plan: {
1347
- ...plan,
1348
- delta: r.delta
1349
- },
1350
- emit: r.guide ? { guide: r.guide } : void 0
1351
- };
1352
- }
1353
- };
1354
- /** Quantizes the agent-union origin + plan.delta to integer multiples
1355
- * of `options.pixel_grid_quantum`. Anchor comes from the snap session
1356
- * when open; falls back to `baseline_union_top_left` (RPC path).
1357
- * Identity when quantum is `null` or `<= 0`. */
1358
- const stage_pixel_grid = {
1359
- name: "pixel_grid",
1360
- run(plan, ctx) {
1361
- const q = ctx.options.pixel_grid_quantum;
1362
- if (q === null || q <= 0) return { plan };
1363
- const anchor = ctx.snap_session?.baseline_union_readonly ?? baseline_union_top_left(plan.baselines);
1364
- if (!anchor) return { plan };
1365
- const qx = Math.round((anchor.x + plan.delta.x) / q) * q - anchor.x;
1366
- const qy = Math.round((anchor.y + plan.delta.y) / q) * q - anchor.y;
1367
- return { plan: {
1368
- ...plan,
1369
- delta: {
1370
- x: qx,
1371
- y: qy
1372
- }
1373
- } };
1374
- }
1375
- };
1376
- const STAGES_DEFAULT$1 = Object.freeze([
1377
- stage_axis_lock,
1378
- stage_snap,
1379
- stage_pixel_grid
1380
- ]);
1381
- const STAGES_NUDGE = Object.freeze([stage_axis_lock, stage_pixel_grid]);
1382
- const STAGES_RPC$1 = Object.freeze([stage_axis_lock]);
1383
- //#endregion
1384
- //#region src/core/translate-pipeline/apply.ts
1385
- /** Apply the plan: for each id, run `apply_translate` with the
1386
- * baseline + world-space delta. Does NOT emit; caller wraps with
1387
- * history machinery and calls `emit()` after. */
1388
- function applyTranslatePlan(doc, plan) {
1389
- for (const id of plan.ids) {
1390
- const baseline = plan.baselines.get(id);
1391
- if (!baseline) continue;
1392
- apply_translate(doc, id, baseline, plan.delta.x, plan.delta.y);
1393
- }
1394
- }
1395
- /** Reset each id to its baseline (delta = 0). Used by undo closures. */
1396
- function revertTranslatePlan(doc, plan) {
1397
- for (const id of plan.ids) {
1398
- const baseline = plan.baselines.get(id);
1399
- if (!baseline) continue;
1400
- apply_translate(doc, id, baseline, 0, 0);
1401
- }
1402
- }
1403
- /** Prepare a one-shot, headless translate. Captures baselines, runs
1404
- * the pipeline with `stages` (default `STAGES_RPC`), returns ready-to-
1405
- * record closures. Caller wraps in history (e.g. `history.atomic`).
1406
- * See `./README.md` for the per-caller stage lists. */
1407
- function prepare_translate_rpc(args) {
1408
- const { doc, ids, delta, options, emit, stages = STAGES_RPC$1 } = args;
1409
- const filtered_ids = doc.prune_nested_nodes(ids);
1410
- const plan0 = {
1411
- ids: filtered_ids,
1412
- baselines: capture_translate_baselines(doc, filtered_ids),
1413
- delta: {
1414
- x: 0,
1415
- y: 0
1416
- }
1417
- };
1418
- const { plan } = run_translate_pipeline(plan0, stages, {
1419
- input: {
1420
- ids: plan0.ids,
1421
- movement: [delta.x, delta.y]
1422
- },
1423
- modifiers: {
1424
- axis_lock: "off",
1425
- force_disable_snap: true
1426
- },
1427
- options,
1428
- snap_session: null,
1429
- snap_policy: "engine"
1430
- });
1431
- return {
1432
- plan,
1433
- apply: () => {
1434
- applyTranslatePlan(doc, plan);
1435
- emit();
1436
- },
1437
- revert: () => {
1438
- revertTranslatePlan(doc, plan);
1439
- emit();
1440
- }
1441
- };
1442
- }
1443
- //#endregion
1444
- //#region src/core/translate-pipeline/orchestrator.ts
1445
- const PROVIDER_ID$1 = "svg-editor";
1446
- var TranslateOrchestrator = class {
1447
- constructor(deps) {
1448
- this.deps = deps;
1449
- this.active = null;
1450
- this._last_guides = [];
1451
- }
1452
- /** Guides emitted by the most recent pipeline run. Cleared on
1453
- * cancel/dispose. HUD compositors read this to draw snap chrome. */
1454
- get last_guides() {
1455
- return this._last_guides;
1456
- }
1457
- /** True while a gesture session is open. */
1458
- has_active_session() {
1459
- return this.active !== null;
1460
- }
1461
- /** Per-frame drive: lazily opens a session on first call, runs the
1462
- * pipeline, writes apply/revert into the preview, and commits when
1463
- * `opts.phase === "commit"`. */
1464
- drive(input, modifiers, opts) {
1465
- if (this.active === null) this.active = this.open(input.ids, opts.snap, opts.label ?? "move");
1466
- const session = this.active;
1467
- const stages = opts.stages ?? STAGES_DEFAULT$1;
1468
- const result = this.run_pass(session, input.movement, modifiers, opts.policy, stages);
1469
- session.last_movement = input.movement;
1470
- session.last_policy = opts.policy;
1471
- session.last_stages = stages;
1472
- this.write_preview_delta(session, result.plan);
1473
- if (opts.phase === "commit") {
1474
- session.preview.commit();
1475
- this.dispose_session();
1476
- }
1477
- return result;
1478
- }
1479
- /** Re-run the current preview frame with new modifiers, reusing the
1480
- * last-known movement / policy / stages. Used when a modifier key
1481
- * changes between pointer-move events (Shift down/up mid-drag).
1482
- * No-op when no session is active. */
1483
- redrive_modifiers(modifiers) {
1484
- if (!this.active) return null;
1485
- const session = this.active;
1486
- const result = this.run_pass(session, session.last_movement, modifiers, session.last_policy, session.last_stages);
1487
- this.write_preview_delta(session, result.plan);
1488
- return result;
1489
- }
1490
- /** Cancel an in-flight gesture (Escape, programmatic abort). */
1491
- cancel() {
1492
- if (!this.active) return;
1493
- this.active.preview.discard();
1494
- this.dispose_session();
1495
- }
1496
- /** Build a plan + context, run the pipeline, stash guides. Pure
1497
- * computation — does not touch the preview. */
1498
- run_pass(session, movement, modifiers, policy, stages) {
1499
- const result = run_translate_pipeline({
1500
- ids: session.ids,
1501
- baselines: session.baselines,
1502
- delta: {
1503
- x: 0,
1504
- y: 0
1505
- }
1506
- }, stages, {
1507
- input: {
1508
- ids: session.ids,
1509
- movement
1510
- },
1511
- modifiers,
1512
- options: this.deps.options(),
1513
- snap_session: session.snap,
1514
- snap_policy: policy
1515
- });
1516
- this._last_guides = result.guides;
1517
- return result;
1518
- }
1519
- open(ids, snap, label) {
1520
- const doc = this.deps.get_doc();
1521
- const filtered = doc.prune_nested_nodes(ids);
1522
- return {
1523
- ids: filtered,
1524
- baselines: capture_translate_baselines(doc, filtered),
1525
- snap: snap ? this.deps.open_snap(filtered) : null,
1526
- preview: this.deps.open_preview(label),
1527
- last_movement: [0, 0],
1528
- last_policy: "engine",
1529
- last_stages: STAGES_DEFAULT$1
1530
- };
1531
- }
1532
- /** Bind a fresh apply/revert pair (closure over `plan`) into the
1533
- * preview slot. Called from both `drive` (per pointer frame) and
1534
- * `redrive_modifiers` (on modifier flip). */
1535
- write_preview_delta(session, plan) {
1536
- const doc = this.deps.get_doc();
1537
- const emit = this.deps.emit;
1538
- session.preview.set({
1539
- providerId: PROVIDER_ID$1,
1540
- apply: () => {
1541
- applyTranslatePlan(doc, plan);
1542
- emit();
1543
- },
1544
- revert: () => {
1545
- revertTranslatePlan(doc, plan);
1546
- emit();
1547
- }
1548
- });
1549
- }
1550
- dispose_session() {
1551
- if (!this.active) return;
1552
- this.active.snap?.dispose();
1553
- this.active = null;
1554
- this._last_guides = [];
1555
- }
1556
- };
1557
- //#endregion
1558
- //#region src/core/translate-pipeline/nudge-dwell-watcher.ts
1559
- /** Hold-time after the last firing detection. Show is immediate (next
1560
- * frame); only the hide edge is delayed. */
1561
- const HIDE_MS = 500;
1562
- var NudgeDwellWatcher = class {
1563
- constructor(deps) {
1564
- this.deps = deps;
1565
- this._guides = [];
1566
- this.raf_id = null;
1567
- this.hide_timer = null;
1568
- this.unsubscribe = deps.editor.subscribe_translate_commit(() => this.schedule_detect());
1569
- }
1570
- /** Currently-published dwell guides. Empty between detections. */
1571
- get guides() {
1572
- return this._guides;
1573
- }
1574
- /** Drop any pending detection / held guide. Idempotent. */
1575
- cancel_pending() {
1576
- this.clear_raf();
1577
- this.clear_hide();
1578
- this.publish_guides([]);
1579
- }
1580
- dispose() {
1581
- this.unsubscribe();
1582
- this.cancel_pending();
1583
- }
1584
- schedule_detect() {
1585
- if (this.raf_id !== null) return;
1586
- this.raf_id = this.deps.window.requestAnimationFrame(() => {
1587
- this.raf_id = null;
1588
- this.detect();
1589
- });
1590
- }
1591
- detect() {
1592
- const ids = this.deps.editor.state.selection;
1593
- if (ids.length === 0) {
1594
- this.publish_guides([]);
1595
- this.clear_hide();
1596
- return;
1597
- }
1598
- const snap = this.deps.open_snap(ids);
1599
- if (!snap) {
1600
- this.publish_guides([]);
1601
- this.clear_hide();
1602
- return;
1603
- }
1604
- try {
1605
- const plan0 = {
1606
- ids: [...ids],
1607
- baselines: capture_translate_baselines(this.deps.editor.document, ids),
1608
- delta: {
1609
- x: 0,
1610
- y: 0
1611
- }
1612
- };
1613
- const result = run_translate_pipeline(plan0, STAGES_DEFAULT$1, {
1614
- input: {
1615
- ids: plan0.ids,
1616
- movement: [0, 0]
1617
- },
1618
- modifiers: {
1619
- axis_lock: "off",
1620
- force_disable_snap: false
1621
- },
1622
- options: this.deps.options(),
1623
- snap_session: snap,
1624
- snap_policy: "aligned"
1625
- });
1626
- if (result.guides.length === 0) {
1627
- this.publish_guides([]);
1628
- this.clear_hide();
1629
- return;
1630
- }
1631
- this.publish_guides(result.guides);
1632
- this.arm_hide();
1633
- } finally {
1634
- snap.dispose();
1635
- }
1636
- }
1637
- arm_hide() {
1638
- this.clear_hide();
1639
- this.hide_timer = this.deps.window.setTimeout(() => {
1640
- this.hide_timer = null;
1641
- this.publish_guides([]);
1642
- }, HIDE_MS);
1643
- }
1644
- publish_guides(next) {
1645
- if (next.length === 0 && this._guides.length === 0) return;
1646
- this._guides = next;
1647
- this.deps.on_guides_change();
1648
- }
1649
- clear_raf() {
1650
- if (this.raf_id === null) return;
1651
- this.deps.window.cancelAnimationFrame(this.raf_id);
1652
- this.raf_id = null;
1653
- }
1654
- clear_hide() {
1655
- if (this.hide_timer === null) return;
1656
- this.deps.window.clearTimeout(this.hide_timer);
1657
- this.hide_timer = null;
1658
- }
1659
- };
1660
- //#endregion
1661
- //#region src/core/rotate-pipeline/pipeline.ts
1662
- /** The funnel. Threads `plan` through `stages` in order. Pure: same
1663
- * inputs → same outputs. */
1664
- function run_rotate_pipeline(init, stages, _ctx) {
1665
- let plan = init;
1666
- for (const stage of stages) plan = stage.run(plan, _ctx).plan;
1667
- return { plan };
1668
- }
1669
- /** Default stage list for HUD-driven rotate gestures (drag). */
1670
- const STAGES_DEFAULT = Object.freeze([{
1671
- name: "angle_snap",
1672
- run(plan, ctx) {
1673
- if (ctx.modifiers.force_disable_snap) return { plan };
1674
- if (ctx.modifiers.angle_snap !== "step") return { plan };
1675
- const step = ctx.options.angle_snap_step_radians;
1676
- if (step === null || step <= 0) return { plan };
1677
- const snapped = Math.round(plan.angle_radians / step) * step;
1678
- return { plan: {
1679
- ...plan,
1680
- angle_radians: snapped
1681
- } };
1682
- }
1683
- }]);
1684
- /** Stage list for headless RPC paths (`commands.rotate`, `rotate_to`).
1685
- * No snap — the caller passed an exact angle on purpose. */
1686
- const STAGES_RPC = Object.freeze([]);
1687
- //#endregion
1688
- //#region src/core/rotate-pipeline/apply.ts
1689
- function applyRotatePlan(doc, plan) {
1690
- for (const m of plan.members) apply_rotate(doc, m.id, m.baseline, plan.angle_radians);
1691
- }
1692
- /** Reset each member to its baseline rotation (angle_radians = 0). Used
1693
- * by undo closures. Identity-restore byte-equality kicks in when the
1694
- * baseline had no pre-existing rotation. */
1695
- function revertRotatePlan(doc, plan) {
1696
- for (const m of plan.members) apply_rotate(doc, m.id, m.baseline, 0);
1697
- }
1698
- /** Headless one-shot rotate. Captures per-member baselines around the
1699
- * shared pivot, runs the pipeline with `stages` (default `STAGES_RPC`),
1700
- * returns ready-to-record closures + per-member refusal verdicts. The
1701
- * caller wraps the closures in history (e.g. `history.atomic`).
1702
- *
1703
- * Verdicts: if any member returns `kind: "refuse"`, the caller SHOULD
1704
- * drop the gesture and emit a chip instead of committing. The `apply`
1705
- * closure here doesn't gate on verdicts — that's a policy decision at
1706
- * the call site (a programmatic `commands.rotate` may still want to
1707
- * force-write through; the interactive path does not). */
1708
- function prepare_rotate_rpc(args) {
1709
- const { doc, ids, pivot, angle_radians, options, emit, stages = STAGES_RPC } = args;
1710
- const filtered_ids = doc.prune_nested_nodes(ids);
1711
- const baselines = capture_rotate_baselines(doc, filtered_ids, pivot);
1712
- const members = filtered_ids.map((id) => ({
1713
- id,
1714
- baseline: baselines.get(id)
1715
- }));
1716
- const verdicts = /* @__PURE__ */ new Map();
1717
- for (const id of filtered_ids) verdicts.set(id, is_rotatable(doc, id));
1718
- const { plan } = run_rotate_pipeline({
1719
- members,
1720
- pivot,
1721
- angle_radians
1722
- }, stages, {
1723
- input: {
1724
- ids: filtered_ids,
1725
- angle_radians
1726
- },
1727
- modifiers: {
1728
- angle_snap: "off",
1729
- force_disable_snap: true
1730
- },
1731
- options
1732
- });
1733
- return {
1734
- plan,
1735
- verdicts,
1736
- apply: () => {
1737
- applyRotatePlan(doc, plan);
1738
- emit();
1739
- },
1740
- revert: () => {
1741
- revertRotatePlan(doc, plan);
1742
- emit();
1743
- }
1744
- };
1745
- }
1746
- //#endregion
1747
- //#region src/core/rotate-pipeline/orchestrator.ts
1748
- const PROVIDER_ID = "svg-editor";
1749
- function ids_key(ids) {
1750
- return [...ids].sort().join("\0");
1751
- }
1752
- var RotateOrchestrator = class {
1753
- constructor(deps) {
1754
- this.deps = deps;
1755
- this.active = null;
1756
- }
1757
- has_active_session() {
1758
- return this.active !== null;
1759
- }
1760
- is_active_for(ids) {
1761
- return this.active !== null && this.active.ids_key === ids_key(ids);
1762
- }
1763
- /** Per-frame drive. Opens a session lazily on the first call. Returns
1764
- * `null` when `ids` is empty. On commit, returns a `RotateCommitOutcome`
1765
- * the caller uses to surface refusal chips. */
1766
- drive(input, modifiers, opts) {
1767
- if (input.ids.length === 0) return null;
1768
- const key = ids_key(input.ids);
1769
- if (this.active && this.active.ids_key !== key) {
1770
- this.active.preview.discard();
1771
- this.dispose_session();
1772
- }
1773
- if (this.active === null) this.active = this.open(input.ids, opts.label ?? "rotate");
1774
- const session = this.active;
1775
- const stages = opts.stages ?? STAGES_DEFAULT;
1776
- const result = this.run_pass(session, input.angle_radians, modifiers, stages);
1777
- session.last_angle = input.angle_radians;
1778
- session.last_stages = stages;
1779
- this.write_preview(session, result.plan);
1780
- if (opts.phase === "commit") {
1781
- let outcome;
1782
- let refused = false;
1783
- for (const v of session.verdicts.values()) if (v.kind === "refuse") {
1784
- refused = true;
1785
- break;
1786
- }
1787
- if (refused) {
1788
- session.preview.discard();
1789
- outcome = {
1790
- kind: "refused",
1791
- verdicts: session.verdicts
1792
- };
1793
- } else {
1794
- session.preview.commit();
1795
- outcome = {
1796
- kind: "committed",
1797
- plan: result.plan
1798
- };
1799
- }
1800
- this.dispose_session();
1801
- return {
1802
- result,
1803
- outcome
1804
- };
1805
- }
1806
- return {
1807
- result,
1808
- outcome: null
1809
- };
1810
- }
1811
- redrive_modifiers(modifiers) {
1812
- if (!this.active) return null;
1813
- const session = this.active;
1814
- const result = this.run_pass(session, session.last_angle, modifiers, session.last_stages);
1815
- this.write_preview(session, result.plan);
1816
- return result;
1817
- }
1818
- cancel() {
1819
- if (!this.active) return;
1820
- this.active.preview.discard();
1821
- this.dispose_session();
1822
- }
1823
- run_pass(session, angle_radians, modifiers, stages) {
1824
- return run_rotate_pipeline({
1825
- members: session.members,
1826
- pivot: session.pivot,
1827
- angle_radians
1828
- }, stages, {
1829
- input: {
1830
- ids: session.members.map((m) => m.id),
1831
- angle_radians
1832
- },
1833
- modifiers,
1834
- options: this.deps.options()
1835
- });
1836
- }
1837
- open(ids, label) {
1838
- const doc = this.deps.get_doc();
1839
- const filtered = doc.prune_nested_nodes(ids);
1840
- const pivot = compute_union_center(filtered, this.deps.bbox_world);
1841
- const baselines = capture_rotate_baselines(doc, filtered, pivot);
1842
- const members = filtered.map((id) => ({
1843
- id,
1844
- baseline: baselines.get(id)
1845
- }));
1846
- const verdicts = /* @__PURE__ */ new Map();
1847
- for (const id of filtered) verdicts.set(id, is_rotatable(doc, id));
1848
- return {
1849
- ids_key: ids_key(ids),
1850
- members,
1851
- pivot,
1852
- verdicts,
1853
- preview: this.deps.open_preview(label),
1854
- last_angle: 0,
1855
- last_stages: STAGES_DEFAULT
1856
- };
1857
- }
1858
- write_preview(session, plan) {
1859
- const doc = this.deps.get_doc();
1860
- const emit = this.deps.emit;
1861
- session.preview.set({
1862
- providerId: PROVIDER_ID,
1863
- apply: () => {
1864
- applyRotatePlan(doc, plan);
1865
- emit();
1866
- },
1867
- revert: () => {
1868
- revertRotatePlan(doc, plan);
1869
- emit();
1870
- }
1871
- });
1872
- }
1873
- dispose_session() {
1874
- this.active = null;
1875
- }
1876
- };
1877
- function compute_union_center(ids, bbox_world) {
1878
- const rects = ids.map(bbox_world);
1879
- const u = cmath.rect.union(rects);
1880
- return {
1881
- x: u.x + u.width / 2,
1882
- y: u.y + u.height / 2
1883
- };
1884
- }
1885
- //#endregion
1886
- //#region src/core/paint.ts
1887
- /**
1888
- * Parse a *computed* paint string into the discriminated union. Returns null
1889
- * for `inherit` / `var()` / empty. Returns an invalid-computed-value record
1890
- * for syntactic errors (rare; we're permissive).
1891
- */
1892
- function parse_paint(declared) {
1893
- if (declared === null || declared === "") return null;
1894
- const trimmed = declared.trim();
1895
- if (trimmed === "") return null;
1896
- if (trimmed === "inherit" || trimmed === "initial" || trimmed === "unset" || trimmed === "revert" || trimmed === "revert-layer") return null;
1897
- if (/^var\s*\(/i.test(trimmed)) return {
1898
- error: "invalid_at_computed_value_time",
1899
- reason: "var() substitution requires a cascade engine (not implemented)"
1900
- };
1901
- if (trimmed === "none") return { kind: "none" };
1902
- if (trimmed === "context-fill" || trimmed === "contextFill") return { kind: "context_fill" };
1903
- if (trimmed === "context-stroke" || trimmed === "contextStroke") return { kind: "context_stroke" };
1904
- const url_match = trimmed.match(/^url\(\s*(["']?)#([^)"']+)\1\s*\)\s*(.*)$/i);
1905
- if (url_match) {
1906
- const id = url_match[2];
1907
- const rest = url_match[3].trim();
1908
- let fallback;
1909
- if (rest !== "") {
1910
- const f = parse_paint(rest);
1911
- if (f && f.kind === "none") fallback = { kind: "none" };
1912
- else if (f && f.kind === "color") fallback = {
1913
- kind: "color",
1914
- value: f.value
1915
- };
1916
- }
1917
- return fallback ? {
1918
- kind: "ref",
1919
- id,
1920
- fallback
1921
- } : {
1922
- kind: "ref",
1923
- id
1924
- };
1925
- }
1926
- if (/^currentcolor$/i.test(trimmed)) return {
1927
- kind: "color",
1928
- value: { kind: "current_color" }
1929
- };
1930
- return {
1931
- kind: "color",
1932
- value: {
1933
- kind: "rgb",
1934
- value: trimmed
1935
- }
1936
- };
1937
- }
1938
- /** Serialize a Paint back to an SVG attribute / inline-style value. */
1939
- function serialize_paint(paint) {
1940
- switch (paint.kind) {
1941
- case "none": return "none";
1942
- case "context_fill": return "context-fill";
1943
- case "context_stroke": return "context-stroke";
1944
- case "color": return paint.value.kind === "current_color" ? "currentColor" : paint.value.value;
1945
- case "ref":
1946
- if (paint.fallback) {
1947
- const f = paint.fallback.kind === "none" ? "none" : paint.fallback.value.kind === "current_color" ? "currentColor" : paint.fallback.value.value;
1948
- return `url(#${paint.id}) ${f}`;
1949
- }
1950
- return `url(#${paint.id})`;
1951
- }
1952
- }
1953
- //#endregion
1954
- //#region src/types.ts
1955
- const TOOL_CURSOR = { type: "cursor" };
1956
- const DEFAULT_STYLE = {
1957
- chrome_color: "#2563eb",
1958
- handle_size: 8,
1959
- handle_fill: "#ffffff",
1960
- handle_stroke: "#2563eb",
1961
- endpoint_dot_radius: 5,
1962
- selection_outline_width: 2,
1963
- measurement_color: "#ff3a30",
1964
- show_size_meter: true,
1965
- snap_enabled: true,
1966
- snap_threshold_px: 6,
1967
- hit_tolerance_px: 0,
1968
- snap_to_pixel_grid: false,
1969
- pixel_grid_size: 1,
1970
- pixel_grid: true,
1971
- angle_snap_step_radians: Math.PI / 12
1972
- };
1973
- /** v1 default fill — gray so newly-drawn shapes are visible against white.
1974
- * Matches the main canvas convention. A future preset may override. */
1975
- const DEFAULT_FILL = "#D9D9D9";
1976
- /**
1977
- * Initial attrs for the moment of pointer-down — zero-size at the click
1978
- * point. Seeds the pending node before any drag movement so the HUD
1979
- * selection chrome can render a (zero-size) box without a flicker.
1980
- *
1981
- * Per tag:
1982
- * - rect → x = px, y = py, width = 0, height = 0
1983
- * - ellipse → cx = px, cy = py, rx = 0, ry = 0
1984
- * - line → x1 = x2 = px, y1 = y2 = py
1985
- */
1986
- function initial_attrs(tag, point) {
1987
- switch (tag) {
1988
- case "rect": return {
1989
- x: fmt(point.x),
1990
- y: fmt(point.y),
1991
- width: "0",
1992
- height: "0"
1993
- };
1994
- case "ellipse": return {
1995
- cx: fmt(point.x),
1996
- cy: fmt(point.y),
1997
- rx: "0",
1998
- ry: "0"
1999
- };
2000
- case "line": return {
2001
- x1: fmt(point.x),
2002
- y1: fmt(point.y),
2003
- x2: fmt(point.x),
2004
- y2: fmt(point.y)
2005
- };
2006
- }
2007
- }
2008
- /**
2009
- * Attrs for click-no-drag commit — default-sized shape centered on the
2010
- * click point. (Rect's `x`/`y` are top-left in SVG, so we offset by
2011
- * `size/2` to center; ellipse's `cx`/`cy` are already center.)
2012
- */
2013
- function default_attrs(tag, point, size = 100) {
2014
- switch (tag) {
2015
- case "rect": return {
2016
- x: fmt(point.x - size / 2),
2017
- y: fmt(point.y - size / 2),
2018
- width: fmt(size),
2019
- height: fmt(size)
2020
- };
2021
- case "ellipse": return {
2022
- cx: fmt(point.x),
2023
- cy: fmt(point.y),
2024
- rx: fmt(size / 2),
2025
- ry: fmt(size / 2)
2026
- };
2027
- case "line": return {
2028
- x1: fmt(point.x - size / 2),
2029
- y1: fmt(point.y),
2030
- x2: fmt(point.x + size / 2),
2031
- y2: fmt(point.y)
2032
- };
2033
- }
2034
- }
2035
- /**
2036
- * Drag math — pure. Given anchor + current + modifiers, returns geometry
2037
- * attrs for the in-progress node.
2038
- *
2039
- * Per-tag rules:
2040
- * - rect → x / y / width / height
2041
- * Shift: width === height (the larger of the two deltas wins, signs
2042
- * preserved so the rect can still flip across the anchor).
2043
- * Alt: anchor is treated as center; rect grows symmetrically.
2044
- * - ellipse → cx / cy / rx / ry
2045
- * Shift: rx === ry (uniform circle).
2046
- * Alt: anchor is treated as center (default for ellipse anyway —
2047
- * Alt makes the anchor act as center even when Shift is on).
2048
- * Without Alt the anchor is one corner of the ellipse's bbox; with
2049
- * Alt the anchor is the center.
2050
- * - line → x1 / y1 / x2 / y2
2051
- * Shift: angle quantized to 0° / 45° / 90°.
2052
- * Alt: anchor is the midpoint of the line (mirror the drag).
2053
- */
2054
- function compute_drag_attrs(tag, anchor, current, modifiers) {
2055
- switch (tag) {
2056
- case "rect": return rect_attrs(anchor, current, modifiers);
2057
- case "ellipse": return ellipse_attrs(anchor, current, modifiers);
2058
- case "line": return line_attrs(anchor, current, modifiers);
2059
- }
2060
- }
2061
- /** Default paint attrs for a freshly-inserted shape. v1: gray fill, no
2062
- * stroke. Line gets a stroke (otherwise it's invisible — `<line>` has no
2063
- * fill area). Hard-coded for v1; a future preset may swap these. */
2064
- function default_paint_attrs(tag) {
2065
- switch (tag) {
2066
- case "rect":
2067
- case "ellipse": return { fill: DEFAULT_FILL };
2068
- case "line": return {
2069
- stroke: "#000000",
2070
- "stroke-width": "1"
2071
- };
2072
- }
2073
- }
2074
- function rect_attrs(anchor, current, mods) {
2075
- let dx = current.x - anchor.x;
2076
- let dy = current.y - anchor.y;
2077
- if (mods.shift) {
2078
- const m = Math.max(Math.abs(dx), Math.abs(dy));
2079
- dx = dx < 0 ? -m : m;
2080
- dy = dy < 0 ? -m : m;
2081
- }
2082
- let x;
2083
- let y;
2084
- let w;
2085
- let h;
2086
- if (mods.alt) {
2087
- x = anchor.x - Math.abs(dx);
2088
- y = anchor.y - Math.abs(dy);
2089
- w = Math.abs(dx) * 2;
2090
- h = Math.abs(dy) * 2;
2091
- } else {
2092
- x = Math.min(anchor.x, anchor.x + dx);
2093
- y = Math.min(anchor.y, anchor.y + dy);
2094
- w = Math.abs(dx);
2095
- h = Math.abs(dy);
2096
- }
2097
- return {
2098
- x: fmt(x),
2099
- y: fmt(y),
2100
- width: fmt(w),
2101
- height: fmt(h)
2102
- };
2103
- }
2104
- function ellipse_attrs(anchor, current, mods) {
2105
- let dx = current.x - anchor.x;
2106
- let dy = current.y - anchor.y;
2107
- if (mods.shift) {
2108
- const m = Math.max(Math.abs(dx), Math.abs(dy));
2109
- dx = dx < 0 ? -m : m;
2110
- dy = dy < 0 ? -m : m;
2111
- }
2112
- let cx;
2113
- let cy;
2114
- let rx;
2115
- let ry;
2116
- if (mods.alt) {
2117
- cx = anchor.x;
2118
- cy = anchor.y;
2119
- rx = Math.abs(dx);
2120
- ry = Math.abs(dy);
2121
- } else {
2122
- cx = anchor.x + dx / 2;
2123
- cy = anchor.y + dy / 2;
2124
- rx = Math.abs(dx) / 2;
2125
- ry = Math.abs(dy) / 2;
2126
- }
2127
- return {
2128
- cx: fmt(cx),
2129
- cy: fmt(cy),
2130
- rx: fmt(rx),
2131
- ry: fmt(ry)
2132
- };
2133
- }
2134
- function line_attrs(anchor, current, mods) {
2135
- let dx = current.x - anchor.x;
2136
- let dy = current.y - anchor.y;
2137
- if (mods.shift) {
2138
- const len = Math.hypot(dx, dy);
2139
- if (len > 0) {
2140
- const angle = Math.atan2(dy, dx);
2141
- const step = Math.PI / 4;
2142
- const quantized = Math.round(angle / step) * step;
2143
- dx = Math.cos(quantized) * len;
2144
- dy = Math.sin(quantized) * len;
2145
- }
2146
- }
2147
- let x1;
2148
- let y1;
2149
- let x2;
2150
- let y2;
2151
- if (mods.alt) {
2152
- x1 = anchor.x - dx;
2153
- y1 = anchor.y - dy;
2154
- x2 = anchor.x + dx;
2155
- y2 = anchor.y + dy;
2156
- } else {
2157
- x1 = anchor.x;
2158
- y1 = anchor.y;
2159
- x2 = anchor.x + dx;
2160
- y2 = anchor.y + dy;
2161
- }
2162
- return {
2163
- x1: fmt(x1),
2164
- y1: fmt(y1),
2165
- x2: fmt(x2),
2166
- y2: fmt(y2)
2167
- };
2168
- }
2169
- /** Format a numeric value for SVG attr output. Rounds to 4 decimals to
2170
- * suppress IEEE-754 noise (`0.30000000000000004` → `0.3`); `String()`
2171
- * drops trailing zeros and the decimal point for integers. */
2172
- function fmt(n) {
2173
- return String(Math.round(n * 1e4) / 1e4);
2174
- }
2175
- //#endregion
2176
- export { is_text_input_focused as A, hit_shape_of_doc as C, parse_transform_list as D, emit_transform_list as E, STRUCTURAL_GRAPHICS_SET as O, is_resizable_node as S, project_local_bbox as T, apply_translate as _, DEFAULT_STYLE as a, compute_resize_factors as b, serialize_paint as c, NudgeDwellWatcher as d, TranslateOrchestrator as f, apply_rotate as g, apply_resize as h, initial_attrs as i, plan_group as k, RotateOrchestrator as l, STAGES_NUDGE as m, default_attrs as n, TOOL_CURSOR as o, prepare_translate_rpc as p, default_paint_attrs as r, parse_paint as s, compute_drag_attrs as t, prepare_rotate_rpc as u, capture_resize_baseline as v, is_transparent_tag as w, is_resizable as x, capture_translate_baseline as y };