@grida/svg-editor 1.0.0-alpha.2 → 1.0.0-alpha.3

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,14 +1,444 @@
1
- import { a as capture_resize_baseline, c as is_resizable, i as apply_translate, o as capture_translate_baseline, r as apply_resize, s as compute_resize_factors, t as parse_paint } from "./paint-DuCg6Y-K.mjs";
1
+ import { a as capture_resize_baseline, c as is_resizable, i as apply_translate, l as is_text_input_focused, o as capture_translate_baseline, r as apply_resize, s as compute_resize_factors, t as parse_paint } from "./paint-BTKvRItP.mjs";
2
2
  import { createTextEditor } from "@grida/text-editor/dom";
3
3
  import { NO_MODS, Surface, measurementToHUDDraw } from "@grida/hud";
4
4
  import { measure } from "@grida/cmath/_measurement";
5
5
  import cmath from "@grida/cmath";
6
+ //#region src/core/camera.ts
7
+ /**
8
+ * Surface-scoped pan/zoom state.
9
+ *
10
+ * The public shape leads with the peer convention (`center` / `zoom` /
11
+ * `bounds`) and keeps the matrix as an advanced read. Methods mirror
12
+ * Figma/Penpot where they overlap.
13
+ */
14
+ var Camera = class {
15
+ constructor(opts) {
16
+ this.viewport_w = 0;
17
+ this.viewport_h = 0;
18
+ this.listeners = /* @__PURE__ */ new Set();
19
+ this._transform = opts.initial ?? cmath.transform.identity;
20
+ this.resolve_bounds = opts.resolve_bounds;
21
+ }
22
+ /** Underlying 2D affine. World→screen. */
23
+ get transform() {
24
+ return this._transform;
25
+ }
26
+ /** Uniform scale factor. 1 = 100 %. */
27
+ get zoom() {
28
+ return this._transform[0][0];
29
+ }
30
+ /** World-space point currently at viewport center. */
31
+ get center() {
32
+ return this.screen_to_world({
33
+ x: this.viewport_w / 2,
34
+ y: this.viewport_h / 2
35
+ });
36
+ }
37
+ /** World-space rectangle visible in the viewport. */
38
+ get bounds() {
39
+ const tl = this.screen_to_world({
40
+ x: 0,
41
+ y: 0
42
+ });
43
+ const br = this.screen_to_world({
44
+ x: this.viewport_w,
45
+ y: this.viewport_h
46
+ });
47
+ return {
48
+ x: tl.x,
49
+ y: tl.y,
50
+ width: br.x - tl.x,
51
+ height: br.y - tl.y
52
+ };
53
+ }
54
+ /** Translate the camera by a screen-space delta. */
55
+ pan(delta_screen) {
56
+ const t = this._transform;
57
+ this.set_transform([[
58
+ t[0][0],
59
+ t[0][1],
60
+ t[0][2] + delta_screen.x
61
+ ], [
62
+ t[1][0],
63
+ t[1][1],
64
+ t[1][2] + delta_screen.y
65
+ ]]);
66
+ }
67
+ /**
68
+ * Multiply zoom by `factor` keeping `origin_screen` fixed in world space.
69
+ * Used by wheel-zoom-at-cursor and pinch-zoom.
70
+ */
71
+ zoom_at(factor, origin_screen) {
72
+ const t = this._transform;
73
+ const s2 = t[0][0] * factor;
74
+ const tx2 = origin_screen.x * (1 - factor) + factor * t[0][2];
75
+ const ty2 = origin_screen.y * (1 - factor) + factor * t[1][2];
76
+ this.set_transform([[
77
+ s2,
78
+ 0,
79
+ tx2
80
+ ], [
81
+ 0,
82
+ s2,
83
+ ty2
84
+ ]]);
85
+ }
86
+ /** Pan so `c` lands at the viewport center. Zoom unchanged. */
87
+ set_center(c) {
88
+ const s = this._transform[0][0];
89
+ const tx = this.viewport_w / 2 - s * c.x;
90
+ const ty = this.viewport_h / 2 - s * c.y;
91
+ this.set_transform([[
92
+ s,
93
+ 0,
94
+ tx
95
+ ], [
96
+ 0,
97
+ s,
98
+ ty
99
+ ]]);
100
+ }
101
+ /** Set zoom directly; pivot defaults to viewport center. */
102
+ set_zoom(z, pivot_screen) {
103
+ const current = this._transform[0][0];
104
+ if (current === 0) return;
105
+ const factor = z / current;
106
+ const pivot = pivot_screen ?? {
107
+ x: this.viewport_w / 2,
108
+ y: this.viewport_h / 2
109
+ };
110
+ this.zoom_at(factor, pivot);
111
+ }
112
+ /**
113
+ * Replace the entire transform.
114
+ *
115
+ * Idempotent: when the new transform is element-wise equal to the current
116
+ * one, this is a no-op (no notification fires). This is the seam that
117
+ * makes external constraint loops (e.g. "subscribe → compute clamped →
118
+ * set_transform") terminate: the clamp re-emits the same transform on
119
+ * the second pass, set_transform short-circuits, no recursion.
120
+ */
121
+ set_transform(t) {
122
+ if (transform_equal(this._transform, t)) return;
123
+ this._transform = t;
124
+ this.notify();
125
+ }
126
+ /** Viewport size in screen pixels. Read by host code computing constraints. */
127
+ get viewport_size() {
128
+ return {
129
+ width: this.viewport_w,
130
+ height: this.viewport_h
131
+ };
132
+ }
133
+ /**
134
+ * Fit a target into the viewport.
135
+ *
136
+ * - `"<root>"` — the document root's content bounds (host-resolved).
137
+ * - `"<selection>"` — current editor.state.selection's union bounds.
138
+ * - `NodeId` — that node's content bounds.
139
+ * - `Rect` — an explicit world-space rectangle.
140
+ *
141
+ * No-ops if the target resolves to `null` (e.g. empty selection) or if
142
+ * the viewport size is 0 (no container).
143
+ */
144
+ fit(target, opts) {
145
+ if (this.viewport_w <= 0 || this.viewport_h <= 0) return;
146
+ const rect = typeof target === "string" ? this.resolve_bounds(target) : target;
147
+ if (!rect || rect.width <= 0 || rect.height <= 0) return;
148
+ const margin = opts?.margin ?? 64;
149
+ const viewport = {
150
+ x: 0,
151
+ y: 0,
152
+ width: this.viewport_w,
153
+ height: this.viewport_h
154
+ };
155
+ this.set_transform(cmath.ext.viewport.transformToFit(viewport, rect, margin));
156
+ }
157
+ /** Snap back to identity. */
158
+ reset() {
159
+ this.set_transform(cmath.transform.identity);
160
+ }
161
+ /**
162
+ * Subscribe to camera changes. Fires on every mutation. Cheap channel —
163
+ * does NOT bump `editor.state.version`. Same pattern as
164
+ * `editor.subscribe_surface_hover`.
165
+ */
166
+ subscribe(cb) {
167
+ this.listeners.add(cb);
168
+ return () => {
169
+ this.listeners.delete(cb);
170
+ };
171
+ }
172
+ /** @internal Surface drives this on container resize. */
173
+ _set_viewport_size(w, h) {
174
+ if (w === this.viewport_w && h === this.viewport_h) return;
175
+ this.viewport_w = w;
176
+ this.viewport_h = h;
177
+ this.notify();
178
+ }
179
+ /** Convert a screen-space point to world-space. */
180
+ screen_to_world(p) {
181
+ const inv = cmath.transform.invert(this._transform);
182
+ const [wx, wy] = cmath.vector2.transform([p.x, p.y], inv);
183
+ return {
184
+ x: wx,
185
+ y: wy
186
+ };
187
+ }
188
+ /** Convert a world-space point to screen-space. */
189
+ world_to_screen(p) {
190
+ const [sx, sy] = cmath.vector2.transform([p.x, p.y], this._transform);
191
+ return {
192
+ x: sx,
193
+ y: sy
194
+ };
195
+ }
196
+ notify() {
197
+ for (const cb of this.listeners) cb();
198
+ }
199
+ };
200
+ function transform_equal(a, b) {
201
+ return a[0][0] === b[0][0] && a[0][1] === b[0][1] && a[0][2] === b[0][2] && a[1][0] === b[1][0] && a[1][1] === b[1][1] && a[1][2] === b[1][2];
202
+ }
203
+ //#endregion
204
+ //#region src/gestures/gestures.ts
205
+ /**
206
+ * Sibling to `Keymap`. Owns a list of installed gesture bindings; each
207
+ * binding's `install(ctx)` is called eagerly when bound and uninstalled
208
+ * on `unbind` or surface detach.
209
+ */
210
+ var Gestures = class {
211
+ constructor(ctx) {
212
+ this.ctx = ctx;
213
+ this.entries = [];
214
+ }
215
+ /**
216
+ * Install a gesture binding. Returns an unbind function.
217
+ * Re-binding the same `id` does NOT replace — both will be active.
218
+ * Use `unbind({ id })` first if you want a clean swap.
219
+ */
220
+ bind(binding) {
221
+ const uninstall = binding.install(this.ctx);
222
+ const entry = {
223
+ binding,
224
+ uninstall
225
+ };
226
+ this.entries.push(entry);
227
+ return () => {
228
+ const i = this.entries.indexOf(entry);
229
+ if (i < 0) return;
230
+ this.entries.splice(i, 1);
231
+ uninstall();
232
+ };
233
+ }
234
+ /**
235
+ * Remove bindings matching the spec. With `{ id }`, all bindings with
236
+ * that id are uninstalled. With no spec, this is a no-op (use
237
+ * `dispose()` to nuke everything).
238
+ */
239
+ unbind(spec) {
240
+ if (spec.id === void 0) return;
241
+ const remaining = [];
242
+ for (const entry of this.entries) if (entry.binding.id === spec.id) entry.uninstall();
243
+ else remaining.push(entry);
244
+ this.entries = remaining;
245
+ }
246
+ /** All currently installed bindings. Order is registration order. */
247
+ bindings() {
248
+ return this.entries.map((e) => e.binding);
249
+ }
250
+ /** @internal Uninstall every binding. Surface calls on detach. */
251
+ _dispose() {
252
+ for (const entry of this.entries) entry.uninstall();
253
+ this.entries = [];
254
+ }
255
+ };
256
+ //#endregion
257
+ //#region src/gestures/defaults.ts
258
+ /** Default margin for `camera.fit` from keyboard shortcuts. */
259
+ const KEYBOARD_FIT_MARGIN = 64;
260
+ /** Default zoom step for `Cmd/Ctrl+=` / `Cmd/Ctrl+-`. */
261
+ const ZOOM_STEP = 1.2;
262
+ /** Per-wheel-unit zoom sensitivity for Cmd/Ctrl+wheel + pinch. */
263
+ const WHEEL_ZOOM_SENSITIVITY = .01;
264
+ /** Min/max zoom clamps. Generous; hosts that want tighter limits can
265
+ * unbind these defaults and bind their own. */
266
+ const MIN_ZOOM = .02;
267
+ const MAX_ZOOM = 256;
268
+ function clamp_zoom(z) {
269
+ return cmath.clamp(z, MIN_ZOOM, MAX_ZOOM);
270
+ }
271
+ /** wheel-pan-zoom: plain wheel = pan, Cmd/Ctrl+wheel + pinch = zoom-at-cursor. */
272
+ const WHEEL_PAN_ZOOM = {
273
+ id: "wheel-pan-zoom",
274
+ install({ container, camera }) {
275
+ const on_wheel = (e) => {
276
+ e.preventDefault();
277
+ if (e.ctrlKey || e.metaKey) {
278
+ const factor = 1 - e.deltaY * WHEEL_ZOOM_SENSITIVITY;
279
+ const eff = clamp_zoom(camera.zoom * factor) / camera.zoom;
280
+ if (eff === 1) return;
281
+ const rect = container.getBoundingClientRect();
282
+ camera.zoom_at(eff, {
283
+ x: e.clientX - rect.left,
284
+ y: e.clientY - rect.top
285
+ });
286
+ } else camera.pan({
287
+ x: -e.deltaX,
288
+ y: -e.deltaY
289
+ });
290
+ };
291
+ container.addEventListener("wheel", on_wheel, { passive: false });
292
+ return () => container.removeEventListener("wheel", on_wheel);
293
+ }
294
+ };
295
+ /**
296
+ * Begin a drag-pan from a pointerdown. Attaches `pointermove` / `pointerup`
297
+ * listeners scoped to the gesture lifetime, then detaches them on release.
298
+ * This is the d3-drag pattern: global listeners only exist while a drag is
299
+ * in flight, not for the surface's whole lifetime.
300
+ */
301
+ function begin_drag_pan(e, container, camera, on_release) {
302
+ let last_x = e.clientX;
303
+ let last_y = e.clientY;
304
+ try {
305
+ container.setPointerCapture(e.pointerId);
306
+ } catch {}
307
+ e.preventDefault();
308
+ e.stopPropagation();
309
+ const win = container.ownerDocument.defaultView ?? window;
310
+ const on_pointermove = (ev) => {
311
+ const dx = ev.clientX - last_x;
312
+ const dy = ev.clientY - last_y;
313
+ last_x = ev.clientX;
314
+ last_y = ev.clientY;
315
+ camera.pan({
316
+ x: dx,
317
+ y: dy
318
+ });
319
+ ev.preventDefault();
320
+ ev.stopPropagation();
321
+ };
322
+ const cleanup = () => {
323
+ win.removeEventListener("pointermove", on_pointermove, true);
324
+ win.removeEventListener("pointerup", on_pointerup, true);
325
+ win.removeEventListener("pointercancel", on_pointerup, true);
326
+ on_release?.();
327
+ };
328
+ const on_pointerup = () => cleanup();
329
+ win.addEventListener("pointermove", on_pointermove, true);
330
+ win.addEventListener("pointerup", on_pointerup, true);
331
+ win.addEventListener("pointercancel", on_pointerup, true);
332
+ }
333
+ /** The data-driven default set. Order = install order. */
334
+ const DEFAULT_GESTURE_BINDINGS = [
335
+ WHEEL_PAN_ZOOM,
336
+ {
337
+ id: "space-drag-pan",
338
+ install({ container, camera }) {
339
+ let space_held = false;
340
+ let prev_cursor = null;
341
+ const set_cursor = (next) => {
342
+ if (prev_cursor === null) prev_cursor = container.style.cursor;
343
+ container.style.cursor = next ?? prev_cursor ?? "";
344
+ if (next === null) prev_cursor = null;
345
+ };
346
+ const on_keydown = (e) => {
347
+ if (e.code !== "Space" || e.repeat) return;
348
+ if (is_text_input_focused()) return;
349
+ space_held = true;
350
+ set_cursor("grab");
351
+ e.preventDefault();
352
+ };
353
+ const on_keyup = (e) => {
354
+ if (e.code !== "Space") return;
355
+ space_held = false;
356
+ set_cursor(null);
357
+ };
358
+ const on_pointerdown = (e) => {
359
+ if (!space_held || e.button !== 0) return;
360
+ set_cursor("grabbing");
361
+ begin_drag_pan(e, container, camera, () => set_cursor(space_held ? "grab" : null));
362
+ };
363
+ const on_blur = () => {
364
+ space_held = false;
365
+ set_cursor(null);
366
+ };
367
+ const win = container.ownerDocument.defaultView ?? window;
368
+ win.addEventListener("keydown", on_keydown);
369
+ win.addEventListener("keyup", on_keyup);
370
+ container.addEventListener("pointerdown", on_pointerdown, true);
371
+ win.addEventListener("blur", on_blur);
372
+ return () => {
373
+ win.removeEventListener("keydown", on_keydown);
374
+ win.removeEventListener("keyup", on_keyup);
375
+ container.removeEventListener("pointerdown", on_pointerdown, true);
376
+ win.removeEventListener("blur", on_blur);
377
+ if (prev_cursor !== null) container.style.cursor = prev_cursor;
378
+ };
379
+ }
380
+ },
381
+ {
382
+ id: "middle-mouse-pan",
383
+ install({ container, camera }) {
384
+ const on_pointerdown = (e) => {
385
+ if (e.button !== 1) return;
386
+ begin_drag_pan(e, container, camera);
387
+ };
388
+ const on_auxclick = (e) => {
389
+ if (e.button === 1) e.preventDefault();
390
+ };
391
+ container.addEventListener("pointerdown", on_pointerdown, true);
392
+ container.addEventListener("auxclick", on_auxclick);
393
+ return () => {
394
+ container.removeEventListener("pointerdown", on_pointerdown, true);
395
+ container.removeEventListener("auxclick", on_auxclick);
396
+ };
397
+ }
398
+ },
399
+ {
400
+ id: "keyboard-zoom",
401
+ install({ container, camera }) {
402
+ const owner_doc = container.ownerDocument;
403
+ const on_keydown = (e) => {
404
+ const active = owner_doc.activeElement;
405
+ if (active && active !== owner_doc.body && !container.contains(active)) return;
406
+ if (is_text_input_focused()) return;
407
+ const mod = e.metaKey || e.ctrlKey;
408
+ if (e.shiftKey && !mod && (e.code === "Digit0" || e.code === "Numpad0")) {
409
+ camera.reset();
410
+ e.preventDefault();
411
+ } else if (e.shiftKey && !mod && (e.code === "Digit1" || e.code === "Digit9" || e.code === "Numpad1" || e.code === "Numpad9")) {
412
+ camera.fit("<root>", { margin: KEYBOARD_FIT_MARGIN });
413
+ e.preventDefault();
414
+ } else if (e.shiftKey && !mod && (e.code === "Digit2" || e.code === "Numpad2")) {
415
+ camera.fit("<selection>", { margin: KEYBOARD_FIT_MARGIN });
416
+ e.preventDefault();
417
+ } else if (mod && (e.code === "Equal" || e.code === "NumpadAdd")) {
418
+ camera.set_zoom(clamp_zoom(camera.zoom * ZOOM_STEP));
419
+ e.preventDefault();
420
+ } else if (mod && (e.code === "Minus" || e.code === "NumpadSubtract")) {
421
+ camera.set_zoom(clamp_zoom(camera.zoom / ZOOM_STEP));
422
+ e.preventDefault();
423
+ }
424
+ };
425
+ owner_doc.addEventListener("keydown", on_keydown);
426
+ return () => owner_doc.removeEventListener("keydown", on_keydown);
427
+ }
428
+ }
429
+ ];
430
+ /** Install every default binding into the gesture layer. */
431
+ function applyDefaultGestures(gestures) {
432
+ for (const b of DEFAULT_GESTURE_BINDINGS) gestures.bind(b);
433
+ }
434
+ //#endregion
6
435
  //#region src/text-surface.ts
7
436
  const SVG_NS = "http://www.w3.org/2000/svg";
8
437
  const XML_NS = "http://www.w3.org/XML/1998/namespace";
9
438
  var SvgTextSurface = class {
10
439
  constructor(textEl) {
11
440
  this.prevXmlSpace = void 0;
441
+ this.prevPointerEvents = void 0;
12
442
  this.last_caret_idx = -1;
13
443
  this.last_caret_visible = false;
14
444
  this.last_sel_start = -1;
@@ -22,6 +452,8 @@ var SvgTextSurface = class {
22
452
  this.prevXmlSpace = textEl.getAttributeNS(XML_NS, "space");
23
453
  textEl.setAttributeNS(XML_NS, "xml:space", "preserve");
24
454
  }
455
+ this.prevPointerEvents = textEl.getAttribute("pointer-events");
456
+ textEl.setAttribute("pointer-events", "bounding-box");
25
457
  const selection = ownerDoc.createElementNS(SVG_NS, "rect");
26
458
  selection.setAttribute("fill", "#2563eb");
27
459
  selection.setAttribute("fill-opacity", "0.25");
@@ -79,7 +511,10 @@ var SvgTextSurface = class {
79
511
  this.selectionRect.remove();
80
512
  if (this.prevXmlSpace !== void 0 && !keepEditMutations) if (this.prevXmlSpace === null) this.textEl.removeAttributeNS(XML_NS, "space");
81
513
  else this.textEl.setAttributeNS(XML_NS, "xml:space", this.prevXmlSpace);
514
+ if (this.prevPointerEvents !== void 0) if (this.prevPointerEvents === null) this.textEl.removeAttribute("pointer-events");
515
+ else this.textEl.setAttribute("pointer-events", this.prevPointerEvents);
82
516
  this.prevXmlSpace = void 0;
517
+ this.prevPointerEvents = void 0;
83
518
  }
84
519
  positionAtPoint(clientX, clientY) {
85
520
  const ctm = this.textEl.getScreenCTM();
@@ -169,20 +604,28 @@ const IS_MODIFIER_KEY = {
169
604
  * live `<text>` element out from under the about-to-mount text surface. */
170
605
  const TEXT_EDIT_PENDING = { __pending: true };
171
606
  /**
172
- * Attach a DOM surface to a headless editor. Returns a `SurfaceHandle` whose
173
- * `detach()` is the inverse — DOM cleared, listeners removed.
607
+ * Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
608
+ * whose `detach()` is the inverse — DOM cleared, listeners removed,
609
+ * gestures uninstalled.
174
610
  *
175
611
  * Usage is one-shot per container: the surface owns the container's children
176
612
  * for its lifetime, and `detach()` restores it to empty.
177
613
  */
178
614
  function attach_dom_surface(editor, options) {
179
- const surface = new DomSurface(editor, options.container);
180
- return editor.attach(surface);
615
+ const surface = new DomSurface(editor, options);
616
+ const inner = editor.attach(surface);
617
+ return {
618
+ detach: () => {
619
+ surface.detach_gestures();
620
+ inner.detach();
621
+ },
622
+ camera: surface.camera,
623
+ gestures: surface.gestures
624
+ };
181
625
  }
182
626
  var DomSurface = class {
183
- constructor(editor, container) {
627
+ constructor(editor, options) {
184
628
  this.editor = editor;
185
- this.container = container;
186
629
  this.svg_root = null;
187
630
  this.teardown = [];
188
631
  this.element_index = /* @__PURE__ */ new Map();
@@ -192,6 +635,9 @@ var DomSurface = class {
192
635
  this.text_edit_target = null;
193
636
  this.text_edit_original = "";
194
637
  this.editor_hover_internal = null;
638
+ this.container = options.container;
639
+ const container = this.container;
640
+ this.fit_on_attach = options.fit === true;
195
641
  if (getComputedStyle(container).position === "static") container.style.position = "relative";
196
642
  container.style.userSelect = "none";
197
643
  container.style.webkitUserSelect = "none";
@@ -209,6 +655,14 @@ var DomSurface = class {
209
655
  onIntent: (i) => this.commit_intent(i),
210
656
  style: { chromeColor: editor.style.chrome_color }
211
657
  });
658
+ this.camera = new Camera({
659
+ resolve_bounds: (target) => this.resolve_world_bounds(target),
660
+ initial: options.initial_camera
661
+ });
662
+ this.teardown.push(this.camera.subscribe(() => {
663
+ this.apply_camera_transform();
664
+ this.redraw();
665
+ }));
212
666
  this.render();
213
667
  this.sync_canvas_size();
214
668
  this.sync_surface_selection();
@@ -216,9 +670,19 @@ var DomSurface = class {
216
670
  const win = container.ownerDocument.defaultView ?? window;
217
671
  const raf = win.requestAnimationFrame(() => {
218
672
  this.sync_canvas_size();
673
+ this.honor_initial_fit();
219
674
  this.redraw();
220
675
  });
221
676
  this.teardown.push(() => win.cancelAnimationFrame(raf));
677
+ this.gestures = new Gestures({
678
+ container,
679
+ svg_root: () => this.svg_root,
680
+ hud_canvas: this.hud_canvas,
681
+ camera: this.camera,
682
+ editor,
683
+ handle: { detach: () => {} }
684
+ });
685
+ if (options.gestures !== false) applyDefaultGestures(this.gestures);
222
686
  const unsub = editor.subscribe(() => {
223
687
  this.render();
224
688
  this.sync_surface_selection();
@@ -290,6 +754,7 @@ var DomSurface = class {
290
754
  this.text_edit = null;
291
755
  this.text_edit_target = null;
292
756
  }
757
+ this.gestures._dispose();
293
758
  for (const fn of this.teardown) fn();
294
759
  this.teardown = [];
295
760
  this.hud.dispose();
@@ -299,6 +764,10 @@ var DomSurface = class {
299
764
  this.element_index.clear();
300
765
  this.active_preview = null;
301
766
  }
767
+ /** Public — invoked by the `DomSurfaceHandle` wrapper before `detach()`. */
768
+ detach_gestures() {
769
+ this.gestures._dispose();
770
+ }
302
771
  render() {
303
772
  if (this.text_edit) return;
304
773
  const owner_doc = this.container.ownerDocument;
@@ -311,6 +780,8 @@ var DomSurface = class {
311
780
  if (this.svg_root) this.svg_root.replaceWith(new_svg);
312
781
  else this.container.insertBefore(new_svg, this.hud_canvas);
313
782
  this.svg_root = new_svg;
783
+ this.apply_svg_layout();
784
+ this.apply_camera_transform();
314
785
  this.element_index.clear();
315
786
  const ids = doc.all_elements();
316
787
  let i = 0;
@@ -327,8 +798,142 @@ var DomSurface = class {
327
798
  sync_canvas_size() {
328
799
  const cr = this.container.getBoundingClientRect();
329
800
  this.hud.setSize(cr.width, cr.height);
801
+ this.camera._set_viewport_size(cr.width, cr.height);
330
802
  this.redraw();
331
803
  }
804
+ /**
805
+ * Apply absolute positioning + transform-origin to the SVG so the camera's
806
+ * CSS matrix maps SVG-coord (0,0) cleanly to container-screen (tx, ty).
807
+ * Called after every render() that may have replaced the root element.
808
+ */
809
+ apply_svg_layout() {
810
+ if (!this.svg_root) return;
811
+ const style = this.svg_root.style;
812
+ style.position = "absolute";
813
+ style.left = "0";
814
+ style.top = "0";
815
+ style.transformOrigin = "0 0";
816
+ }
817
+ /**
818
+ * Push the current camera transform to the SVG as a CSS `matrix(...)`.
819
+ * The HUD canvas stays at identity — selection chrome reads node bounds
820
+ * via `getScreenCTM()`, which already includes the CSS transform, so
821
+ * chrome aligns automatically and stays 1px sharp at any zoom.
822
+ */
823
+ apply_camera_transform() {
824
+ if (!this.svg_root) return;
825
+ const t = this.camera.transform;
826
+ this.svg_root.style.transform = `matrix(${t[0][0]}, ${t[1][0]}, ${t[0][1]}, ${t[1][1]}, ${t[0][2]}, ${t[1][2]})`;
827
+ }
828
+ /** One-shot fit-on-attach. Runs after layout has settled. */
829
+ honor_initial_fit() {
830
+ if (!this.fit_on_attach) return;
831
+ this.fit_on_attach = false;
832
+ this.camera.fit("<root>");
833
+ }
834
+ /**
835
+ * BoundsResolver for `Camera.fit(target)`. The Camera class handles Rect
836
+ * passthrough itself; this resolver only sees string targets — sentinels
837
+ * ("<root>", "<selection>") and NodeIds.
838
+ */
839
+ resolve_world_bounds(target) {
840
+ if (target === "<root>") return this.root_world_bounds();
841
+ if (target === "<selection>") {
842
+ const sel = this.editor.state.selection;
843
+ if (sel.length === 0) return null;
844
+ const rects = [];
845
+ for (const id of sel) {
846
+ const r = this.node_world_bounds(id);
847
+ if (r) rects.push(r);
848
+ }
849
+ if (rects.length === 0) return null;
850
+ return cmath.rect.union(rects);
851
+ }
852
+ return this.node_world_bounds(target);
853
+ }
854
+ /**
855
+ * World-space bounds of the root document. Prefer `viewBox` (the SVG's
856
+ * declared world rect), fall back to `width`/`height` attrs, then the
857
+ * SVG root's `getBBox()` as a last resort.
858
+ */
859
+ root_world_bounds() {
860
+ const root_id = this.editor.tree().root;
861
+ const doc = this.editor.document;
862
+ const view_box = doc.get_attr(root_id, "viewBox");
863
+ if (view_box) {
864
+ const parts = view_box.trim().split(/[\s,]+/).map(Number);
865
+ if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) return {
866
+ x: parts[0],
867
+ y: parts[1],
868
+ width: parts[2],
869
+ height: parts[3]
870
+ };
871
+ }
872
+ const w = parseFloat(doc.get_attr(root_id, "width") ?? "");
873
+ const h = parseFloat(doc.get_attr(root_id, "height") ?? "");
874
+ if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) return {
875
+ x: 0,
876
+ y: 0,
877
+ width: w,
878
+ height: h
879
+ };
880
+ if (this.svg_root) try {
881
+ const b = this.svg_root.getBBox();
882
+ if (b.width > 0 && b.height > 0) return {
883
+ x: b.x,
884
+ y: b.y,
885
+ width: b.width,
886
+ height: b.height
887
+ };
888
+ } catch {}
889
+ return null;
890
+ }
891
+ /**
892
+ * World-space bounds of a single node. Uses `getBBox()` (element-local)
893
+ * and projects through `getCTM()` (local → nearest viewport = SVG root).
894
+ */
895
+ node_world_bounds(id) {
896
+ const el = this.element_index.get(id);
897
+ if (!el) return null;
898
+ const ge = el;
899
+ if (typeof ge.getBBox !== "function" || typeof ge.getCTM !== "function") return null;
900
+ let bbox;
901
+ try {
902
+ const b = ge.getBBox();
903
+ bbox = {
904
+ x: b.x,
905
+ y: b.y,
906
+ width: b.width,
907
+ height: b.height
908
+ };
909
+ } catch {
910
+ return null;
911
+ }
912
+ const ctm = ge.getCTM();
913
+ if (!ctm) return bbox;
914
+ const project = (px, py) => ({
915
+ x: ctm.a * px + ctm.c * py + ctm.e,
916
+ y: ctm.b * px + ctm.d * py + ctm.f
917
+ });
918
+ const corners = [
919
+ project(bbox.x, bbox.y),
920
+ project(bbox.x + bbox.width, bbox.y),
921
+ project(bbox.x + bbox.width, bbox.y + bbox.height),
922
+ project(bbox.x, bbox.y + bbox.height)
923
+ ];
924
+ const xs = corners.map((c) => c.x);
925
+ const ys = corners.map((c) => c.y);
926
+ const left = Math.min(...xs);
927
+ const top = Math.min(...ys);
928
+ const right = Math.max(...xs);
929
+ const bottom = Math.max(...ys);
930
+ return {
931
+ x: left,
932
+ y: top,
933
+ width: right - left,
934
+ height: bottom - top
935
+ };
936
+ }
332
937
  /** Single per-frame draw entry — merges host-fed extras with surface chrome. */
333
938
  redraw() {
334
939
  this.hud.draw(this.compute_measurement_extra());
@@ -544,8 +1149,10 @@ var DomSurface = class {
544
1149
  dispatch_pointer(e, kind) {
545
1150
  if (this.text_edit) {
546
1151
  if (kind === "pointer_down") {
1152
+ e.preventDefault();
547
1153
  const el = this.text_edit_target ? this.element_index.get(this.text_edit_target) : null;
548
1154
  if (el && e.target instanceof Element && (e.target === el || el.contains(e.target))) this.text_edit.pointerDown(e.clientX, e.clientY, e.shiftKey);
1155
+ else this.text_edit.commit();
549
1156
  } else if (kind === "pointer_move") this.text_edit.pointerMove(e.clientX, e.clientY);
550
1157
  else if (kind === "pointer_up") this.text_edit.pointerUp();
551
1158
  return;