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