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

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