@grida/svg-editor 1.0.0-alpha.1 → 1.0.0-alpha.12
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.
- package/README.md +184 -185
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/dom-BlMk07oX.mjs +3515 -0
- package/dist/dom-Cvm9Towu.js +3545 -0
- package/dist/dom-DCX-a8Kr.d.ts +57 -0
- package/dist/dom-DgB4f-TE.d.mts +59 -0
- package/dist/dom.d.mts +3 -16
- package/dist/dom.d.ts +3 -16
- package/dist/dom.js +5 -1
- package/dist/dom.mjs +2 -2
- package/dist/editor-BH03X8cX.d.mts +1139 -0
- package/dist/editor-Bd4-VCEJ.d.ts +1139 -0
- package/dist/{editor-DQWUWrVZ.js → editor-CdyC3uAe.js} +1205 -388
- package/dist/{editor-B5z-gTML.mjs → editor-DtuRIs-Q.mjs} +1195 -372
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -2
- package/dist/index.mjs +3 -2
- package/dist/insertions-BJ-6o6o5.js +2399 -0
- package/dist/insertions-Okcuo-Ck.mjs +2176 -0
- package/dist/presets.d.mts +61 -0
- package/dist/presets.d.ts +61 -0
- package/dist/presets.js +61 -0
- package/dist/presets.mjs +55 -0
- package/dist/react.d.mts +94 -9
- package/dist/react.d.ts +94 -9
- package/dist/react.js +157 -19
- package/dist/react.mjs +147 -21
- package/package.json +11 -6
- package/dist/dom-CfP_ZURh.js +0 -963
- package/dist/dom-kA8NDuVh.mjs +0 -929
- package/dist/editor-CTtU2gu4.d.ts +0 -607
- package/dist/editor-JY7AQrR1.d.mts +0 -607
- package/dist/paint-DHq_3iwU.js +0 -509
- package/dist/paint-DuCg6Y-K.mjs +0 -461
|
@@ -0,0 +1,3545 @@
|
|
|
1
|
+
const require_insertions = require("./insertions-BJ-6o6o5.js");
|
|
2
|
+
let _grida_cmath = require("@grida/cmath");
|
|
3
|
+
_grida_cmath = require_insertions.__toESM(_grida_cmath);
|
|
4
|
+
let _grida_svg_parse = require("@grida/svg/parse");
|
|
5
|
+
let _grida_text_editor_dom = require("@grida/text-editor/dom");
|
|
6
|
+
let _grida_hud = require("@grida/hud");
|
|
7
|
+
let _grida_hud_cursors = require("@grida/hud/cursors");
|
|
8
|
+
let _grida_cmath__measurement = require("@grida/cmath/_measurement");
|
|
9
|
+
let _grida_cmath__snap = require("@grida/cmath/_snap");
|
|
10
|
+
//#region src/core/camera.ts
|
|
11
|
+
/**
|
|
12
|
+
* Surface-scoped pan/zoom state.
|
|
13
|
+
*
|
|
14
|
+
* The public shape leads with the peer convention (`center` / `zoom` /
|
|
15
|
+
* `bounds`) and keeps the matrix as an advanced read. Methods mirror
|
|
16
|
+
* Figma/Penpot where they overlap.
|
|
17
|
+
*/
|
|
18
|
+
var Camera = class {
|
|
19
|
+
constructor(opts) {
|
|
20
|
+
this.viewport_w = 0;
|
|
21
|
+
this.viewport_h = 0;
|
|
22
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
23
|
+
this._constraints = null;
|
|
24
|
+
this._transform = opts.initial ?? _grida_cmath.default.transform.identity;
|
|
25
|
+
this.resolve_bounds = opts.resolve_bounds;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Current viewport constraint, or `null` for free pan/zoom. Set with
|
|
29
|
+
* `camera.constraints = { type: 'cover', bounds: '<root>', padding: 80 }`
|
|
30
|
+
* to clamp zoom + pan; assign `null` to clear.
|
|
31
|
+
*
|
|
32
|
+
* Constraints are applied synchronously inside `set_transform` (and
|
|
33
|
+
* `_set_viewport_size`), so every public mutation respects them
|
|
34
|
+
* automatically — the host never needs to subscribe-and-clamp itself.
|
|
35
|
+
*/
|
|
36
|
+
get constraints() {
|
|
37
|
+
return this._constraints;
|
|
38
|
+
}
|
|
39
|
+
set constraints(c) {
|
|
40
|
+
this._constraints = c;
|
|
41
|
+
if (c) this.reenforce();
|
|
42
|
+
}
|
|
43
|
+
/** Underlying 2D affine. World→screen. */
|
|
44
|
+
get transform() {
|
|
45
|
+
return this._transform;
|
|
46
|
+
}
|
|
47
|
+
/** Uniform scale factor. 1 = 100 %. */
|
|
48
|
+
get zoom() {
|
|
49
|
+
return this._transform[0][0];
|
|
50
|
+
}
|
|
51
|
+
/** World-space point currently at viewport center. */
|
|
52
|
+
get center() {
|
|
53
|
+
return this.screen_to_world({
|
|
54
|
+
x: this.viewport_w / 2,
|
|
55
|
+
y: this.viewport_h / 2
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** World-space rectangle visible in the viewport. */
|
|
59
|
+
get bounds() {
|
|
60
|
+
const tl = this.screen_to_world({
|
|
61
|
+
x: 0,
|
|
62
|
+
y: 0
|
|
63
|
+
});
|
|
64
|
+
const br = this.screen_to_world({
|
|
65
|
+
x: this.viewport_w,
|
|
66
|
+
y: this.viewport_h
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
x: tl.x,
|
|
70
|
+
y: tl.y,
|
|
71
|
+
width: br.x - tl.x,
|
|
72
|
+
height: br.y - tl.y
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Translate the camera by a screen-space delta. */
|
|
76
|
+
pan(delta_screen) {
|
|
77
|
+
const t = this._transform;
|
|
78
|
+
this.set_transform([[
|
|
79
|
+
t[0][0],
|
|
80
|
+
t[0][1],
|
|
81
|
+
t[0][2] + delta_screen.x
|
|
82
|
+
], [
|
|
83
|
+
t[1][0],
|
|
84
|
+
t[1][1],
|
|
85
|
+
t[1][2] + delta_screen.y
|
|
86
|
+
]]);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Multiply zoom by `factor` keeping `origin_screen` fixed in world space.
|
|
90
|
+
* Used by wheel-zoom-at-cursor and pinch-zoom.
|
|
91
|
+
*/
|
|
92
|
+
zoom_at(factor, origin_screen) {
|
|
93
|
+
const t = this._transform;
|
|
94
|
+
const s2 = t[0][0] * factor;
|
|
95
|
+
const tx2 = origin_screen.x * (1 - factor) + factor * t[0][2];
|
|
96
|
+
const ty2 = origin_screen.y * (1 - factor) + factor * t[1][2];
|
|
97
|
+
this.set_transform([[
|
|
98
|
+
s2,
|
|
99
|
+
0,
|
|
100
|
+
tx2
|
|
101
|
+
], [
|
|
102
|
+
0,
|
|
103
|
+
s2,
|
|
104
|
+
ty2
|
|
105
|
+
]]);
|
|
106
|
+
}
|
|
107
|
+
/** Pan so `c` lands at the viewport center. Zoom unchanged. */
|
|
108
|
+
set_center(c) {
|
|
109
|
+
const s = this._transform[0][0];
|
|
110
|
+
const tx = this.viewport_w / 2 - s * c.x;
|
|
111
|
+
const ty = this.viewport_h / 2 - s * c.y;
|
|
112
|
+
this.set_transform([[
|
|
113
|
+
s,
|
|
114
|
+
0,
|
|
115
|
+
tx
|
|
116
|
+
], [
|
|
117
|
+
0,
|
|
118
|
+
s,
|
|
119
|
+
ty
|
|
120
|
+
]]);
|
|
121
|
+
}
|
|
122
|
+
/** Set zoom directly; pivot defaults to viewport center. */
|
|
123
|
+
set_zoom(z, pivot_screen) {
|
|
124
|
+
const current = this._transform[0][0];
|
|
125
|
+
if (current === 0) return;
|
|
126
|
+
const factor = z / current;
|
|
127
|
+
const pivot = pivot_screen ?? {
|
|
128
|
+
x: this.viewport_w / 2,
|
|
129
|
+
y: this.viewport_h / 2
|
|
130
|
+
};
|
|
131
|
+
this.zoom_at(factor, pivot);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Replace the entire transform.
|
|
135
|
+
*
|
|
136
|
+
* Idempotent: when the new transform is element-wise equal to the current
|
|
137
|
+
* one, this is a no-op (no notification fires). This is the seam that
|
|
138
|
+
* makes external constraint loops (e.g. "subscribe → compute clamped →
|
|
139
|
+
* set_transform") terminate: the clamp re-emits the same transform on
|
|
140
|
+
* the second pass, set_transform short-circuits, no recursion.
|
|
141
|
+
*
|
|
142
|
+
* When `camera.constraints` is non-null, the input transform is clamped
|
|
143
|
+
* synchronously before being stored — every public mutation respects the
|
|
144
|
+
* constraint automatically.
|
|
145
|
+
*/
|
|
146
|
+
set_transform(t) {
|
|
147
|
+
const next = this.apply_constraints(t);
|
|
148
|
+
if (transform_equal(this._transform, next)) return;
|
|
149
|
+
this._transform = next;
|
|
150
|
+
this.notify();
|
|
151
|
+
}
|
|
152
|
+
/** Viewport size in screen pixels. Read by host code computing constraints. */
|
|
153
|
+
get viewport_size() {
|
|
154
|
+
return {
|
|
155
|
+
width: this.viewport_w,
|
|
156
|
+
height: this.viewport_h
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Fit a target into the viewport.
|
|
161
|
+
*
|
|
162
|
+
* - `"<root>"` — the document root's content bounds (host-resolved).
|
|
163
|
+
* - `"<selection>"` — current editor.state.selection's union bounds.
|
|
164
|
+
* - `NodeId` — that node's content bounds.
|
|
165
|
+
* - `Rect` — an explicit world-space rectangle.
|
|
166
|
+
*
|
|
167
|
+
* No-ops if the target resolves to `null` (e.g. empty selection) or if
|
|
168
|
+
* the viewport size is 0 (no container).
|
|
169
|
+
*/
|
|
170
|
+
fit(target, opts) {
|
|
171
|
+
if (this.viewport_w <= 0 || this.viewport_h <= 0) return;
|
|
172
|
+
const rect = typeof target === "string" ? this.resolve_bounds(target) : target;
|
|
173
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
|
174
|
+
const margin = opts?.margin ?? 64;
|
|
175
|
+
const viewport = {
|
|
176
|
+
x: 0,
|
|
177
|
+
y: 0,
|
|
178
|
+
width: this.viewport_w,
|
|
179
|
+
height: this.viewport_h
|
|
180
|
+
};
|
|
181
|
+
this.set_transform(_grida_cmath.default.ext.viewport.transformToFit(viewport, rect, margin));
|
|
182
|
+
}
|
|
183
|
+
/** Snap back to identity. */
|
|
184
|
+
reset() {
|
|
185
|
+
this.set_transform(_grida_cmath.default.transform.identity);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Subscribe to camera changes. Fires on every mutation. Cheap channel —
|
|
189
|
+
* does NOT bump `editor.state.version`. Same pattern as
|
|
190
|
+
* `editor.subscribe_surface_hover`.
|
|
191
|
+
*/
|
|
192
|
+
subscribe(cb) {
|
|
193
|
+
this.listeners.add(cb);
|
|
194
|
+
return () => {
|
|
195
|
+
this.listeners.delete(cb);
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/** @internal Surface drives this on container resize. */
|
|
199
|
+
_set_viewport_size(w, h) {
|
|
200
|
+
if (w === this.viewport_w && h === this.viewport_h) return;
|
|
201
|
+
this.viewport_w = w;
|
|
202
|
+
this.viewport_h = h;
|
|
203
|
+
if (this._constraints) {
|
|
204
|
+
const next = this.apply_constraints(this._transform);
|
|
205
|
+
if (!transform_equal(this._transform, next)) this._transform = next;
|
|
206
|
+
}
|
|
207
|
+
this.notify();
|
|
208
|
+
}
|
|
209
|
+
/** Convert a screen-space point to world-space. */
|
|
210
|
+
screen_to_world(p) {
|
|
211
|
+
const inv = _grida_cmath.default.transform.invert(this._transform);
|
|
212
|
+
const [wx, wy] = _grida_cmath.default.vector2.transform([p.x, p.y], inv);
|
|
213
|
+
return {
|
|
214
|
+
x: wx,
|
|
215
|
+
y: wy
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/** Convert a world-space point to screen-space. */
|
|
219
|
+
world_to_screen(p) {
|
|
220
|
+
const [sx, sy] = _grida_cmath.default.vector2.transform([p.x, p.y], this._transform);
|
|
221
|
+
return {
|
|
222
|
+
x: sx,
|
|
223
|
+
y: sy
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Apply the current constraint (if any) to a candidate transform.
|
|
228
|
+
* Pure: returns the clamped result, never mutates state. Returns the
|
|
229
|
+
* input unchanged when constraints are null / bounds are unresolvable /
|
|
230
|
+
* viewport is 0.
|
|
231
|
+
*/
|
|
232
|
+
apply_constraints(t) {
|
|
233
|
+
if (!this._constraints) return t;
|
|
234
|
+
if (this.viewport_w <= 0 || this.viewport_h <= 0) return t;
|
|
235
|
+
switch (this._constraints.type) {
|
|
236
|
+
case "cover": return clamp_cover(t, this._constraints, this.viewport_w, this.viewport_h, this.resolve_bounds);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Re-clamp the stored transform against the current constraint. Called
|
|
241
|
+
* from the `constraints` setter; `_set_viewport_size` has its own
|
|
242
|
+
* notify-inclusive path.
|
|
243
|
+
*/
|
|
244
|
+
reenforce() {
|
|
245
|
+
if (!this._constraints) return;
|
|
246
|
+
const next = this.apply_constraints(this._transform);
|
|
247
|
+
if (transform_equal(this._transform, next)) return;
|
|
248
|
+
this._transform = next;
|
|
249
|
+
this.notify();
|
|
250
|
+
}
|
|
251
|
+
notify() {
|
|
252
|
+
for (const cb of this.listeners) cb();
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
/**
|
|
256
|
+
* Clamp a transform under a `'cover'` constraint:
|
|
257
|
+
* - Zoom lower-bounded at fit-with-padding (the slide always fills the
|
|
258
|
+
* viewport edge-to-edge).
|
|
259
|
+
* - When at min-zoom the slide is locked centered (bounds smaller than
|
|
260
|
+
* viewport on the constrained axis is impossible above min_zoom; below
|
|
261
|
+
* is impossible because zoom is clamped up).
|
|
262
|
+
* - When zoomed in, pan is clamped so the slide always covers the viewport
|
|
263
|
+
* (no black bars).
|
|
264
|
+
*
|
|
265
|
+
* Returns the input transform unchanged when bounds can't be resolved or
|
|
266
|
+
* are degenerate.
|
|
267
|
+
*/
|
|
268
|
+
function clamp_cover(t, c, vp_w, vp_h, resolve) {
|
|
269
|
+
const bounds = typeof c.bounds === "string" ? resolve(c.bounds) : c.bounds;
|
|
270
|
+
if (!bounds || bounds.width <= 0 || bounds.height <= 0) return t;
|
|
271
|
+
const padding = c.padding ?? 0;
|
|
272
|
+
const eff_w = vp_w - 2 * padding;
|
|
273
|
+
const eff_h = vp_h - 2 * padding;
|
|
274
|
+
if (eff_w <= 0 || eff_h <= 0) return t;
|
|
275
|
+
const min_zoom = Math.min(eff_w / bounds.width, eff_h / bounds.height);
|
|
276
|
+
const s = Math.max(t[0][0], min_zoom);
|
|
277
|
+
const sw = s * bounds.width;
|
|
278
|
+
const sh = s * bounds.height;
|
|
279
|
+
const o = Math.max(0, c.pan_overshoot ?? 0);
|
|
280
|
+
const tx = sw > vp_w ? _grida_cmath.default.clamp(t[0][2], vp_w - s * (bounds.x + bounds.width) - o, -s * bounds.x + o) : (vp_w - sw) / 2 - s * bounds.x;
|
|
281
|
+
const ty = sh > vp_h ? _grida_cmath.default.clamp(t[1][2], vp_h - s * (bounds.y + bounds.height) - o, -s * bounds.y + o) : (vp_h - sh) / 2 - s * bounds.y;
|
|
282
|
+
return [[
|
|
283
|
+
s,
|
|
284
|
+
0,
|
|
285
|
+
tx
|
|
286
|
+
], [
|
|
287
|
+
0,
|
|
288
|
+
s,
|
|
289
|
+
ty
|
|
290
|
+
]];
|
|
291
|
+
}
|
|
292
|
+
function transform_equal(a, b) {
|
|
293
|
+
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];
|
|
294
|
+
}
|
|
295
|
+
//#endregion
|
|
296
|
+
//#region src/core/geometry.ts
|
|
297
|
+
/**
|
|
298
|
+
* Caches `bounds_of` results keyed on `NodeId`; full-clears on either
|
|
299
|
+
* `structure_version` or `geometry_version` bump. See docs/wg/feat-svg-editor/geometry.md for
|
|
300
|
+
* why the cache is load-bearing under the surface's per-tick re-render.
|
|
301
|
+
*/
|
|
302
|
+
var MemoizedGeometryProvider = class {
|
|
303
|
+
constructor(driver, signals) {
|
|
304
|
+
this.unsubscribers = [];
|
|
305
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
306
|
+
this.driver = driver;
|
|
307
|
+
const invalidate = () => this.cache.clear();
|
|
308
|
+
this.unsubscribers.push(signals.subscribe_structure(invalidate));
|
|
309
|
+
this.unsubscribers.push(signals.subscribe_geometry(invalidate));
|
|
310
|
+
}
|
|
311
|
+
bounds_of(id) {
|
|
312
|
+
if (this.cache.has(id)) return this.cache.get(id) ?? null;
|
|
313
|
+
const r = this.driver.bounds_of(id);
|
|
314
|
+
this.cache.set(id, r);
|
|
315
|
+
return r;
|
|
316
|
+
}
|
|
317
|
+
bounds_of_many(ids) {
|
|
318
|
+
const out = /* @__PURE__ */ new Map();
|
|
319
|
+
const missing = [];
|
|
320
|
+
for (const id of ids) if (this.cache.has(id)) {
|
|
321
|
+
const r = this.cache.get(id);
|
|
322
|
+
if (r) out.set(id, r);
|
|
323
|
+
} else missing.push(id);
|
|
324
|
+
if (missing.length > 0) {
|
|
325
|
+
const fresh = this.driver.bounds_of_many(missing);
|
|
326
|
+
for (const id of missing) {
|
|
327
|
+
const r = fresh.get(id) ?? null;
|
|
328
|
+
this.cache.set(id, r);
|
|
329
|
+
if (r) out.set(id, r);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Pass-through. These are less hot than `bounds_of` (called once per
|
|
336
|
+
* gesture frame at most) and their result is sensitive to current
|
|
337
|
+
* viewport state, so caching them would be a footgun.
|
|
338
|
+
*/
|
|
339
|
+
nodes_in_rect(rect) {
|
|
340
|
+
return this.driver.nodes_in_rect(rect);
|
|
341
|
+
}
|
|
342
|
+
node_at_point(p) {
|
|
343
|
+
return this.driver.node_at_point(p);
|
|
344
|
+
}
|
|
345
|
+
/** Unsubscribe from both signals. Call on surface detach. */
|
|
346
|
+
dispose() {
|
|
347
|
+
for (const unsub of this.unsubscribers) unsub();
|
|
348
|
+
this.unsubscribers.length = 0;
|
|
349
|
+
this.cache.clear();
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
//#endregion
|
|
353
|
+
//#region src/core/hit-shape.ts
|
|
354
|
+
/**
|
|
355
|
+
* Shortest distance from world-space `p` to `shape`. Returns `0` for
|
|
356
|
+
* points strictly inside a filled region (rect / ellipse / polygon
|
|
357
|
+
* interior); the picker treats this as "inside the fill" — an exact
|
|
358
|
+
* hit, no tolerance required.
|
|
359
|
+
*
|
|
360
|
+
* For `path`, this is *edge* distance (no interior test). v1 doesn't
|
|
361
|
+
* model winding-rule fill regions for paths; see docs/wg/feat-svg-editor/hit-test.md.
|
|
362
|
+
*/
|
|
363
|
+
function point_distance_to_shape(p, shape) {
|
|
364
|
+
const pv = [p.x, p.y];
|
|
365
|
+
switch (shape.kind) {
|
|
366
|
+
case "rect": {
|
|
367
|
+
const r = {
|
|
368
|
+
x: shape.x,
|
|
369
|
+
y: shape.y,
|
|
370
|
+
width: shape.width,
|
|
371
|
+
height: shape.height
|
|
372
|
+
};
|
|
373
|
+
if (_grida_cmath.default.rect.containsPoint(r, pv)) return 0;
|
|
374
|
+
const [dx, dy] = _grida_cmath.default.rect.offset(r, pv);
|
|
375
|
+
return Math.hypot(dx, dy);
|
|
376
|
+
}
|
|
377
|
+
case "ellipse": {
|
|
378
|
+
if (shape.rx <= 0 || shape.ry <= 0) return Math.hypot(p.x - shape.cx, p.y - shape.cy);
|
|
379
|
+
const nx = (p.x - shape.cx) / shape.rx;
|
|
380
|
+
const ny = (p.y - shape.cy) / shape.ry;
|
|
381
|
+
const r_norm = Math.hypot(nx, ny);
|
|
382
|
+
if (r_norm <= 1) return 0;
|
|
383
|
+
return (r_norm - 1) * Math.min(shape.rx, shape.ry);
|
|
384
|
+
}
|
|
385
|
+
case "segment": return _grida_cmath.default.segment.point_distance(pv, [shape.a.x, shape.a.y], [shape.b.x, shape.b.y]);
|
|
386
|
+
case "polyline": return _grida_cmath.default.polyline.point_distance(pv, shape.pts.map((q) => [q.x, q.y]), false);
|
|
387
|
+
case "polygon": {
|
|
388
|
+
const ring = shape.pts.map((q) => [q.x, q.y]);
|
|
389
|
+
if (ring.length >= 3 && _grida_cmath.default.polygon.pointInPolygon(pv, ring)) return 0;
|
|
390
|
+
return _grida_cmath.default.polyline.point_distance(pv, ring, true);
|
|
391
|
+
}
|
|
392
|
+
case "path": return _grida_cmath.default.polyline.point_distance(pv, shape.pts.map((q) => [q.x, q.y]), shape.closed);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Topmost node within `tolerance_world` of `p`. Returns `null` if no
|
|
397
|
+
* candidate qualifies.
|
|
398
|
+
*
|
|
399
|
+
* Algorithm:
|
|
400
|
+
* 1. Walk `ordered_ids` topmost-first.
|
|
401
|
+
* 2. AABB pre-filter via `cmath.rect.pad(bounds_of(id), tolerance)`.
|
|
402
|
+
* 3. Distance check via `point_distance_to_shape`.
|
|
403
|
+
* 4. First candidate with `dist <= tolerance` wins.
|
|
404
|
+
*
|
|
405
|
+
* Candidates whose `hit_shape_of` returns `null` are skipped. Candidates
|
|
406
|
+
* whose `bounds_of` returns `null` are skipped.
|
|
407
|
+
*/
|
|
408
|
+
function pick_at_world(p, opts) {
|
|
409
|
+
const { tolerance_world, ordered_ids, bounds_of, hit_shape_of } = opts;
|
|
410
|
+
const pv = [p.x, p.y];
|
|
411
|
+
for (let i = ordered_ids.length - 1; i >= 0; i--) {
|
|
412
|
+
const id = ordered_ids[i];
|
|
413
|
+
const bounds = bounds_of(id);
|
|
414
|
+
if (!bounds) continue;
|
|
415
|
+
const inflated = _grida_cmath.default.rect.pad(bounds, tolerance_world);
|
|
416
|
+
if (!_grida_cmath.default.rect.containsPoint(inflated, pv)) continue;
|
|
417
|
+
const shape = hit_shape_of(id);
|
|
418
|
+
if (!shape) continue;
|
|
419
|
+
if (point_distance_to_shape(p, shape) <= tolerance_world) return id;
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
/** Caches `hit_shape_of` results keyed on NodeId; full-clears on either
|
|
424
|
+
* `structure_version` or `geometry_version` bump. Mirror of
|
|
425
|
+
* `MemoizedGeometryProvider` in `core/geometry.ts`. */
|
|
426
|
+
var MemoizedHitShapeProvider = class {
|
|
427
|
+
constructor(driver, signals) {
|
|
428
|
+
this.unsubscribers = [];
|
|
429
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
430
|
+
this.driver = driver;
|
|
431
|
+
const invalidate = () => this.cache.clear();
|
|
432
|
+
this.unsubscribers.push(signals.subscribe_structure(invalidate));
|
|
433
|
+
this.unsubscribers.push(signals.subscribe_geometry(invalidate));
|
|
434
|
+
}
|
|
435
|
+
hit_shape_of(id) {
|
|
436
|
+
if (this.cache.has(id)) return this.cache.get(id) ?? null;
|
|
437
|
+
const s = this.driver.hit_shape_of(id);
|
|
438
|
+
this.cache.set(id, s);
|
|
439
|
+
return s;
|
|
440
|
+
}
|
|
441
|
+
dispose() {
|
|
442
|
+
for (const unsub of this.unsubscribers) unsub();
|
|
443
|
+
this.unsubscribers.length = 0;
|
|
444
|
+
this.cache.clear();
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
//#endregion
|
|
448
|
+
//#region src/core/snap/session.ts
|
|
449
|
+
/**
|
|
450
|
+
* Sub-pixel tolerance used as the effective threshold for `"aligned"`
|
|
451
|
+
* policy. cmath's `snap1D` only fires when |delta| ≤ threshold, so
|
|
452
|
+
* setting the threshold near zero means the engine only matches
|
|
453
|
+
* alignments that are already exact (within rounding noise). We can't
|
|
454
|
+
* use a post-hoc `correction == 0` check instead — `snap1D` locks in
|
|
455
|
+
* the FIRST signed delta it finds (lexicographic over the 9-point
|
|
456
|
+
* order), so a touching pair where `agent.left↔anchor.left` needs +10
|
|
457
|
+
* while `agent.right↔anchor.left` needs 0 will return correction = 10,
|
|
458
|
+
* not 0. The tiny-threshold trick lets only the 0-delta matches pass.
|
|
459
|
+
*/
|
|
460
|
+
const ALIGNED_THRESHOLD = .5;
|
|
461
|
+
/**
|
|
462
|
+
* Gesture-scoped snap state. Constructor freezes agent + neighbor rects
|
|
463
|
+
* once; `snap()` runs the cmath engine per frame against the frozen
|
|
464
|
+
* inputs. Rects are in whatever space the caller chose (svg-editor uses
|
|
465
|
+
* HUD-container CSS px); the engine is space-agnostic as long as agents,
|
|
466
|
+
* neighbors, delta, and threshold all share that space.
|
|
467
|
+
*/
|
|
468
|
+
var SnapSession = class {
|
|
469
|
+
constructor(opts) {
|
|
470
|
+
this._last_guide = void 0;
|
|
471
|
+
const live_agents = opts.agents.filter((r) => r.width > 0 || r.height > 0);
|
|
472
|
+
const live_neighbors = opts.neighbors.filter((r) => r.width > 0 || r.height > 0);
|
|
473
|
+
this.agents = live_agents;
|
|
474
|
+
this.anchors = live_neighbors;
|
|
475
|
+
const xs = [];
|
|
476
|
+
const ys = [];
|
|
477
|
+
for (const a of live_neighbors) {
|
|
478
|
+
xs.push(a.x, a.x + a.width / 2, a.x + a.width);
|
|
479
|
+
ys.push(a.y, a.y + a.height / 2, a.y + a.height);
|
|
480
|
+
}
|
|
481
|
+
this.anchor_xs = xs;
|
|
482
|
+
this.anchor_ys = ys;
|
|
483
|
+
this.baseline_union = live_agents.length > 0 ? _grida_cmath.default.rect.union([...live_agents]) : null;
|
|
484
|
+
}
|
|
485
|
+
/** Read-only snapshot of the most recent guide. Host code consumes
|
|
486
|
+
* this from `compute_snap_extra()` style helpers. */
|
|
487
|
+
get last_guide() {
|
|
488
|
+
return this._last_guide;
|
|
489
|
+
}
|
|
490
|
+
/** Read-only snapshot of the pre-translation agent-union rect.
|
|
491
|
+
* Consumed by the translate pipeline's `stage_pixel_grid` to anchor
|
|
492
|
+
* the integer-grid quantization on the gesture's starting origin
|
|
493
|
+
* (so a rect at `x=0.5` settles to `x=1` after first nudge, not on
|
|
494
|
+
* every fractional drag). Returns `null` when no agents were frozen. */
|
|
495
|
+
get baseline_union_readonly() {
|
|
496
|
+
return this.baseline_union;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Run snap for a candidate cumulative delta.
|
|
500
|
+
*
|
|
501
|
+
* Returns the corrected delta (== input when no snap fires or snap
|
|
502
|
+
* is disabled) and a `SnapGuide` for HUD rendering (`undefined` when
|
|
503
|
+
* no guide should be drawn). Guide emission is governed by `policy`
|
|
504
|
+
* — see `SnapGuidePolicy`.
|
|
505
|
+
*
|
|
506
|
+
* The same guide is also stashed on `last_guide`.
|
|
507
|
+
*/
|
|
508
|
+
snap(delta, opts, policy = "engine") {
|
|
509
|
+
const baseline = this.baseline_union;
|
|
510
|
+
if (!opts.enabled || !baseline || !this.agents || !this.anchors || this.anchors.length === 0) {
|
|
511
|
+
this._last_guide = void 0;
|
|
512
|
+
return {
|
|
513
|
+
delta,
|
|
514
|
+
guide: void 0
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
const dx = delta.x;
|
|
518
|
+
const dy = delta.y;
|
|
519
|
+
const translated = [];
|
|
520
|
+
for (const r of this.agents) translated.push({
|
|
521
|
+
x: r.x + dx,
|
|
522
|
+
y: r.y + dy,
|
|
523
|
+
width: r.width,
|
|
524
|
+
height: r.height
|
|
525
|
+
});
|
|
526
|
+
const agent_rect = _grida_cmath.default.rect.union(translated);
|
|
527
|
+
const padX = opts.threshold_px + agent_rect.width;
|
|
528
|
+
const padY = opts.threshold_px + agent_rect.height;
|
|
529
|
+
const minX = agent_rect.x - padX;
|
|
530
|
+
const maxX = agent_rect.x + agent_rect.width + padX;
|
|
531
|
+
const minY = agent_rect.y - padY;
|
|
532
|
+
const maxY = agent_rect.y + agent_rect.height + padY;
|
|
533
|
+
const nearby = [];
|
|
534
|
+
for (const r of this.anchors) {
|
|
535
|
+
const rMaxX = r.x + r.width;
|
|
536
|
+
const rMaxY = r.y + r.height;
|
|
537
|
+
const x_overlap = rMaxX >= minX && r.x <= maxX;
|
|
538
|
+
const y_overlap = rMaxY >= minY && r.y <= maxY;
|
|
539
|
+
if (x_overlap || y_overlap) nearby.push(r);
|
|
540
|
+
}
|
|
541
|
+
const result = (0, _grida_cmath__snap.snapToCanvasGeometry)(agent_rect, { objects: nearby }, {
|
|
542
|
+
x: opts.threshold_px,
|
|
543
|
+
y: opts.threshold_px
|
|
544
|
+
});
|
|
545
|
+
const corrected = result.by_objects ? {
|
|
546
|
+
x: result.by_objects.translated.x - baseline.x,
|
|
547
|
+
y: result.by_objects.translated.y - baseline.y
|
|
548
|
+
} : {
|
|
549
|
+
x: dx,
|
|
550
|
+
y: dy
|
|
551
|
+
};
|
|
552
|
+
const guide_source = policy === "aligned" ? (0, _grida_cmath__snap.snapToCanvasGeometry)(agent_rect, { objects: nearby }, {
|
|
553
|
+
x: ALIGNED_THRESHOLD,
|
|
554
|
+
y: ALIGNED_THRESHOLD
|
|
555
|
+
}) : result;
|
|
556
|
+
const sg = _grida_cmath__snap.guide.plot(guide_source);
|
|
557
|
+
const guide = sg.lines.length > 0 || sg.points.length > 0 || sg.rules.length > 0 ? sg : void 0;
|
|
558
|
+
this._last_guide = guide;
|
|
559
|
+
return {
|
|
560
|
+
delta: corrected,
|
|
561
|
+
guide
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Resize-snap pass.
|
|
566
|
+
*
|
|
567
|
+
* Snaps the *moving edges* of an effective rect (post per-element
|
|
568
|
+
* constraint) against the frozen neighbor anchors. The caller is
|
|
569
|
+
* responsible for computing the effective rect — see
|
|
570
|
+
* `effective_resize` in `../resize-capability.ts`. This entrypoint
|
|
571
|
+
* does NOT understand circle / text uniformity; it only sees the rect
|
|
572
|
+
* and the edge mask.
|
|
573
|
+
*
|
|
574
|
+
* Per-axis snap candidates are the neighbors' left / center-x / right
|
|
575
|
+
* (for x) and top / center-y / bottom (for y), exactly what
|
|
576
|
+
* translate-snap uses. Only the moving edge participates as the agent
|
|
577
|
+
* — the opposite edge is fixed by the resize anchor and must not
|
|
578
|
+
* contribute corrections.
|
|
579
|
+
*
|
|
580
|
+
* Returns signed corrections in the rect's space. Guide is a SnapGuide
|
|
581
|
+
* compatible with `snapGuideToHUDDraw`.
|
|
582
|
+
*/
|
|
583
|
+
snap_resize(effective_rect, edges, opts) {
|
|
584
|
+
if (!opts.enabled || !this.anchor_xs || this.anchor_xs.length === 0 || edges.x === null && edges.y === null) {
|
|
585
|
+
this._last_guide = void 0;
|
|
586
|
+
return {
|
|
587
|
+
dx: 0,
|
|
588
|
+
dy: 0,
|
|
589
|
+
guide: void 0
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
const agent_x = edges.x === "left" ? effective_rect.x : edges.x === "right" ? effective_rect.x + effective_rect.width : null;
|
|
593
|
+
const agent_y = edges.y === "top" ? effective_rect.y : edges.y === "bottom" ? effective_rect.y + effective_rect.height : null;
|
|
594
|
+
let dx = 0;
|
|
595
|
+
let dy = 0;
|
|
596
|
+
let hit_x_offset = null;
|
|
597
|
+
let hit_y_offset = null;
|
|
598
|
+
if (agent_x !== null) {
|
|
599
|
+
const r = _grida_cmath.default.ext.snap.snap1D([agent_x], this.anchor_xs, opts.threshold_px);
|
|
600
|
+
if (Number.isFinite(r.distance)) {
|
|
601
|
+
dx = r.distance;
|
|
602
|
+
hit_x_offset = agent_x + r.distance;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (agent_y !== null && this.anchor_ys) {
|
|
606
|
+
const r = _grida_cmath.default.ext.snap.snap1D([agent_y], this.anchor_ys, opts.threshold_px);
|
|
607
|
+
if (Number.isFinite(r.distance)) {
|
|
608
|
+
dy = r.distance;
|
|
609
|
+
hit_y_offset = agent_y + r.distance;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
let guide = void 0;
|
|
613
|
+
if (hit_x_offset !== null || hit_y_offset !== null) {
|
|
614
|
+
const rules = [];
|
|
615
|
+
if (hit_x_offset !== null) rules.push(["x", hit_x_offset]);
|
|
616
|
+
if (hit_y_offset !== null) rules.push(["y", hit_y_offset]);
|
|
617
|
+
guide = {
|
|
618
|
+
lines: [],
|
|
619
|
+
points: [],
|
|
620
|
+
rules
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
this._last_guide = guide;
|
|
624
|
+
return {
|
|
625
|
+
dx,
|
|
626
|
+
dy,
|
|
627
|
+
guide
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
/** Release frozen refs. After dispose, `snap()` returns identity and
|
|
631
|
+
* `last_guide` is undefined. */
|
|
632
|
+
dispose() {
|
|
633
|
+
this.agents = null;
|
|
634
|
+
this.anchors = null;
|
|
635
|
+
this.anchor_xs = null;
|
|
636
|
+
this.anchor_ys = null;
|
|
637
|
+
this.baseline_union = null;
|
|
638
|
+
this._last_guide = void 0;
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
//#endregion
|
|
642
|
+
//#region src/core/snap/neighborhood.ts
|
|
643
|
+
/** Tags whose subtree is never rendered (resource containers). Children
|
|
644
|
+
* inside these are referenced via `<use>` or `clip-path`, not drawn
|
|
645
|
+
* directly, so they must not surface as snap targets. */
|
|
646
|
+
const NON_RENDERED_CONTAINER_TAGS = new Set([
|
|
647
|
+
"defs",
|
|
648
|
+
"symbol",
|
|
649
|
+
"clipPath",
|
|
650
|
+
"mask",
|
|
651
|
+
"pattern",
|
|
652
|
+
"marker",
|
|
653
|
+
"filter",
|
|
654
|
+
"linearGradient",
|
|
655
|
+
"radialGradient"
|
|
656
|
+
]);
|
|
657
|
+
/** Self-only render check. Does NOT walk ancestors — callers that descend
|
|
658
|
+
* from a known-rendered root already inherit ancestor-rendered as an
|
|
659
|
+
* invariant. Returns `false` for `display="none"`, `visibility="hidden"`,
|
|
660
|
+
* and for resource-container tags whose contents are never drawn. */
|
|
661
|
+
function is_self_rendered(doc, id) {
|
|
662
|
+
const tag = doc.tag_of(id);
|
|
663
|
+
if (NON_RENDERED_CONTAINER_TAGS.has(tag)) return false;
|
|
664
|
+
if (doc.get_attr(id, "display") === "none") return false;
|
|
665
|
+
if (doc.get_attr(id, "visibility") === "hidden") return false;
|
|
666
|
+
return true;
|
|
667
|
+
}
|
|
668
|
+
/** Recursive descent into a `<g>`. Pushes every rendered structural
|
|
669
|
+
* descendant — including nested `<g>` ids themselves — into `out`.
|
|
670
|
+
* Caller is responsible for adding the root id. */
|
|
671
|
+
function collect_rendered_subtree(doc, parent, out) {
|
|
672
|
+
for (const child of doc.element_children_of(parent)) {
|
|
673
|
+
if (!is_self_rendered(doc, child)) continue;
|
|
674
|
+
if (!require_insertions.STRUCTURAL_GRAPHICS_SET.has(doc.tag_of(child))) continue;
|
|
675
|
+
out.add(child);
|
|
676
|
+
if (doc.tag_of(child) === "g") collect_rendered_subtree(doc, child, out);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Expand a single id into its snap-candidate id set.
|
|
681
|
+
*
|
|
682
|
+
* - Non-`<g>`: returns `[id]`.
|
|
683
|
+
* - `<g>`: returns `[id, ...rendered structural descendants]` — the
|
|
684
|
+
* group's own bbox stays in the set (preserving group-to-group
|
|
685
|
+
* alignment) AND every rendered leaf inside it becomes its own
|
|
686
|
+
* candidate. Nested `<g>` ids are included as bboxes; their
|
|
687
|
+
* children flatten in too.
|
|
688
|
+
*
|
|
689
|
+
* Editor-agnostic w.r.t. rect resolution — this returns ids only.
|
|
690
|
+
* Callers feed the ids to the geometry provider to get rects.
|
|
691
|
+
*/
|
|
692
|
+
function snap_descent(doc, id) {
|
|
693
|
+
if (doc.tag_of(id) !== "g") return [id];
|
|
694
|
+
if (!is_self_rendered(doc, id)) return [];
|
|
695
|
+
const out = new Set([id]);
|
|
696
|
+
collect_rendered_subtree(doc, id, out);
|
|
697
|
+
return [...out];
|
|
698
|
+
}
|
|
699
|
+
function compute_neighborhood(doc, dragged) {
|
|
700
|
+
if (dragged.length === 0) return [];
|
|
701
|
+
const excluded = /* @__PURE__ */ new Set();
|
|
702
|
+
for (const id of dragged) {
|
|
703
|
+
excluded.add(id);
|
|
704
|
+
if (doc.tag_of(id) === "g") collect_rendered_subtree(doc, id, excluded);
|
|
705
|
+
}
|
|
706
|
+
const out = /* @__PURE__ */ new Set();
|
|
707
|
+
for (const id of dragged) {
|
|
708
|
+
const parent = doc.parent_of(id);
|
|
709
|
+
if (parent === null) continue;
|
|
710
|
+
if (!excluded.has(parent) && require_insertions.STRUCTURAL_GRAPHICS_SET.has(doc.tag_of(parent)) && is_self_rendered(doc, parent)) out.add(parent);
|
|
711
|
+
for (const sib of doc.element_children_of(parent)) {
|
|
712
|
+
if (excluded.has(sib)) continue;
|
|
713
|
+
if (!require_insertions.STRUCTURAL_GRAPHICS_SET.has(doc.tag_of(sib))) continue;
|
|
714
|
+
if (!is_self_rendered(doc, sib)) continue;
|
|
715
|
+
for (const inner of snap_descent(doc, sib)) {
|
|
716
|
+
if (excluded.has(inner)) continue;
|
|
717
|
+
out.add(inner);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return [...out];
|
|
722
|
+
}
|
|
723
|
+
//#endregion
|
|
724
|
+
//#region src/core/snap/options.ts
|
|
725
|
+
const DEFAULT_SNAP_OPTIONS = {
|
|
726
|
+
enabled: true,
|
|
727
|
+
threshold_px: 6
|
|
728
|
+
};
|
|
729
|
+
//#endregion
|
|
730
|
+
//#region src/core/resize-pipeline/pipeline.ts
|
|
731
|
+
/** The funnel. Threads `plan` through `stages` in order; aggregates guide
|
|
732
|
+
* emissions. Pure: same inputs → same outputs. */
|
|
733
|
+
function run_resize_pipeline(init, stages, ctx) {
|
|
734
|
+
let plan = init;
|
|
735
|
+
const guides = [];
|
|
736
|
+
for (const stage of stages) {
|
|
737
|
+
const out = stage.run(plan, ctx);
|
|
738
|
+
plan = out.plan;
|
|
739
|
+
if (out.emit?.guide) guides.push(out.emit.guide);
|
|
740
|
+
}
|
|
741
|
+
return {
|
|
742
|
+
plan,
|
|
743
|
+
guides
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
//#endregion
|
|
747
|
+
//#region src/core/resize-capability.ts
|
|
748
|
+
function direction_mask(dir) {
|
|
749
|
+
const has_n = dir === "n" || dir === "ne" || dir === "nw";
|
|
750
|
+
const has_s = dir === "s" || dir === "se" || dir === "sw";
|
|
751
|
+
const has_e = dir === "e" || dir === "ne" || dir === "se";
|
|
752
|
+
const has_w = dir === "w" || dir === "nw" || dir === "sw";
|
|
753
|
+
return {
|
|
754
|
+
affects_x: has_e || has_w,
|
|
755
|
+
affects_y: has_n || has_s,
|
|
756
|
+
x_edge: has_e ? "right" : has_w ? "left" : null,
|
|
757
|
+
y_edge: has_n ? "top" : has_s ? "bottom" : null
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
/** Is this direction one of the four corners (vs. an edge handle)? */
|
|
761
|
+
function is_corner_direction(dir) {
|
|
762
|
+
return dir === "nw" || dir === "ne" || dir === "se" || dir === "sw";
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Apply per-element resize constraints to a gesture's proposed `(sx, sy)`.
|
|
766
|
+
*
|
|
767
|
+
* The constraint mirrors `apply_resize` exactly so that the *effective
|
|
768
|
+
* rect* the caller computes from `(sx, sy)` matches what attribute writes
|
|
769
|
+
* actually produce. This is the keystone of resize snap: snapping on the
|
|
770
|
+
* gesture-proposed rect would lie about where the geometry lands when an
|
|
771
|
+
* element-type constraint kicks in.
|
|
772
|
+
*
|
|
773
|
+
* Constraints:
|
|
774
|
+
* - `rect` / `image` / `use` / `ellipse` / `line` / `polyline` /
|
|
775
|
+
* `polygon` / `path`: free per-axis. Identity.
|
|
776
|
+
* - `circle`: uniform. `s = min(sx, sy)`.
|
|
777
|
+
* - `text`: uniform on corner drags; no-op on edge drags. Mirrors the
|
|
778
|
+
* `isCorner = sx !== 1 && sy !== 1` check in `apply_resize`.
|
|
779
|
+
* - `unsupported`: no-op.
|
|
780
|
+
*/
|
|
781
|
+
function resize_constraint(baseline, dir, sx_gesture, sy_gesture) {
|
|
782
|
+
switch (baseline.attrs.kind) {
|
|
783
|
+
case "rect":
|
|
784
|
+
case "image":
|
|
785
|
+
case "use":
|
|
786
|
+
case "ellipse":
|
|
787
|
+
case "line":
|
|
788
|
+
case "polyline":
|
|
789
|
+
case "polygon":
|
|
790
|
+
case "path": return {
|
|
791
|
+
sx: sx_gesture,
|
|
792
|
+
sy: sy_gesture,
|
|
793
|
+
no_op: false,
|
|
794
|
+
uniform: false
|
|
795
|
+
};
|
|
796
|
+
case "circle": {
|
|
797
|
+
const s = Math.min(sx_gesture, sy_gesture);
|
|
798
|
+
return {
|
|
799
|
+
sx: s,
|
|
800
|
+
sy: s,
|
|
801
|
+
no_op: false,
|
|
802
|
+
uniform: true
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
case "text": {
|
|
806
|
+
if (!is_corner_direction(dir)) return {
|
|
807
|
+
sx: 1,
|
|
808
|
+
sy: 1,
|
|
809
|
+
no_op: true,
|
|
810
|
+
uniform: true
|
|
811
|
+
};
|
|
812
|
+
const s = Math.min(sx_gesture, sy_gesture);
|
|
813
|
+
return {
|
|
814
|
+
sx: s,
|
|
815
|
+
sy: s,
|
|
816
|
+
no_op: false,
|
|
817
|
+
uniform: true
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
case "unsupported": return {
|
|
821
|
+
sx: 1,
|
|
822
|
+
sy: 1,
|
|
823
|
+
no_op: true,
|
|
824
|
+
uniform: false
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
/** Position of a bbox corner / edge midpoint by direction. */
|
|
829
|
+
function corner_of_rect(r, dir) {
|
|
830
|
+
switch (dir) {
|
|
831
|
+
case "nw": return {
|
|
832
|
+
x: r.x,
|
|
833
|
+
y: r.y
|
|
834
|
+
};
|
|
835
|
+
case "n": return {
|
|
836
|
+
x: r.x + r.width / 2,
|
|
837
|
+
y: r.y
|
|
838
|
+
};
|
|
839
|
+
case "ne": return {
|
|
840
|
+
x: r.x + r.width,
|
|
841
|
+
y: r.y
|
|
842
|
+
};
|
|
843
|
+
case "e": return {
|
|
844
|
+
x: r.x + r.width,
|
|
845
|
+
y: r.y + r.height / 2
|
|
846
|
+
};
|
|
847
|
+
case "se": return {
|
|
848
|
+
x: r.x + r.width,
|
|
849
|
+
y: r.y + r.height
|
|
850
|
+
};
|
|
851
|
+
case "s": return {
|
|
852
|
+
x: r.x + r.width / 2,
|
|
853
|
+
y: r.y + r.height
|
|
854
|
+
};
|
|
855
|
+
case "sw": return {
|
|
856
|
+
x: r.x,
|
|
857
|
+
y: r.y + r.height
|
|
858
|
+
};
|
|
859
|
+
case "w": return {
|
|
860
|
+
x: r.x,
|
|
861
|
+
y: r.y + r.height / 2
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
/** The fixed-origin corner for a drag (opposite the moving corner). */
|
|
866
|
+
function origin_of_direction(r, dir) {
|
|
867
|
+
switch (dir) {
|
|
868
|
+
case "nw": return {
|
|
869
|
+
x: r.x + r.width,
|
|
870
|
+
y: r.y + r.height
|
|
871
|
+
};
|
|
872
|
+
case "n": return {
|
|
873
|
+
x: r.x + r.width / 2,
|
|
874
|
+
y: r.y + r.height
|
|
875
|
+
};
|
|
876
|
+
case "ne": return {
|
|
877
|
+
x: r.x,
|
|
878
|
+
y: r.y + r.height
|
|
879
|
+
};
|
|
880
|
+
case "e": return {
|
|
881
|
+
x: r.x,
|
|
882
|
+
y: r.y + r.height / 2
|
|
883
|
+
};
|
|
884
|
+
case "se": return {
|
|
885
|
+
x: r.x,
|
|
886
|
+
y: r.y
|
|
887
|
+
};
|
|
888
|
+
case "s": return {
|
|
889
|
+
x: r.x + r.width / 2,
|
|
890
|
+
y: r.y
|
|
891
|
+
};
|
|
892
|
+
case "sw": return {
|
|
893
|
+
x: r.x + r.width,
|
|
894
|
+
y: r.y
|
|
895
|
+
};
|
|
896
|
+
case "w": return {
|
|
897
|
+
x: r.x + r.width,
|
|
898
|
+
y: r.y + r.height / 2
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Compute the effective rect that `apply_resize` would write for the
|
|
904
|
+
* given gesture. This is what snap operates on.
|
|
905
|
+
*
|
|
906
|
+
* The rect is computed by scaling `baseline.bbox` around `origin` by the
|
|
907
|
+
* constrained `(sx, sy)`. For free elements this matches the gesture
|
|
908
|
+
* exactly; for circle / text-on-corner it collapses to a uniform-scale
|
|
909
|
+
* rect; for text-on-edge it returns the baseline rect unchanged.
|
|
910
|
+
*/
|
|
911
|
+
function effective_resize(baseline, dir, sx_gesture, sy_gesture) {
|
|
912
|
+
const bbox = baseline.bbox;
|
|
913
|
+
const origin = origin_of_direction(bbox, dir);
|
|
914
|
+
const c = resize_constraint(baseline, dir, sx_gesture, sy_gesture);
|
|
915
|
+
const mask = direction_mask(dir);
|
|
916
|
+
const rect = {
|
|
917
|
+
x: origin.x + (bbox.x - origin.x) * c.sx,
|
|
918
|
+
y: origin.y + (bbox.y - origin.y) * c.sy,
|
|
919
|
+
width: bbox.width * c.sx,
|
|
920
|
+
height: bbox.height * c.sy
|
|
921
|
+
};
|
|
922
|
+
const moving_corner = corner_of_rect(rect, dir);
|
|
923
|
+
return {
|
|
924
|
+
rect,
|
|
925
|
+
sx: c.sx,
|
|
926
|
+
sy: c.sy,
|
|
927
|
+
moving_corner,
|
|
928
|
+
origin,
|
|
929
|
+
no_op: c.no_op,
|
|
930
|
+
uniform: c.uniform,
|
|
931
|
+
mask
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
//#endregion
|
|
935
|
+
//#region src/core/resize-pipeline/stages.ts
|
|
936
|
+
function pipeline_baseline(plan) {
|
|
937
|
+
return plan.baseline;
|
|
938
|
+
}
|
|
939
|
+
/** Collapse `(dx, dy)` to a uniform scale when Shift-drag is active.
|
|
940
|
+
* Element-driven uniform (circle / text-on-corner) is enforced inside
|
|
941
|
+
* `resize_constraint`, not here — this stage is *only* the user-visible
|
|
942
|
+
* modifier. No-op on edge handles (uniform across one axis isn't a
|
|
943
|
+
* meaningful constraint). */
|
|
944
|
+
const stage_aspect_lock = {
|
|
945
|
+
name: "aspect_lock",
|
|
946
|
+
run(plan, ctx) {
|
|
947
|
+
if (ctx.modifiers.aspect_lock !== "uniform") return { plan };
|
|
948
|
+
if (!is_corner_direction(plan.direction)) return { plan };
|
|
949
|
+
const pbase = pipeline_baseline(plan);
|
|
950
|
+
const locked = require_insertions.compute_resize_factors(pbase, plan.direction, plan.dx, plan.dy, true);
|
|
951
|
+
const bbox = pbase.bbox;
|
|
952
|
+
const Hx_base = (() => {
|
|
953
|
+
switch (plan.direction) {
|
|
954
|
+
case "nw":
|
|
955
|
+
case "w":
|
|
956
|
+
case "sw": return bbox.x;
|
|
957
|
+
case "ne":
|
|
958
|
+
case "e":
|
|
959
|
+
case "se": return bbox.x + bbox.width;
|
|
960
|
+
case "n":
|
|
961
|
+
case "s": return bbox.x + bbox.width / 2;
|
|
962
|
+
}
|
|
963
|
+
})();
|
|
964
|
+
const Hy_base = (() => {
|
|
965
|
+
switch (plan.direction) {
|
|
966
|
+
case "nw":
|
|
967
|
+
case "n":
|
|
968
|
+
case "ne": return bbox.y;
|
|
969
|
+
case "sw":
|
|
970
|
+
case "s":
|
|
971
|
+
case "se": return bbox.y + bbox.height;
|
|
972
|
+
case "e":
|
|
973
|
+
case "w": return bbox.y + bbox.height / 2;
|
|
974
|
+
}
|
|
975
|
+
})();
|
|
976
|
+
const new_Hx = locked.origin.x + (Hx_base - locked.origin.x) * locked.sx;
|
|
977
|
+
const new_Hy = locked.origin.y + (Hy_base - locked.origin.y) * locked.sy;
|
|
978
|
+
return { plan: {
|
|
979
|
+
...plan,
|
|
980
|
+
dx: new_Hx - Hx_base,
|
|
981
|
+
dy: new_Hy - Hy_base
|
|
982
|
+
} };
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
/** Consults `ctx.snap_session` for moving-edge alignment correction.
|
|
986
|
+
* Identity on `force_disable_snap`, missing session, or
|
|
987
|
+
* `snap_enabled === false`. */
|
|
988
|
+
const stage_snap = {
|
|
989
|
+
name: "snap",
|
|
990
|
+
run(plan, ctx) {
|
|
991
|
+
if (ctx.modifiers.force_disable_snap) return { plan };
|
|
992
|
+
if (!ctx.snap_session) return { plan };
|
|
993
|
+
if (!ctx.options.snap_enabled) return { plan };
|
|
994
|
+
const pbase = pipeline_baseline(plan);
|
|
995
|
+
const f = require_insertions.compute_resize_factors(pbase, plan.direction, plan.dx, plan.dy, false);
|
|
996
|
+
const eff = effective_resize(pbase, plan.direction, f.sx, f.sy);
|
|
997
|
+
if (eff.no_op) return { plan };
|
|
998
|
+
const r = ctx.snap_session.snap_resize(eff.rect, {
|
|
999
|
+
x: eff.mask.x_edge,
|
|
1000
|
+
y: eff.mask.y_edge
|
|
1001
|
+
}, {
|
|
1002
|
+
enabled: true,
|
|
1003
|
+
threshold_px: ctx.options.snap_threshold_px
|
|
1004
|
+
});
|
|
1005
|
+
if (r.dx === 0 && r.dy === 0) return {
|
|
1006
|
+
plan,
|
|
1007
|
+
emit: r.guide ? { guide: r.guide } : void 0
|
|
1008
|
+
};
|
|
1009
|
+
if (eff.uniform) {
|
|
1010
|
+
const bbox = pbase.bbox;
|
|
1011
|
+
const new_Hx = eff.moving_corner.x + r.dx;
|
|
1012
|
+
const new_Hy = eff.moving_corner.y + r.dy;
|
|
1013
|
+
const sx_from_x = eff.mask.x_edge !== null && r.dx !== 0 && bbox.width !== 0 ? (new_Hx - eff.origin.x) / (eff.moving_corner.x - eff.origin.x) * eff.sx : null;
|
|
1014
|
+
const sy_from_y = eff.mask.y_edge !== null && r.dy !== 0 && bbox.height !== 0 ? (new_Hy - eff.origin.y) / (eff.moving_corner.y - eff.origin.y) * eff.sy : null;
|
|
1015
|
+
let s = eff.sx;
|
|
1016
|
+
if (sx_from_x !== null && sy_from_y !== null) s = Math.min(sx_from_x, sy_from_y);
|
|
1017
|
+
else if (sx_from_x !== null) s = sx_from_x;
|
|
1018
|
+
else if (sy_from_y !== null) s = sy_from_y;
|
|
1019
|
+
const Hx_base = corner_x_of(bbox, plan.direction);
|
|
1020
|
+
const Hy_base = corner_y_of(bbox, plan.direction);
|
|
1021
|
+
const target_Hx = eff.origin.x + (Hx_base - eff.origin.x) * s;
|
|
1022
|
+
const target_Hy = eff.origin.y + (Hy_base - eff.origin.y) * s;
|
|
1023
|
+
return {
|
|
1024
|
+
plan: {
|
|
1025
|
+
...plan,
|
|
1026
|
+
dx: eff.mask.affects_x ? target_Hx - Hx_base : 0,
|
|
1027
|
+
dy: eff.mask.affects_y ? target_Hy - Hy_base : 0
|
|
1028
|
+
},
|
|
1029
|
+
emit: r.guide ? { guide: r.guide } : void 0
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
return {
|
|
1033
|
+
plan: {
|
|
1034
|
+
...plan,
|
|
1035
|
+
dx: eff.mask.affects_x ? plan.dx + r.dx : plan.dx,
|
|
1036
|
+
dy: eff.mask.affects_y ? plan.dy + r.dy : plan.dy
|
|
1037
|
+
},
|
|
1038
|
+
emit: r.guide ? { guide: r.guide } : void 0
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
/** Quantize the moving corner's *post-resize* position to integer
|
|
1043
|
+
* multiples of `pixel_grid_quantum` in own-frame space. Identity when
|
|
1044
|
+
* quantum is `null` / `<= 0` or the gesture is a no-op. */
|
|
1045
|
+
const stage_pixel_grid = {
|
|
1046
|
+
name: "pixel_grid",
|
|
1047
|
+
run(plan, ctx) {
|
|
1048
|
+
const q = ctx.options.pixel_grid_quantum;
|
|
1049
|
+
if (q === null || q <= 0) return { plan };
|
|
1050
|
+
const pbase = pipeline_baseline(plan);
|
|
1051
|
+
const f = require_insertions.compute_resize_factors(pbase, plan.direction, plan.dx, plan.dy, false);
|
|
1052
|
+
const eff = effective_resize(pbase, plan.direction, f.sx, f.sy);
|
|
1053
|
+
if (eff.no_op) return { plan };
|
|
1054
|
+
const target_Hx = eff.mask.affects_x ? Math.round(eff.moving_corner.x / q) * q : eff.moving_corner.x;
|
|
1055
|
+
const target_Hy = eff.mask.affects_y ? Math.round(eff.moving_corner.y / q) * q : eff.moving_corner.y;
|
|
1056
|
+
const bbox = pbase.bbox;
|
|
1057
|
+
const Hx_base = corner_x_of(bbox, plan.direction);
|
|
1058
|
+
const Hy_base = corner_y_of(bbox, plan.direction);
|
|
1059
|
+
return { plan: {
|
|
1060
|
+
...plan,
|
|
1061
|
+
dx: eff.mask.affects_x ? target_Hx - Hx_base : 0,
|
|
1062
|
+
dy: eff.mask.affects_y ? target_Hy - Hy_base : 0
|
|
1063
|
+
} };
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
function corner_x_of(r, dir) {
|
|
1067
|
+
switch (dir) {
|
|
1068
|
+
case "nw":
|
|
1069
|
+
case "w":
|
|
1070
|
+
case "sw": return r.x;
|
|
1071
|
+
case "ne":
|
|
1072
|
+
case "e":
|
|
1073
|
+
case "se": return r.x + r.width;
|
|
1074
|
+
case "n":
|
|
1075
|
+
case "s": return r.x + r.width / 2;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
function corner_y_of(r, dir) {
|
|
1079
|
+
switch (dir) {
|
|
1080
|
+
case "nw":
|
|
1081
|
+
case "n":
|
|
1082
|
+
case "ne": return r.y;
|
|
1083
|
+
case "sw":
|
|
1084
|
+
case "s":
|
|
1085
|
+
case "se": return r.y + r.height;
|
|
1086
|
+
case "e":
|
|
1087
|
+
case "w": return r.y + r.height / 2;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
/** Default stage list for HUD-driven resize gestures (drag).
|
|
1091
|
+
* No NUDGE / RPC analogs at v1 (no `commands.resize` per README). */
|
|
1092
|
+
const STAGES_DEFAULT = Object.freeze([
|
|
1093
|
+
stage_aspect_lock,
|
|
1094
|
+
stage_snap,
|
|
1095
|
+
stage_pixel_grid
|
|
1096
|
+
]);
|
|
1097
|
+
//#endregion
|
|
1098
|
+
//#region src/core/resize-pipeline/apply.ts
|
|
1099
|
+
function applyResizePlan(doc, plan, phase = "commit") {
|
|
1100
|
+
const f = require_insertions.compute_resize_factors(plan.baseline, plan.direction, plan.dx, plan.dy, false);
|
|
1101
|
+
const members = plan.members ?? [{
|
|
1102
|
+
id: plan.id,
|
|
1103
|
+
baseline: plan.baseline
|
|
1104
|
+
}];
|
|
1105
|
+
for (const m of members) require_insertions.apply_resize(doc, m.id, m.baseline, f.sx, f.sy, f.origin, phase);
|
|
1106
|
+
}
|
|
1107
|
+
function revertResizePlan(doc, plan) {
|
|
1108
|
+
const f = require_insertions.compute_resize_factors(plan.baseline, plan.direction, 0, 0, false);
|
|
1109
|
+
const members = plan.members ?? [{
|
|
1110
|
+
id: plan.id,
|
|
1111
|
+
baseline: plan.baseline
|
|
1112
|
+
}];
|
|
1113
|
+
for (const m of members) require_insertions.apply_resize(doc, m.id, m.baseline, 1, 1, f.origin, "preview");
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Synthesize a "group" baseline over an arbitrary union rect. The attrs
|
|
1117
|
+
* carrier is `rect`-kind so the pipeline math (snap / pixel-grid)
|
|
1118
|
+
* treats the group as free per-axis — per-member constraints (circle
|
|
1119
|
+
* uniform, text edge no-op) kick in at apply time against each
|
|
1120
|
+
* member's own captured baseline.
|
|
1121
|
+
*
|
|
1122
|
+
* For single-member groups callers should pass the member's own
|
|
1123
|
+
* baseline directly so the per-element snap correction (`eff.uniform`
|
|
1124
|
+
* branch) fires correctly.
|
|
1125
|
+
*/
|
|
1126
|
+
function synthesize_group_baseline(union) {
|
|
1127
|
+
return {
|
|
1128
|
+
bbox: {
|
|
1129
|
+
x: union.x,
|
|
1130
|
+
y: union.y,
|
|
1131
|
+
width: union.width,
|
|
1132
|
+
height: union.height
|
|
1133
|
+
},
|
|
1134
|
+
attrs: {
|
|
1135
|
+
kind: "rect",
|
|
1136
|
+
x: union.x,
|
|
1137
|
+
y: union.y,
|
|
1138
|
+
w: union.width,
|
|
1139
|
+
h: union.height
|
|
1140
|
+
}
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
//#endregion
|
|
1144
|
+
//#region src/core/resize-pipeline/orchestrator.ts
|
|
1145
|
+
const PROVIDER_ID = "svg-editor";
|
|
1146
|
+
/** West/north-anchor flips invert the corresponding world delta so a
|
|
1147
|
+
* positive value always grows the moving edge outward — the convention
|
|
1148
|
+
* `compute_resize_factors` consumes. */
|
|
1149
|
+
function sign_adjust(dir, dx_world, dy_world) {
|
|
1150
|
+
return {
|
|
1151
|
+
dx: dir === "w" || dir === "nw" || dir === "sw" ? -dx_world : dx_world,
|
|
1152
|
+
dy: dir === "n" || dir === "ne" || dir === "nw" ? -dy_world : dy_world
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
/** Stable, order-independent key for an id set — used by `is_active_for`
|
|
1156
|
+
* to decide whether the current session targets the same group. */
|
|
1157
|
+
function ids_key(ids) {
|
|
1158
|
+
return [...ids].sort().join("\0");
|
|
1159
|
+
}
|
|
1160
|
+
var ResizeOrchestrator = class {
|
|
1161
|
+
constructor(deps) {
|
|
1162
|
+
this.deps = deps;
|
|
1163
|
+
this.active = null;
|
|
1164
|
+
this._last_guides = [];
|
|
1165
|
+
}
|
|
1166
|
+
/** Guides emitted by the most recent pipeline run. Cleared on
|
|
1167
|
+
* cancel/dispose. */
|
|
1168
|
+
get last_guides() {
|
|
1169
|
+
return this._last_guides;
|
|
1170
|
+
}
|
|
1171
|
+
has_active_session() {
|
|
1172
|
+
return this.active !== null;
|
|
1173
|
+
}
|
|
1174
|
+
/** Is the gesture currently targeting `ids` with `direction`? Used by
|
|
1175
|
+
* the HUD dispatch to decide whether to reset the session on a new
|
|
1176
|
+
* handle / target. Order-independent. */
|
|
1177
|
+
is_active_for(ids, direction) {
|
|
1178
|
+
return this.active !== null && this.active.direction === direction && this.active.ids_key === ids_key(ids);
|
|
1179
|
+
}
|
|
1180
|
+
/** Per-frame drive. Opens a session lazily on the first call. The
|
|
1181
|
+
* HUD passes its gesture-target rect dimensions in **world space**;
|
|
1182
|
+
* the orchestrator derives the signed world-frame delta against its
|
|
1183
|
+
* captured `baseline.bbox`. The DOM adapter is responsible for the
|
|
1184
|
+
* CSS-px → world conversion at the intent boundary. */
|
|
1185
|
+
drive(input, modifiers, opts) {
|
|
1186
|
+
if (input.ids.length === 0) return null;
|
|
1187
|
+
const doc = this.deps.get_doc();
|
|
1188
|
+
for (const id of input.ids) if (!require_insertions.is_resizable_node(doc, id)) return null;
|
|
1189
|
+
const key = ids_key(input.ids);
|
|
1190
|
+
if (this.active && (this.active.ids_key !== key || this.active.direction !== input.direction)) {
|
|
1191
|
+
this.active.preview.discard();
|
|
1192
|
+
this.dispose_session();
|
|
1193
|
+
}
|
|
1194
|
+
if (this.active === null) this.active = this.open(input.ids, input.direction, opts.snap, opts.label ?? "resize");
|
|
1195
|
+
const session = this.active;
|
|
1196
|
+
const bbox = session.baseline.bbox;
|
|
1197
|
+
const dx_world = input.target_width - bbox.width;
|
|
1198
|
+
const dy_world = input.target_height - bbox.height;
|
|
1199
|
+
const d = sign_adjust(input.direction, dx_world, dy_world);
|
|
1200
|
+
const stages = opts.stages ?? STAGES_DEFAULT;
|
|
1201
|
+
const result = this.run_pass(session, d.dx, d.dy, modifiers, stages);
|
|
1202
|
+
session.last_dx = d.dx;
|
|
1203
|
+
session.last_dy = d.dy;
|
|
1204
|
+
session.last_stages = stages;
|
|
1205
|
+
this.write_preview(session, result.plan, opts.phase);
|
|
1206
|
+
if (opts.phase === "commit") {
|
|
1207
|
+
session.preview.commit();
|
|
1208
|
+
this.dispose_session();
|
|
1209
|
+
}
|
|
1210
|
+
return result;
|
|
1211
|
+
}
|
|
1212
|
+
/** Re-run the current preview frame with new modifiers. */
|
|
1213
|
+
redrive_modifiers(modifiers) {
|
|
1214
|
+
if (!this.active) return null;
|
|
1215
|
+
const session = this.active;
|
|
1216
|
+
const result = this.run_pass(session, session.last_dx, session.last_dy, modifiers, session.last_stages);
|
|
1217
|
+
this.write_preview(session, result.plan, "preview");
|
|
1218
|
+
return result;
|
|
1219
|
+
}
|
|
1220
|
+
cancel() {
|
|
1221
|
+
if (!this.active) return;
|
|
1222
|
+
this.active.preview.discard();
|
|
1223
|
+
this.dispose_session();
|
|
1224
|
+
}
|
|
1225
|
+
run_pass(session, dx, dy, modifiers, stages) {
|
|
1226
|
+
const result = run_resize_pipeline({
|
|
1227
|
+
id: session.primary_id,
|
|
1228
|
+
baseline: session.baseline,
|
|
1229
|
+
members: session.members,
|
|
1230
|
+
direction: session.direction,
|
|
1231
|
+
dx,
|
|
1232
|
+
dy
|
|
1233
|
+
}, stages, {
|
|
1234
|
+
input: {
|
|
1235
|
+
id: session.primary_id,
|
|
1236
|
+
direction: session.direction,
|
|
1237
|
+
dx,
|
|
1238
|
+
dy
|
|
1239
|
+
},
|
|
1240
|
+
modifiers,
|
|
1241
|
+
options: this.deps.options(),
|
|
1242
|
+
snap_session: session.snap
|
|
1243
|
+
});
|
|
1244
|
+
this._last_guides = result.guides;
|
|
1245
|
+
return result;
|
|
1246
|
+
}
|
|
1247
|
+
open(ids, direction, snap, label) {
|
|
1248
|
+
const doc = this.deps.get_doc();
|
|
1249
|
+
const members = ids.map((id) => ({
|
|
1250
|
+
id,
|
|
1251
|
+
baseline: require_insertions.capture_resize_baseline(doc, id, this.deps.bbox_world(id))
|
|
1252
|
+
}));
|
|
1253
|
+
const baseline = members.length === 1 ? members[0].baseline : synthesize_group_baseline(_grida_cmath.default.rect.union(members.map((m) => m.baseline.bbox)));
|
|
1254
|
+
return {
|
|
1255
|
+
ids_key: ids_key(ids),
|
|
1256
|
+
primary_id: members[0].id,
|
|
1257
|
+
direction,
|
|
1258
|
+
members,
|
|
1259
|
+
baseline,
|
|
1260
|
+
snap: snap ? this.deps.open_snap(ids) : null,
|
|
1261
|
+
preview: this.deps.open_preview(label),
|
|
1262
|
+
last_dx: 0,
|
|
1263
|
+
last_dy: 0,
|
|
1264
|
+
last_stages: STAGES_DEFAULT
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
write_preview(session, plan, phase) {
|
|
1268
|
+
const doc = this.deps.get_doc();
|
|
1269
|
+
const emit = this.deps.emit;
|
|
1270
|
+
session.preview.set({
|
|
1271
|
+
providerId: PROVIDER_ID,
|
|
1272
|
+
apply: () => {
|
|
1273
|
+
applyResizePlan(doc, plan, phase);
|
|
1274
|
+
emit();
|
|
1275
|
+
},
|
|
1276
|
+
revert: () => {
|
|
1277
|
+
revertResizePlan(doc, plan);
|
|
1278
|
+
emit();
|
|
1279
|
+
}
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
dispose_session() {
|
|
1283
|
+
if (!this.active) return;
|
|
1284
|
+
this.active.snap?.dispose();
|
|
1285
|
+
this.active = null;
|
|
1286
|
+
this._last_guides = [];
|
|
1287
|
+
}
|
|
1288
|
+
};
|
|
1289
|
+
//#endregion
|
|
1290
|
+
//#region src/gestures/gestures.ts
|
|
1291
|
+
/**
|
|
1292
|
+
* Sibling to `Keymap`. Owns a list of installed gesture bindings; each
|
|
1293
|
+
* binding's `install(ctx)` is called eagerly when bound and uninstalled
|
|
1294
|
+
* on `unbind` or surface detach.
|
|
1295
|
+
*/
|
|
1296
|
+
var Gestures = class {
|
|
1297
|
+
constructor(ctx) {
|
|
1298
|
+
this.ctx = ctx;
|
|
1299
|
+
this.entries = [];
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Install a gesture binding. Returns an unbind function.
|
|
1303
|
+
* Re-binding the same `id` does NOT replace — both will be active.
|
|
1304
|
+
* Use `unbind({ id })` first if you want a clean swap.
|
|
1305
|
+
*/
|
|
1306
|
+
bind(binding) {
|
|
1307
|
+
const uninstall = binding.install(this.ctx);
|
|
1308
|
+
const entry = {
|
|
1309
|
+
binding,
|
|
1310
|
+
uninstall
|
|
1311
|
+
};
|
|
1312
|
+
this.entries.push(entry);
|
|
1313
|
+
return () => {
|
|
1314
|
+
const i = this.entries.indexOf(entry);
|
|
1315
|
+
if (i < 0) return;
|
|
1316
|
+
this.entries.splice(i, 1);
|
|
1317
|
+
uninstall();
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
/**
|
|
1321
|
+
* Remove bindings matching the spec. With `{ id }`, all bindings with
|
|
1322
|
+
* that id are uninstalled. With no spec, this is a no-op (use
|
|
1323
|
+
* `dispose()` to nuke everything).
|
|
1324
|
+
*/
|
|
1325
|
+
unbind(spec) {
|
|
1326
|
+
if (spec.id === void 0) return;
|
|
1327
|
+
const remaining = [];
|
|
1328
|
+
for (const entry of this.entries) if (entry.binding.id === spec.id) entry.uninstall();
|
|
1329
|
+
else remaining.push(entry);
|
|
1330
|
+
this.entries = remaining;
|
|
1331
|
+
}
|
|
1332
|
+
/** All currently installed bindings. Order is registration order. */
|
|
1333
|
+
bindings() {
|
|
1334
|
+
return this.entries.map((e) => e.binding);
|
|
1335
|
+
}
|
|
1336
|
+
/** @internal Uninstall every binding. Surface calls on detach. */
|
|
1337
|
+
_dispose() {
|
|
1338
|
+
for (const entry of this.entries) entry.uninstall();
|
|
1339
|
+
this.entries = [];
|
|
1340
|
+
}
|
|
1341
|
+
};
|
|
1342
|
+
//#endregion
|
|
1343
|
+
//#region src/gestures/defaults.ts
|
|
1344
|
+
/** Default margin for `camera.fit` from keyboard shortcuts. */
|
|
1345
|
+
const KEYBOARD_FIT_MARGIN = 64;
|
|
1346
|
+
/** Default zoom step for `Cmd/Ctrl+=` / `Cmd/Ctrl+-`. */
|
|
1347
|
+
const ZOOM_STEP = 1.2;
|
|
1348
|
+
/** Per-wheel-unit zoom sensitivity for Cmd/Ctrl+wheel + pinch. */
|
|
1349
|
+
const WHEEL_ZOOM_SENSITIVITY = .01;
|
|
1350
|
+
/** Min/max zoom clamps. Generous; hosts that want tighter limits can
|
|
1351
|
+
* unbind these defaults and bind their own. */
|
|
1352
|
+
const MIN_ZOOM = .02;
|
|
1353
|
+
const MAX_ZOOM = 256;
|
|
1354
|
+
function clamp_zoom(z) {
|
|
1355
|
+
return _grida_cmath.default.clamp(z, MIN_ZOOM, MAX_ZOOM);
|
|
1356
|
+
}
|
|
1357
|
+
/** wheel-pan-zoom: plain wheel = pan, Cmd/Ctrl+wheel + pinch = zoom-at-cursor. */
|
|
1358
|
+
const WHEEL_PAN_ZOOM = {
|
|
1359
|
+
id: "wheel-pan-zoom",
|
|
1360
|
+
install({ container, camera }) {
|
|
1361
|
+
const on_wheel = (e) => {
|
|
1362
|
+
e.preventDefault();
|
|
1363
|
+
if (e.ctrlKey || e.metaKey) {
|
|
1364
|
+
const factor = 1 - e.deltaY * WHEEL_ZOOM_SENSITIVITY;
|
|
1365
|
+
const eff = clamp_zoom(camera.zoom * factor) / camera.zoom;
|
|
1366
|
+
if (eff === 1) return;
|
|
1367
|
+
const rect = container.getBoundingClientRect();
|
|
1368
|
+
camera.zoom_at(eff, {
|
|
1369
|
+
x: e.clientX - rect.left,
|
|
1370
|
+
y: e.clientY - rect.top
|
|
1371
|
+
});
|
|
1372
|
+
} else camera.pan({
|
|
1373
|
+
x: -e.deltaX,
|
|
1374
|
+
y: -e.deltaY
|
|
1375
|
+
});
|
|
1376
|
+
};
|
|
1377
|
+
container.addEventListener("wheel", on_wheel, { passive: false });
|
|
1378
|
+
return () => container.removeEventListener("wheel", on_wheel);
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
/**
|
|
1382
|
+
* Begin a drag-pan from a pointerdown. Attaches `pointermove` / `pointerup`
|
|
1383
|
+
* listeners scoped to the gesture lifetime, then detaches them on release.
|
|
1384
|
+
* This is the d3-drag pattern: global listeners only exist while a drag is
|
|
1385
|
+
* in flight, not for the surface's whole lifetime.
|
|
1386
|
+
*/
|
|
1387
|
+
function begin_drag_pan(e, container, camera, on_release) {
|
|
1388
|
+
let last_x = e.clientX;
|
|
1389
|
+
let last_y = e.clientY;
|
|
1390
|
+
try {
|
|
1391
|
+
container.setPointerCapture(e.pointerId);
|
|
1392
|
+
} catch {}
|
|
1393
|
+
e.preventDefault();
|
|
1394
|
+
e.stopPropagation();
|
|
1395
|
+
const win = container.ownerDocument.defaultView ?? window;
|
|
1396
|
+
const on_pointermove = (ev) => {
|
|
1397
|
+
const dx = ev.clientX - last_x;
|
|
1398
|
+
const dy = ev.clientY - last_y;
|
|
1399
|
+
last_x = ev.clientX;
|
|
1400
|
+
last_y = ev.clientY;
|
|
1401
|
+
camera.pan({
|
|
1402
|
+
x: dx,
|
|
1403
|
+
y: dy
|
|
1404
|
+
});
|
|
1405
|
+
ev.preventDefault();
|
|
1406
|
+
ev.stopPropagation();
|
|
1407
|
+
};
|
|
1408
|
+
const cleanup = () => {
|
|
1409
|
+
win.removeEventListener("pointermove", on_pointermove, true);
|
|
1410
|
+
win.removeEventListener("pointerup", on_pointerup, true);
|
|
1411
|
+
win.removeEventListener("pointercancel", on_pointerup, true);
|
|
1412
|
+
on_release?.();
|
|
1413
|
+
};
|
|
1414
|
+
const on_pointerup = () => cleanup();
|
|
1415
|
+
win.addEventListener("pointermove", on_pointermove, true);
|
|
1416
|
+
win.addEventListener("pointerup", on_pointerup, true);
|
|
1417
|
+
win.addEventListener("pointercancel", on_pointerup, true);
|
|
1418
|
+
}
|
|
1419
|
+
/** The data-driven default set. Order = install order. */
|
|
1420
|
+
const DEFAULT_GESTURE_BINDINGS = [
|
|
1421
|
+
WHEEL_PAN_ZOOM,
|
|
1422
|
+
{
|
|
1423
|
+
id: "space-drag-pan",
|
|
1424
|
+
install({ container, camera }) {
|
|
1425
|
+
let space_held = false;
|
|
1426
|
+
let prev_cursor = null;
|
|
1427
|
+
const set_cursor = (next) => {
|
|
1428
|
+
if (prev_cursor === null) prev_cursor = container.style.cursor;
|
|
1429
|
+
container.style.cursor = next ?? prev_cursor ?? "";
|
|
1430
|
+
if (next === null) prev_cursor = null;
|
|
1431
|
+
};
|
|
1432
|
+
const on_keydown = (e) => {
|
|
1433
|
+
if (e.code !== "Space" || e.repeat) return;
|
|
1434
|
+
if (require_insertions.is_text_input_focused()) return;
|
|
1435
|
+
space_held = true;
|
|
1436
|
+
set_cursor("grab");
|
|
1437
|
+
e.preventDefault();
|
|
1438
|
+
};
|
|
1439
|
+
const on_keyup = (e) => {
|
|
1440
|
+
if (e.code !== "Space") return;
|
|
1441
|
+
space_held = false;
|
|
1442
|
+
set_cursor(null);
|
|
1443
|
+
};
|
|
1444
|
+
const on_pointerdown = (e) => {
|
|
1445
|
+
if (!space_held || e.button !== 0) return;
|
|
1446
|
+
set_cursor("grabbing");
|
|
1447
|
+
begin_drag_pan(e, container, camera, () => set_cursor(space_held ? "grab" : null));
|
|
1448
|
+
};
|
|
1449
|
+
const on_blur = () => {
|
|
1450
|
+
space_held = false;
|
|
1451
|
+
set_cursor(null);
|
|
1452
|
+
};
|
|
1453
|
+
const win = container.ownerDocument.defaultView ?? window;
|
|
1454
|
+
win.addEventListener("keydown", on_keydown);
|
|
1455
|
+
win.addEventListener("keyup", on_keyup);
|
|
1456
|
+
container.addEventListener("pointerdown", on_pointerdown, true);
|
|
1457
|
+
win.addEventListener("blur", on_blur);
|
|
1458
|
+
return () => {
|
|
1459
|
+
win.removeEventListener("keydown", on_keydown);
|
|
1460
|
+
win.removeEventListener("keyup", on_keyup);
|
|
1461
|
+
container.removeEventListener("pointerdown", on_pointerdown, true);
|
|
1462
|
+
win.removeEventListener("blur", on_blur);
|
|
1463
|
+
if (prev_cursor !== null) container.style.cursor = prev_cursor;
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
},
|
|
1467
|
+
{
|
|
1468
|
+
id: "middle-mouse-pan",
|
|
1469
|
+
install({ container, camera }) {
|
|
1470
|
+
const on_pointerdown = (e) => {
|
|
1471
|
+
if (e.button !== 1) return;
|
|
1472
|
+
begin_drag_pan(e, container, camera);
|
|
1473
|
+
};
|
|
1474
|
+
const on_auxclick = (e) => {
|
|
1475
|
+
if (e.button === 1) e.preventDefault();
|
|
1476
|
+
};
|
|
1477
|
+
container.addEventListener("pointerdown", on_pointerdown, true);
|
|
1478
|
+
container.addEventListener("auxclick", on_auxclick);
|
|
1479
|
+
return () => {
|
|
1480
|
+
container.removeEventListener("pointerdown", on_pointerdown, true);
|
|
1481
|
+
container.removeEventListener("auxclick", on_auxclick);
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
},
|
|
1485
|
+
{
|
|
1486
|
+
id: "keyboard-zoom",
|
|
1487
|
+
install({ container, camera }) {
|
|
1488
|
+
const owner_doc = container.ownerDocument;
|
|
1489
|
+
const on_keydown = (e) => {
|
|
1490
|
+
const active = owner_doc.activeElement;
|
|
1491
|
+
if (active && active !== owner_doc.body && !container.contains(active)) return;
|
|
1492
|
+
if (require_insertions.is_text_input_focused()) return;
|
|
1493
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
1494
|
+
if (e.shiftKey && !mod && (e.code === "Digit0" || e.code === "Numpad0")) {
|
|
1495
|
+
camera.reset();
|
|
1496
|
+
e.preventDefault();
|
|
1497
|
+
} else if (e.shiftKey && !mod && (e.code === "Digit1" || e.code === "Digit9" || e.code === "Numpad1" || e.code === "Numpad9")) {
|
|
1498
|
+
camera.fit("<root>", { margin: KEYBOARD_FIT_MARGIN });
|
|
1499
|
+
e.preventDefault();
|
|
1500
|
+
} else if (e.shiftKey && !mod && (e.code === "Digit2" || e.code === "Numpad2")) {
|
|
1501
|
+
camera.fit("<selection>", { margin: KEYBOARD_FIT_MARGIN });
|
|
1502
|
+
e.preventDefault();
|
|
1503
|
+
} else if (mod && (e.code === "Equal" || e.code === "NumpadAdd")) {
|
|
1504
|
+
camera.set_zoom(clamp_zoom(camera.zoom * ZOOM_STEP));
|
|
1505
|
+
e.preventDefault();
|
|
1506
|
+
} else if (mod && (e.code === "Minus" || e.code === "NumpadSubtract")) {
|
|
1507
|
+
camera.set_zoom(clamp_zoom(camera.zoom / ZOOM_STEP));
|
|
1508
|
+
e.preventDefault();
|
|
1509
|
+
}
|
|
1510
|
+
};
|
|
1511
|
+
owner_doc.addEventListener("keydown", on_keydown);
|
|
1512
|
+
return () => owner_doc.removeEventListener("keydown", on_keydown);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
];
|
|
1516
|
+
/** Install every default binding into the gesture layer. */
|
|
1517
|
+
function applyDefaultGestures(gestures) {
|
|
1518
|
+
for (const b of DEFAULT_GESTURE_BINDINGS) gestures.bind(b);
|
|
1519
|
+
}
|
|
1520
|
+
//#endregion
|
|
1521
|
+
//#region src/text-surface.ts
|
|
1522
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
1523
|
+
const XML_NS = "http://www.w3.org/XML/1998/namespace";
|
|
1524
|
+
var SvgTextSurface = class {
|
|
1525
|
+
constructor(textEl) {
|
|
1526
|
+
this.prevXmlSpace = void 0;
|
|
1527
|
+
this.prevPointerEvents = void 0;
|
|
1528
|
+
this.last_caret_idx = -1;
|
|
1529
|
+
this.last_caret_visible = false;
|
|
1530
|
+
this.last_sel_start = -1;
|
|
1531
|
+
this.last_sel_end = -1;
|
|
1532
|
+
this.textEl = textEl;
|
|
1533
|
+
const ownerDoc = textEl.ownerDocument;
|
|
1534
|
+
let mountAnchor = textEl;
|
|
1535
|
+
while (mountAnchor.parentElement instanceof SVGElement && (mountAnchor.localName === "tspan" || mountAnchor.localName === "textPath")) mountAnchor = mountAnchor.parentElement;
|
|
1536
|
+
const parent = mountAnchor.parentNode;
|
|
1537
|
+
if (!parent) throw new Error("text element has no parent");
|
|
1538
|
+
const computedWhitespace = ownerDoc.defaultView?.getComputedStyle(textEl).whiteSpace;
|
|
1539
|
+
if (!(computedWhitespace === "pre" || computedWhitespace === "pre-wrap" || computedWhitespace === "break-spaces")) {
|
|
1540
|
+
this.prevXmlSpace = textEl.getAttributeNS(XML_NS, "space");
|
|
1541
|
+
textEl.setAttributeNS(XML_NS, "xml:space", "preserve");
|
|
1542
|
+
}
|
|
1543
|
+
this.prevPointerEvents = textEl.getAttribute("pointer-events");
|
|
1544
|
+
textEl.setAttribute("pointer-events", "bounding-box");
|
|
1545
|
+
const selection = ownerDoc.createElementNS(SVG_NS, "rect");
|
|
1546
|
+
selection.setAttribute("fill", "#2563eb");
|
|
1547
|
+
selection.setAttribute("fill-opacity", "0.25");
|
|
1548
|
+
selection.setAttribute("pointer-events", "none");
|
|
1549
|
+
selection.setAttribute("data-svg-text-edit-selection", "");
|
|
1550
|
+
selection.style.display = "none";
|
|
1551
|
+
parent.insertBefore(selection, mountAnchor);
|
|
1552
|
+
this.selectionRect = selection;
|
|
1553
|
+
const caret = ownerDoc.createElementNS(SVG_NS, "rect");
|
|
1554
|
+
caret.setAttribute("fill", "#2563eb");
|
|
1555
|
+
caret.setAttribute("pointer-events", "none");
|
|
1556
|
+
caret.setAttribute("data-svg-text-edit-caret", "");
|
|
1557
|
+
caret.style.display = "none";
|
|
1558
|
+
parent.insertBefore(caret, mountAnchor.nextSibling);
|
|
1559
|
+
this.caretRect = caret;
|
|
1560
|
+
}
|
|
1561
|
+
setText(text) {
|
|
1562
|
+
if (this.textEl.textContent !== text) this.textEl.textContent = text;
|
|
1563
|
+
}
|
|
1564
|
+
setCaret(index, visible) {
|
|
1565
|
+
if (index === this.last_caret_idx && visible === this.last_caret_visible) return;
|
|
1566
|
+
this.last_caret_idx = index;
|
|
1567
|
+
this.last_caret_visible = visible;
|
|
1568
|
+
if (!visible) {
|
|
1569
|
+
this.caretRect.style.display = "none";
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
const m = this.metrics();
|
|
1573
|
+
const x = this.charX(index);
|
|
1574
|
+
this.caretRect.setAttribute("x", String(x - .75));
|
|
1575
|
+
this.caretRect.setAttribute("y", String(m.top));
|
|
1576
|
+
this.caretRect.setAttribute("width", "1.5");
|
|
1577
|
+
this.caretRect.setAttribute("height", String(m.height));
|
|
1578
|
+
this.caretRect.style.display = "block";
|
|
1579
|
+
}
|
|
1580
|
+
setSelection(start, end) {
|
|
1581
|
+
if (start === this.last_sel_start && end === this.last_sel_end) return;
|
|
1582
|
+
this.last_sel_start = start;
|
|
1583
|
+
this.last_sel_end = end;
|
|
1584
|
+
if (start === end) {
|
|
1585
|
+
this.selectionRect.style.display = "none";
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
const m = this.metrics();
|
|
1589
|
+
const x1 = this.charX(start);
|
|
1590
|
+
const x2 = this.charX(end);
|
|
1591
|
+
this.selectionRect.setAttribute("x", String(Math.min(x1, x2)));
|
|
1592
|
+
this.selectionRect.setAttribute("y", String(m.top));
|
|
1593
|
+
this.selectionRect.setAttribute("width", String(Math.abs(x2 - x1)));
|
|
1594
|
+
this.selectionRect.setAttribute("height", String(m.height));
|
|
1595
|
+
this.selectionRect.style.display = "block";
|
|
1596
|
+
}
|
|
1597
|
+
dispose(keepEditMutations = false) {
|
|
1598
|
+
this.caretRect.remove();
|
|
1599
|
+
this.selectionRect.remove();
|
|
1600
|
+
if (this.prevXmlSpace !== void 0 && !keepEditMutations) if (this.prevXmlSpace === null) this.textEl.removeAttributeNS(XML_NS, "space");
|
|
1601
|
+
else this.textEl.setAttributeNS(XML_NS, "xml:space", this.prevXmlSpace);
|
|
1602
|
+
if (this.prevPointerEvents !== void 0) if (this.prevPointerEvents === null) this.textEl.removeAttribute("pointer-events");
|
|
1603
|
+
else this.textEl.setAttribute("pointer-events", this.prevPointerEvents);
|
|
1604
|
+
this.prevXmlSpace = void 0;
|
|
1605
|
+
this.prevPointerEvents = void 0;
|
|
1606
|
+
}
|
|
1607
|
+
positionAtPoint(clientX, clientY) {
|
|
1608
|
+
const ctm = this.textEl.getScreenCTM();
|
|
1609
|
+
const svg = this.textEl.ownerSVGElement;
|
|
1610
|
+
if (!ctm || !svg) return 0;
|
|
1611
|
+
const pt = svg.createSVGPoint();
|
|
1612
|
+
pt.x = clientX;
|
|
1613
|
+
pt.y = clientY;
|
|
1614
|
+
const local = pt.matrixTransform(ctm.inverse());
|
|
1615
|
+
return this.localXToCharIndex(local.x);
|
|
1616
|
+
}
|
|
1617
|
+
/**
|
|
1618
|
+
* Single-line `<text>` element: there's no "previous visual line" to move
|
|
1619
|
+
* to. Cocoa single-line convention: Up/PageUp/line_start → doc start;
|
|
1620
|
+
* Down/PageDown/line_end → doc end.
|
|
1621
|
+
*/
|
|
1622
|
+
positionForNavigation(_index, direction) {
|
|
1623
|
+
const text = this.textEl.textContent ?? "";
|
|
1624
|
+
switch (direction) {
|
|
1625
|
+
case "up":
|
|
1626
|
+
case "page_up":
|
|
1627
|
+
case "line_start": return 0;
|
|
1628
|
+
case "down":
|
|
1629
|
+
case "page_down":
|
|
1630
|
+
case "line_end": return text.length;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
metrics() {
|
|
1634
|
+
try {
|
|
1635
|
+
const b = this.textEl.getBBox();
|
|
1636
|
+
if (b.height > 0) return {
|
|
1637
|
+
top: b.y,
|
|
1638
|
+
height: b.height
|
|
1639
|
+
};
|
|
1640
|
+
} catch {}
|
|
1641
|
+
const fontSize = parseFloat(this.textEl.ownerDocument.defaultView?.getComputedStyle(this.textEl).fontSize ?? "16") || 16;
|
|
1642
|
+
return {
|
|
1643
|
+
top: parseFloat(this.textEl.getAttribute("y") ?? "0") - fontSize * .85,
|
|
1644
|
+
height: fontSize
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
charX(i) {
|
|
1648
|
+
const text = this.textEl.textContent ?? "";
|
|
1649
|
+
const baseX = parseFloat(this.textEl.getAttribute("x") ?? "0");
|
|
1650
|
+
if (text.length === 0) return baseX;
|
|
1651
|
+
if (i <= 0) try {
|
|
1652
|
+
return this.textEl.getStartPositionOfChar(0).x;
|
|
1653
|
+
} catch {
|
|
1654
|
+
return baseX;
|
|
1655
|
+
}
|
|
1656
|
+
if (i >= text.length) try {
|
|
1657
|
+
return this.textEl.getEndPositionOfChar(text.length - 1).x;
|
|
1658
|
+
} catch {
|
|
1659
|
+
return baseX;
|
|
1660
|
+
}
|
|
1661
|
+
try {
|
|
1662
|
+
return this.textEl.getStartPositionOfChar(i).x;
|
|
1663
|
+
} catch {
|
|
1664
|
+
return baseX;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
localXToCharIndex(localX) {
|
|
1668
|
+
const text = this.textEl.textContent ?? "";
|
|
1669
|
+
if (!text) return 0;
|
|
1670
|
+
for (let i = 0; i < text.length; i++) try {
|
|
1671
|
+
const ext = this.textEl.getExtentOfChar(i);
|
|
1672
|
+
if (localX < ext.x + ext.width / 2) return i;
|
|
1673
|
+
} catch {
|
|
1674
|
+
break;
|
|
1675
|
+
}
|
|
1676
|
+
return text.length;
|
|
1677
|
+
}
|
|
1678
|
+
};
|
|
1679
|
+
//#endregion
|
|
1680
|
+
//#region src/dom.ts
|
|
1681
|
+
/** Stamped on every rendered SVG element by `render()` so external
|
|
1682
|
+
* tooling (host inspectors, the layers panel, snapshot tests) can map
|
|
1683
|
+
* a DOM node back to its `NodeId`. The cmath fat-hit picker doesn't
|
|
1684
|
+
* use it — see `_pick_node_at_world`. The legacy elementFromPoint
|
|
1685
|
+
* fallback (active when `hit_tolerance_px <= 0`) walks up via
|
|
1686
|
+
* {@link walk_to_id} below. */
|
|
1687
|
+
const ID_ATTR = "data-grida-id";
|
|
1688
|
+
/** Walk from `target` up through `parentElement`, returning the first id
|
|
1689
|
+
* found on a `[data-grida-id]` ancestor. When `exclude_root` matches an
|
|
1690
|
+
* encountered id, the walk stops and returns `null` — selection HUD
|
|
1691
|
+
* treats root as non-selectable; measurement HUD passes `undefined`
|
|
1692
|
+
* so the root id can be returned. */
|
|
1693
|
+
function walk_to_id(target, exclude_root) {
|
|
1694
|
+
let cur = target;
|
|
1695
|
+
while (cur instanceof Element) {
|
|
1696
|
+
const id = cur.getAttribute(ID_ATTR);
|
|
1697
|
+
if (id) return id === exclude_root ? null : id;
|
|
1698
|
+
cur = cur.parentElement;
|
|
1699
|
+
}
|
|
1700
|
+
return null;
|
|
1701
|
+
}
|
|
1702
|
+
const SVG_HUD_GROUP = {
|
|
1703
|
+
selection: "svg-editor.selection",
|
|
1704
|
+
selectionControls: "svg-editor.selection-controls",
|
|
1705
|
+
sizeMeter: "svg-editor.size-meter",
|
|
1706
|
+
memberOutline: "svg-editor.member-outline"
|
|
1707
|
+
};
|
|
1708
|
+
/** KeyboardEvent.key values for the modifiers the surface tracks. Used to
|
|
1709
|
+
* short-circuit window-level `keydown`/`keyup` for non-modifier keystrokes. */
|
|
1710
|
+
const IS_MODIFIER_KEY = {
|
|
1711
|
+
Shift: true,
|
|
1712
|
+
Alt: true,
|
|
1713
|
+
Meta: true,
|
|
1714
|
+
Control: true
|
|
1715
|
+
};
|
|
1716
|
+
/** Sentinel placed in `text_edit` before `createTextEditor` returns, so the
|
|
1717
|
+
* surface skips render() during the in-flight mount and doesn't yank the
|
|
1718
|
+
* live `<text>` element out from under the about-to-mount text surface. */
|
|
1719
|
+
const TEXT_EDIT_PENDING = { __pending: true };
|
|
1720
|
+
/**
|
|
1721
|
+
* Attach a DOM surface to a headless editor. Returns a `DomSurfaceHandle`
|
|
1722
|
+
* whose `detach()` is the inverse — DOM cleared, listeners removed,
|
|
1723
|
+
* gestures uninstalled.
|
|
1724
|
+
*
|
|
1725
|
+
* Usage is one-shot per container: the surface owns the container's children
|
|
1726
|
+
* for its lifetime, and `detach()` restores it to empty.
|
|
1727
|
+
*/
|
|
1728
|
+
function attach_dom_surface(editor, options) {
|
|
1729
|
+
const surface = new DomSurface(editor, options);
|
|
1730
|
+
const inner = editor.attach(surface);
|
|
1731
|
+
return {
|
|
1732
|
+
detach: () => {
|
|
1733
|
+
surface.detach_gestures();
|
|
1734
|
+
inner.detach();
|
|
1735
|
+
},
|
|
1736
|
+
camera: surface.camera,
|
|
1737
|
+
gestures: surface.gestures
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
var DomSurface = class DomSurface {
|
|
1741
|
+
constructor(editor, options) {
|
|
1742
|
+
this.editor = editor;
|
|
1743
|
+
this.svg_root = null;
|
|
1744
|
+
this.teardown = [];
|
|
1745
|
+
this.element_index = /* @__PURE__ */ new Map();
|
|
1746
|
+
this.last_pointer = {
|
|
1747
|
+
x: 0,
|
|
1748
|
+
y: 0
|
|
1749
|
+
};
|
|
1750
|
+
this.last_pointer_valid = false;
|
|
1751
|
+
this.resize_observer = null;
|
|
1752
|
+
this.redraw_raf_id = null;
|
|
1753
|
+
this._geometry_provider = null;
|
|
1754
|
+
this._hit_shapes = null;
|
|
1755
|
+
this._z_order_cache = [];
|
|
1756
|
+
this._z_order_dirty = true;
|
|
1757
|
+
this.active_preview = null;
|
|
1758
|
+
this.text_edit = null;
|
|
1759
|
+
this.text_edit_target = null;
|
|
1760
|
+
this.text_edit_original = "";
|
|
1761
|
+
this.current_tool = require_insertions.TOOL_CURSOR;
|
|
1762
|
+
this.pending_insert = null;
|
|
1763
|
+
this.editor_hover_internal = null;
|
|
1764
|
+
this.container = options.container;
|
|
1765
|
+
const container = this.container;
|
|
1766
|
+
this.fit_on_attach = options.fit === true;
|
|
1767
|
+
if (process.env.NODE_ENV !== "production" && container.children.length > 0) console.warn("@grida/svg-editor: surface container is not empty at attach time. Render chrome (toolbars, layer lists, inspectors) as siblings of the container, not children — otherwise clicks on those children will silently break. See README §Surface.");
|
|
1768
|
+
if (getComputedStyle(container).position === "static") container.style.position = "relative";
|
|
1769
|
+
container.style.userSelect = "none";
|
|
1770
|
+
container.style.webkitUserSelect = "none";
|
|
1771
|
+
const translate_options = () => {
|
|
1772
|
+
const style = this.editor.style;
|
|
1773
|
+
const zoom = this.camera.zoom || 1;
|
|
1774
|
+
return {
|
|
1775
|
+
pixel_grid_quantum: style.snap_to_pixel_grid ? style.pixel_grid_size : null,
|
|
1776
|
+
snap_enabled: style.snap_enabled,
|
|
1777
|
+
snap_threshold_px: style.snap_threshold_px / zoom
|
|
1778
|
+
};
|
|
1779
|
+
};
|
|
1780
|
+
this.translate_orchestrator = new require_insertions.TranslateOrchestrator({
|
|
1781
|
+
get_doc: () => this.editor_internal().doc,
|
|
1782
|
+
emit: () => this.editor_internal().emit(),
|
|
1783
|
+
open_preview: (label) => this.editor_internal().history.preview(label),
|
|
1784
|
+
open_snap: (ids) => this.open_snap_session_for(ids),
|
|
1785
|
+
options: translate_options
|
|
1786
|
+
});
|
|
1787
|
+
const resize_options = () => {
|
|
1788
|
+
const style = this.editor.style;
|
|
1789
|
+
const zoom = this.camera.zoom || 1;
|
|
1790
|
+
return {
|
|
1791
|
+
pixel_grid_quantum: style.snap_to_pixel_grid ? style.pixel_grid_size : null,
|
|
1792
|
+
snap_enabled: style.snap_enabled,
|
|
1793
|
+
snap_threshold_px: style.snap_threshold_px / zoom
|
|
1794
|
+
};
|
|
1795
|
+
};
|
|
1796
|
+
this.resize_orchestrator = new ResizeOrchestrator({
|
|
1797
|
+
get_doc: () => this.editor_internal().doc,
|
|
1798
|
+
emit: () => this.editor_internal().emit(),
|
|
1799
|
+
open_preview: (label) => this.editor_internal().history.preview(label),
|
|
1800
|
+
open_snap: (ids) => this.open_snap_session_for(ids),
|
|
1801
|
+
options: resize_options,
|
|
1802
|
+
bbox_world: (id) => this.bbox_world(id) ?? {
|
|
1803
|
+
x: 0,
|
|
1804
|
+
y: 0,
|
|
1805
|
+
width: 0,
|
|
1806
|
+
height: 0
|
|
1807
|
+
}
|
|
1808
|
+
});
|
|
1809
|
+
const rotate_options = () => ({ angle_snap_step_radians: this.editor.style.angle_snap_step_radians });
|
|
1810
|
+
this.rotate_orchestrator = new require_insertions.RotateOrchestrator({
|
|
1811
|
+
get_doc: () => this.editor_internal().doc,
|
|
1812
|
+
emit: () => this.editor_internal().emit(),
|
|
1813
|
+
open_preview: (label) => this.editor_internal().history.preview(label),
|
|
1814
|
+
options: rotate_options,
|
|
1815
|
+
bbox_world: (id) => this.bbox_world(id) ?? {
|
|
1816
|
+
x: 0,
|
|
1817
|
+
y: 0,
|
|
1818
|
+
width: 0,
|
|
1819
|
+
height: 0
|
|
1820
|
+
}
|
|
1821
|
+
});
|
|
1822
|
+
const editor_ref = this.editor;
|
|
1823
|
+
const editor_for_watcher = {
|
|
1824
|
+
get document() {
|
|
1825
|
+
return editor_ref.document;
|
|
1826
|
+
},
|
|
1827
|
+
get state() {
|
|
1828
|
+
return editor_ref.state;
|
|
1829
|
+
},
|
|
1830
|
+
subscribe_translate_commit: (cb) => this.editor_internal().subscribe_translate_commit(cb)
|
|
1831
|
+
};
|
|
1832
|
+
this.nudge_dwell_watcher = new require_insertions.NudgeDwellWatcher({
|
|
1833
|
+
editor: editor_for_watcher,
|
|
1834
|
+
open_snap: (ids) => this.open_snap_session_for(ids),
|
|
1835
|
+
options: translate_options,
|
|
1836
|
+
on_guides_change: () => this.request_redraw(),
|
|
1837
|
+
window: container.ownerDocument.defaultView ?? window
|
|
1838
|
+
});
|
|
1839
|
+
this.teardown.push(() => this.nudge_dwell_watcher.dispose());
|
|
1840
|
+
this.hud_canvas = container.ownerDocument.createElement("canvas");
|
|
1841
|
+
Object.assign(this.hud_canvas.style, {
|
|
1842
|
+
position: "absolute",
|
|
1843
|
+
left: "0",
|
|
1844
|
+
top: "0",
|
|
1845
|
+
pointerEvents: "none"
|
|
1846
|
+
});
|
|
1847
|
+
container.appendChild(this.hud_canvas);
|
|
1848
|
+
this.hud = new _grida_hud.Surface(this.hud_canvas, {
|
|
1849
|
+
pick: (p) => this.hit_test(p[0], p[1]),
|
|
1850
|
+
shapeOf: (id) => this.shape_of(id),
|
|
1851
|
+
onIntent: (i) => this.commit_intent(i),
|
|
1852
|
+
style: {
|
|
1853
|
+
chromeColor: editor.style.chrome_color,
|
|
1854
|
+
showRotationHandles: true
|
|
1855
|
+
},
|
|
1856
|
+
groups: {
|
|
1857
|
+
selection: SVG_HUD_GROUP.selection,
|
|
1858
|
+
selectionControls: SVG_HUD_GROUP.selectionControls
|
|
1859
|
+
},
|
|
1860
|
+
visibility: ({ gesture }) => {
|
|
1861
|
+
if (gesture.kind !== "translate") return void 0;
|
|
1862
|
+
return { hidden: [
|
|
1863
|
+
SVG_HUD_GROUP.selection,
|
|
1864
|
+
SVG_HUD_GROUP.selectionControls,
|
|
1865
|
+
SVG_HUD_GROUP.sizeMeter
|
|
1866
|
+
] };
|
|
1867
|
+
},
|
|
1868
|
+
pixelGrid: {
|
|
1869
|
+
enabled: editor.style.pixel_grid,
|
|
1870
|
+
zoomThreshold: 4,
|
|
1871
|
+
transform: options.initial_camera
|
|
1872
|
+
}
|
|
1873
|
+
});
|
|
1874
|
+
this.hud.setCursorRenderer(_grida_hud_cursors.cursors.defaultRenderer());
|
|
1875
|
+
this.camera = new Camera({
|
|
1876
|
+
resolve_bounds: (target) => this.resolve_world_bounds(target),
|
|
1877
|
+
initial: options.initial_camera
|
|
1878
|
+
});
|
|
1879
|
+
this.hud.setPixelGridTransform(this.camera.transform);
|
|
1880
|
+
this.teardown.push(this.camera.subscribe(() => {
|
|
1881
|
+
this.apply_camera_transform();
|
|
1882
|
+
this.hud.setPixelGridTransform(this.camera.transform);
|
|
1883
|
+
this.sync_surface_selection();
|
|
1884
|
+
this.redraw();
|
|
1885
|
+
}));
|
|
1886
|
+
this.render();
|
|
1887
|
+
this.sync_canvas_size();
|
|
1888
|
+
this.sync_surface_selection();
|
|
1889
|
+
this.redraw();
|
|
1890
|
+
const win = container.ownerDocument.defaultView ?? window;
|
|
1891
|
+
const raf = win.requestAnimationFrame(() => {
|
|
1892
|
+
this.sync_canvas_size();
|
|
1893
|
+
this.honor_initial_fit();
|
|
1894
|
+
this.redraw();
|
|
1895
|
+
});
|
|
1896
|
+
this.teardown.push(() => win.cancelAnimationFrame(raf));
|
|
1897
|
+
this.gestures = new Gestures({
|
|
1898
|
+
container,
|
|
1899
|
+
svg_root: () => this.svg_root,
|
|
1900
|
+
hud_canvas: this.hud_canvas,
|
|
1901
|
+
camera: this.camera,
|
|
1902
|
+
editor,
|
|
1903
|
+
handle: { detach: () => {} }
|
|
1904
|
+
});
|
|
1905
|
+
if (options.gestures !== false) applyDefaultGestures(this.gestures);
|
|
1906
|
+
const unsub = editor.subscribe(() => {
|
|
1907
|
+
this.current_tool = editor.state.tool;
|
|
1908
|
+
this.render();
|
|
1909
|
+
this.sync_surface_selection();
|
|
1910
|
+
this.hud.setPixelGrid({
|
|
1911
|
+
enabled: editor.style.pixel_grid,
|
|
1912
|
+
zoomThreshold: 4
|
|
1913
|
+
});
|
|
1914
|
+
this.sync_canvas_size();
|
|
1915
|
+
this.sync_cursor();
|
|
1916
|
+
if (this.pending_insert) {
|
|
1917
|
+
const t = this.current_tool;
|
|
1918
|
+
const cur = this.pending_insert;
|
|
1919
|
+
if (t.type === "insert" ? t.tag !== cur.tag : cur.phase === "armed") {
|
|
1920
|
+
if (cur.phase === "drawing") cur.session.discard();
|
|
1921
|
+
this.pending_insert = null;
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
this.request_redraw();
|
|
1925
|
+
});
|
|
1926
|
+
this.teardown.push(unsub);
|
|
1927
|
+
this.teardown.push(editor.subscribe_geometry(() => this.request_redraw()));
|
|
1928
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
1929
|
+
this.resize_observer = new ResizeObserver(() => this.sync_canvas_size());
|
|
1930
|
+
this.resize_observer.observe(container);
|
|
1931
|
+
this.teardown.push(() => this.resize_observer?.disconnect());
|
|
1932
|
+
} else {
|
|
1933
|
+
const win = container.ownerDocument.defaultView ?? window;
|
|
1934
|
+
const fn = () => this.sync_canvas_size();
|
|
1935
|
+
win.addEventListener("resize", fn);
|
|
1936
|
+
this.teardown.push(() => win.removeEventListener("resize", fn));
|
|
1937
|
+
}
|
|
1938
|
+
this.wire_events();
|
|
1939
|
+
const internal = editor._internal;
|
|
1940
|
+
this.editor_hover_internal = internal;
|
|
1941
|
+
internal.set_content_edit_driver((id) => this.enter_content_edit(id));
|
|
1942
|
+
this.teardown.push(() => internal.set_content_edit_driver(null));
|
|
1943
|
+
internal.set_computed_resolver({
|
|
1944
|
+
computed_property: (id, name) => {
|
|
1945
|
+
const el = this.element_index.get(id);
|
|
1946
|
+
if (!el) return null;
|
|
1947
|
+
const value = getComputedStyle(el).getPropertyValue(name);
|
|
1948
|
+
return value === "" ? null : value;
|
|
1949
|
+
},
|
|
1950
|
+
computed_paint: (id, channel) => {
|
|
1951
|
+
const el = this.element_index.get(id);
|
|
1952
|
+
if (!el) return null;
|
|
1953
|
+
const computed = getComputedStyle(el).getPropertyValue(channel);
|
|
1954
|
+
if (computed === "") return null;
|
|
1955
|
+
return {
|
|
1956
|
+
computed,
|
|
1957
|
+
resolved_paint: require_insertions.parse_paint(computed)
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
});
|
|
1961
|
+
this.teardown.push(() => internal.set_computed_resolver(null));
|
|
1962
|
+
const geometry = new MemoizedGeometryProvider(new SvgGeometryDriver({
|
|
1963
|
+
element_for: (id) => this.element_index.get(id) ?? null,
|
|
1964
|
+
root: () => this.svg_root,
|
|
1965
|
+
camera: () => this.camera,
|
|
1966
|
+
container: () => this.container,
|
|
1967
|
+
pick_at_world: (p, allow_root) => this._pick_node_at_world(p, allow_root)
|
|
1968
|
+
}), {
|
|
1969
|
+
subscribe_structure: (cb) => editor.subscribe_with_selector((s) => s.structure_version, () => cb()),
|
|
1970
|
+
subscribe_geometry: (cb) => editor.subscribe_geometry(cb)
|
|
1971
|
+
});
|
|
1972
|
+
this._geometry_provider = geometry;
|
|
1973
|
+
internal.set_geometry(geometry);
|
|
1974
|
+
this.teardown.push(() => {
|
|
1975
|
+
internal.set_geometry(null);
|
|
1976
|
+
geometry.dispose();
|
|
1977
|
+
this._geometry_provider = null;
|
|
1978
|
+
});
|
|
1979
|
+
const hit_shapes = new MemoizedHitShapeProvider(new SvgHitShapeDriver({
|
|
1980
|
+
doc: () => {
|
|
1981
|
+
try {
|
|
1982
|
+
return this.editor_internal().doc;
|
|
1983
|
+
} catch {
|
|
1984
|
+
return null;
|
|
1985
|
+
}
|
|
1986
|
+
},
|
|
1987
|
+
bounds_of: (id) => geometry.bounds_of(id)
|
|
1988
|
+
}), {
|
|
1989
|
+
subscribe_structure: (cb) => editor.subscribe_with_selector((s) => s.structure_version, () => cb()),
|
|
1990
|
+
subscribe_geometry: (cb) => editor.subscribe_geometry(cb)
|
|
1991
|
+
});
|
|
1992
|
+
this._hit_shapes = hit_shapes;
|
|
1993
|
+
this._z_order_dirty = true;
|
|
1994
|
+
this.teardown.push(editor.subscribe_with_selector((s) => s.structure_version, () => {
|
|
1995
|
+
this._z_order_dirty = true;
|
|
1996
|
+
}));
|
|
1997
|
+
this.teardown.push(() => {
|
|
1998
|
+
hit_shapes.dispose();
|
|
1999
|
+
this._hit_shapes = null;
|
|
2000
|
+
this._z_order_cache = [];
|
|
2001
|
+
});
|
|
2002
|
+
internal.set_surface_hover_override_driver((id) => {
|
|
2003
|
+
const response = this.hud.setHoverOverride(id);
|
|
2004
|
+
if (response.hoverChanged) internal.push_surface_hover(this.hud.hover());
|
|
2005
|
+
if (response.needsRedraw) this.redraw();
|
|
2006
|
+
});
|
|
2007
|
+
this.teardown.push(() => internal.set_surface_hover_override_driver(null));
|
|
2008
|
+
}
|
|
2009
|
+
paint(_snapshot) {}
|
|
2010
|
+
hit_test(x, y) {
|
|
2011
|
+
return this.pick_at(x, y, false);
|
|
2012
|
+
}
|
|
2013
|
+
/** Element-walk under (x, y) → first ancestor with `ID_ATTR`. When
|
|
2014
|
+
* `allow_root` is `false`, root hits are rejected (returns `null`) so
|
|
2015
|
+
* the HUD never hovers / selects / drags the document itself —
|
|
2016
|
+
* selection of the root is a host concern. When `true`, the root id
|
|
2017
|
+
* is returned for callers that need it as a measurement candidate
|
|
2018
|
+
* (`<svg>` is a snap target and should be a measurement target too;
|
|
2019
|
+
* see `compute_measurement_extra`). */
|
|
2020
|
+
pick_at(x, y, allow_root) {
|
|
2021
|
+
const world = this.camera.screen_to_world({
|
|
2022
|
+
x,
|
|
2023
|
+
y
|
|
2024
|
+
});
|
|
2025
|
+
return this._pick_node_at_world(world, allow_root);
|
|
2026
|
+
}
|
|
2027
|
+
/** Resolve a world-space point to a node id.
|
|
2028
|
+
*
|
|
2029
|
+
* Two paths, selected at runtime by `EditorStyle.hit_tolerance_px`:
|
|
2030
|
+
*
|
|
2031
|
+
* - **`> 0` (cmath fat-hit picker)** — walks document z-order
|
|
2032
|
+
* topmost-first via {@link pick_at_world}. Each candidate's
|
|
2033
|
+
* hit-shape comes from the memoized `SvgHitShapeDriver`
|
|
2034
|
+
* (intrinsic geometry or world-space bounds-rect fallback for
|
|
2035
|
+
* `<text>` / `<use>` / transformed nodes). Tolerance is screen-
|
|
2036
|
+
* CSS-px, converted to world units via `camera.zoom` so the band
|
|
2037
|
+
* stays the same width on screen regardless of zoom. Has known
|
|
2038
|
+
* issues — see `docs/wg/feat-svg-editor/hit-test.md`.
|
|
2039
|
+
*
|
|
2040
|
+
* - **`<= 0` (legacy elementFromPoint)** — opt-out of the cmath
|
|
2041
|
+
* picker. Uses the browser's painted-pixel hit-test plus a
|
|
2042
|
+
* walk-up of `data-grida-id`. Pixel-exact, no tolerance, but
|
|
2043
|
+
* renderer-correct on every real-world SVG feature (transforms,
|
|
2044
|
+
* cascade, clip-path, fill-rule, pointer-events). This is the
|
|
2045
|
+
* v1-baseline path; useful for A/B comparison and as a safe
|
|
2046
|
+
* fallback if the cmath path misbehaves.
|
|
2047
|
+
*
|
|
2048
|
+
* `allow_root` controls whether the root `<svg>` may be returned:
|
|
2049
|
+
* selection HUD passes `false`, measurement HUD passes `true`.
|
|
2050
|
+
*
|
|
2051
|
+
* Used by both `pick_at` (HUD hover / measurement) and
|
|
2052
|
+
* `SvgGeometryDriver.node_at_point` (core editor selection) so one
|
|
2053
|
+
* source of truth governs every click that resolves to a node. */
|
|
2054
|
+
_pick_node_at_world(p, allow_root) {
|
|
2055
|
+
const root_id = this.editor.tree().root;
|
|
2056
|
+
const tol_px = this.editor.style.hit_tolerance_px;
|
|
2057
|
+
if (tol_px <= 0) return this._pick_node_via_dom(p, allow_root, root_id);
|
|
2058
|
+
const geometry = this._geometry_provider;
|
|
2059
|
+
const hit_shapes = this._hit_shapes;
|
|
2060
|
+
if (geometry && hit_shapes) {
|
|
2061
|
+
const zoom = this.camera.zoom;
|
|
2062
|
+
const hit = pick_at_world(p, {
|
|
2063
|
+
tolerance_world: zoom > 0 ? tol_px / zoom : 0,
|
|
2064
|
+
ordered_ids: this._ensure_z_order(false, root_id),
|
|
2065
|
+
bounds_of: (id) => geometry.bounds_of(id),
|
|
2066
|
+
hit_shape_of: (id) => hit_shapes.hit_shape_of(id)
|
|
2067
|
+
});
|
|
2068
|
+
if (hit !== null) return hit;
|
|
2069
|
+
}
|
|
2070
|
+
if (allow_root && geometry) {
|
|
2071
|
+
const root_bounds = geometry.bounds_of(root_id);
|
|
2072
|
+
if (root_bounds && _grida_cmath.default.rect.containsPoint(root_bounds, [p.x, p.y])) return root_id;
|
|
2073
|
+
}
|
|
2074
|
+
return null;
|
|
2075
|
+
}
|
|
2076
|
+
/** Legacy DOM-based picker. World point → container CSS px via camera,
|
|
2077
|
+
* then `elementFromPoint` + walk-up to `data-grida-id`. No tolerance,
|
|
2078
|
+
* no cmath, no z-order owned by us — the browser stacks paints and
|
|
2079
|
+
* returns the topmost. The picker the package shipped with at v1. */
|
|
2080
|
+
_pick_node_via_dom(p, allow_root, root_id) {
|
|
2081
|
+
const screen = this.camera.world_to_screen(p);
|
|
2082
|
+
const cr = this.container.getBoundingClientRect();
|
|
2083
|
+
const target = this.container.ownerDocument.elementFromPoint(cr.left + screen.x, cr.top + screen.y);
|
|
2084
|
+
if (!(target instanceof SVGElement)) return null;
|
|
2085
|
+
return walk_to_id(target, allow_root ? void 0 : root_id);
|
|
2086
|
+
}
|
|
2087
|
+
/** Lazily rebuild the z-order list. Walks the document depth-first,
|
|
2088
|
+
* emitting element ids in paint order (back → front). The root is
|
|
2089
|
+
* always pushed first; callers that disallow root pass `false` to
|
|
2090
|
+
* have the picker skip it. (Cheaper to keep one canonical list and
|
|
2091
|
+
* filter the root inline than to maintain two parallel arrays.) */
|
|
2092
|
+
_ensure_z_order(allow_root, root_id) {
|
|
2093
|
+
if (this._z_order_dirty) {
|
|
2094
|
+
const out = [];
|
|
2095
|
+
const doc = (() => {
|
|
2096
|
+
try {
|
|
2097
|
+
return this.editor_internal().doc;
|
|
2098
|
+
} catch {
|
|
2099
|
+
return null;
|
|
2100
|
+
}
|
|
2101
|
+
})();
|
|
2102
|
+
if (doc) {
|
|
2103
|
+
const walk = (id) => {
|
|
2104
|
+
out.push(id);
|
|
2105
|
+
for (const c of doc.element_children_of(id)) walk(c);
|
|
2106
|
+
};
|
|
2107
|
+
walk(doc.root);
|
|
2108
|
+
}
|
|
2109
|
+
this._z_order_cache = out;
|
|
2110
|
+
this._z_order_dirty = false;
|
|
2111
|
+
}
|
|
2112
|
+
if (allow_root) return this._z_order_cache;
|
|
2113
|
+
if (this._z_order_cache.length > 0 && this._z_order_cache[0] === root_id) return this._z_order_cache.slice(1);
|
|
2114
|
+
return this._z_order_cache.filter((id) => id !== root_id);
|
|
2115
|
+
}
|
|
2116
|
+
on_input(_listener) {
|
|
2117
|
+
return () => {};
|
|
2118
|
+
}
|
|
2119
|
+
dispose() {
|
|
2120
|
+
if (this.text_edit) {
|
|
2121
|
+
this.text_edit.cancel();
|
|
2122
|
+
this.text_edit = null;
|
|
2123
|
+
this.text_edit_target = null;
|
|
2124
|
+
}
|
|
2125
|
+
this.gestures._dispose();
|
|
2126
|
+
this.translate_orchestrator.cancel();
|
|
2127
|
+
this.resize_orchestrator.cancel();
|
|
2128
|
+
this.rotate_orchestrator.cancel();
|
|
2129
|
+
this.active_preview = null;
|
|
2130
|
+
if (this.redraw_raf_id !== null) {
|
|
2131
|
+
(this.container.ownerDocument.defaultView ?? window).cancelAnimationFrame(this.redraw_raf_id);
|
|
2132
|
+
this.redraw_raf_id = null;
|
|
2133
|
+
}
|
|
2134
|
+
for (const fn of this.teardown) fn();
|
|
2135
|
+
this.teardown = [];
|
|
2136
|
+
this.hud.dispose();
|
|
2137
|
+
this.hud_canvas.remove();
|
|
2138
|
+
if (this.svg_root) this.svg_root.remove();
|
|
2139
|
+
this.svg_root = null;
|
|
2140
|
+
this.element_index.clear();
|
|
2141
|
+
this.last_pointer_valid = false;
|
|
2142
|
+
}
|
|
2143
|
+
/** Public — invoked by the `DomSurfaceHandle` wrapper before `detach()`. */
|
|
2144
|
+
detach_gestures() {
|
|
2145
|
+
this.gestures._dispose();
|
|
2146
|
+
}
|
|
2147
|
+
render() {
|
|
2148
|
+
if (this.text_edit) return;
|
|
2149
|
+
const owner_doc = this.container.ownerDocument;
|
|
2150
|
+
const doc = this.editor._internal.doc;
|
|
2151
|
+
const svg_text = this.editor.serialize();
|
|
2152
|
+
const wrapper = owner_doc.createElement("div");
|
|
2153
|
+
wrapper.innerHTML = svg_text;
|
|
2154
|
+
const new_svg = wrapper.querySelector("svg");
|
|
2155
|
+
if (!(new_svg instanceof SVGSVGElement)) return;
|
|
2156
|
+
if (this.svg_root) this.svg_root.replaceWith(new_svg);
|
|
2157
|
+
else this.container.insertBefore(new_svg, this.hud_canvas);
|
|
2158
|
+
this.svg_root = new_svg;
|
|
2159
|
+
this.apply_svg_layout();
|
|
2160
|
+
this.apply_camera_transform();
|
|
2161
|
+
this.element_index.clear();
|
|
2162
|
+
const ids = doc.all_elements();
|
|
2163
|
+
let i = 0;
|
|
2164
|
+
const tag_walk = (el) => {
|
|
2165
|
+
if (i < ids.length) {
|
|
2166
|
+
const id = ids[i++];
|
|
2167
|
+
el.setAttribute(ID_ATTR, id);
|
|
2168
|
+
this.element_index.set(id, el);
|
|
2169
|
+
}
|
|
2170
|
+
for (let c = el.firstElementChild; c; c = c.nextElementSibling) if (c instanceof SVGElement) tag_walk(c);
|
|
2171
|
+
};
|
|
2172
|
+
tag_walk(new_svg);
|
|
2173
|
+
}
|
|
2174
|
+
sync_canvas_size() {
|
|
2175
|
+
const cr = this.container.getBoundingClientRect();
|
|
2176
|
+
this.hud.setSize(cr.width, cr.height);
|
|
2177
|
+
this.camera._set_viewport_size(cr.width, cr.height);
|
|
2178
|
+
this.redraw();
|
|
2179
|
+
}
|
|
2180
|
+
/**
|
|
2181
|
+
* Apply absolute positioning + transform-origin to the SVG so the camera's
|
|
2182
|
+
* CSS matrix maps SVG-coord (0,0) cleanly to container-screen (tx, ty).
|
|
2183
|
+
* Called after every render() that may have replaced the root element.
|
|
2184
|
+
*/
|
|
2185
|
+
apply_svg_layout() {
|
|
2186
|
+
if (!this.svg_root) return;
|
|
2187
|
+
const style = this.svg_root.style;
|
|
2188
|
+
style.position = "absolute";
|
|
2189
|
+
style.left = "0";
|
|
2190
|
+
style.top = "0";
|
|
2191
|
+
style.transformOrigin = "0 0";
|
|
2192
|
+
}
|
|
2193
|
+
/**
|
|
2194
|
+
* Push the current camera transform to the SVG as a CSS `matrix(...)`.
|
|
2195
|
+
* The HUD canvas stays at identity — selection chrome reads node bounds
|
|
2196
|
+
* via `getScreenCTM()`, which already includes the CSS transform, so
|
|
2197
|
+
* chrome aligns automatically and stays 1px sharp at any zoom.
|
|
2198
|
+
*/
|
|
2199
|
+
apply_camera_transform() {
|
|
2200
|
+
if (!this.svg_root) return;
|
|
2201
|
+
const t = this.camera.transform;
|
|
2202
|
+
this.svg_root.style.transform = `matrix(${t[0][0]}, ${t[1][0]}, ${t[0][1]}, ${t[1][1]}, ${t[0][2]}, ${t[1][2]})`;
|
|
2203
|
+
}
|
|
2204
|
+
/** One-shot fit-on-attach. Runs after layout has settled. */
|
|
2205
|
+
honor_initial_fit() {
|
|
2206
|
+
if (!this.fit_on_attach) return;
|
|
2207
|
+
this.fit_on_attach = false;
|
|
2208
|
+
this.camera.fit("<root>");
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* BoundsResolver for `Camera.fit(target)`. The Camera class handles Rect
|
|
2212
|
+
* passthrough itself; this resolver only sees string targets — sentinels
|
|
2213
|
+
* ("<root>", "<selection>") and NodeIds.
|
|
2214
|
+
*/
|
|
2215
|
+
resolve_world_bounds(target) {
|
|
2216
|
+
if (target === "<root>") return this.root_world_bounds();
|
|
2217
|
+
const geometry = this.editor.geometry;
|
|
2218
|
+
if (target === "<selection>") {
|
|
2219
|
+
const sel = this.editor.state.selection;
|
|
2220
|
+
if (sel.length === 0 || !geometry) return null;
|
|
2221
|
+
const rects = [...geometry.bounds_of_many(sel).values()];
|
|
2222
|
+
if (rects.length === 0) return null;
|
|
2223
|
+
return _grida_cmath.default.rect.union(rects);
|
|
2224
|
+
}
|
|
2225
|
+
return geometry?.bounds_of(target) ?? null;
|
|
2226
|
+
}
|
|
2227
|
+
/**
|
|
2228
|
+
* World-space bounds of the root document. Prefer `viewBox` (the SVG's
|
|
2229
|
+
* declared world rect), fall back to `width`/`height` attrs, then the
|
|
2230
|
+
* SVG root's `getBBox()` as a last resort.
|
|
2231
|
+
*/
|
|
2232
|
+
root_world_bounds() {
|
|
2233
|
+
const root_id = this.editor.tree().root;
|
|
2234
|
+
const doc = this.editor.document;
|
|
2235
|
+
const view_box = doc.get_attr(root_id, "viewBox");
|
|
2236
|
+
if (view_box) {
|
|
2237
|
+
const parts = view_box.trim().split(/[\s,]+/).map(Number);
|
|
2238
|
+
if (parts.length === 4 && parts.every((n) => Number.isFinite(n))) return {
|
|
2239
|
+
x: parts[0],
|
|
2240
|
+
y: parts[1],
|
|
2241
|
+
width: parts[2],
|
|
2242
|
+
height: parts[3]
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
const w = parseFloat(doc.get_attr(root_id, "width") ?? "");
|
|
2246
|
+
const h = parseFloat(doc.get_attr(root_id, "height") ?? "");
|
|
2247
|
+
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) return {
|
|
2248
|
+
x: 0,
|
|
2249
|
+
y: 0,
|
|
2250
|
+
width: w,
|
|
2251
|
+
height: h
|
|
2252
|
+
};
|
|
2253
|
+
if (this.svg_root) try {
|
|
2254
|
+
const b = this.svg_root.getBBox();
|
|
2255
|
+
if (b.width > 0 && b.height > 0) return {
|
|
2256
|
+
x: b.x,
|
|
2257
|
+
y: b.y,
|
|
2258
|
+
width: b.width,
|
|
2259
|
+
height: b.height
|
|
2260
|
+
};
|
|
2261
|
+
} catch {}
|
|
2262
|
+
return null;
|
|
2263
|
+
}
|
|
2264
|
+
/** Single per-frame draw entry — merges host-fed extras with surface chrome. */
|
|
2265
|
+
redraw() {
|
|
2266
|
+
this.hud.draw(merge_hud_draws(this.compute_measurement_extra(), this.compute_size_meter_extra(), this.compute_snap_extra(), this.compute_member_outlines_extra()));
|
|
2267
|
+
}
|
|
2268
|
+
/** RAF-coalesced `redraw` for event sources that may emit many times per
|
|
2269
|
+
* frame (geometry-version bumps mid-drag). Camera/gesture paths still call
|
|
2270
|
+
* `redraw` directly for synchronous chrome alignment. */
|
|
2271
|
+
request_redraw() {
|
|
2272
|
+
if (this.redraw_raf_id !== null) return;
|
|
2273
|
+
const win = this.container.ownerDocument.defaultView ?? window;
|
|
2274
|
+
this.redraw_raf_id = win.requestAnimationFrame(() => {
|
|
2275
|
+
this.redraw_raf_id = null;
|
|
2276
|
+
this.redraw();
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
2280
|
+
* Build the host-fed measurement guide for the current frame, or
|
|
2281
|
+
* `undefined` if no guide should be drawn.
|
|
2282
|
+
*
|
|
2283
|
+
* Master signal: Alt held (read from `surface.modifiers()`). Each
|
|
2284
|
+
* additional condition is a derivation, not a separate flag — this keeps
|
|
2285
|
+
* a single source of truth and lets future Alt-consumers (constrained
|
|
2286
|
+
* resize, axis-lock, …) live next to this one without re-tracking the key.
|
|
2287
|
+
*/
|
|
2288
|
+
compute_measurement_extra() {
|
|
2289
|
+
if (!this.hud.modifiers().alt) return void 0;
|
|
2290
|
+
if (this.hud.gesture().kind !== "idle") return void 0;
|
|
2291
|
+
const sel = this.editor.state.selection;
|
|
2292
|
+
if (sel.length === 0) return void 0;
|
|
2293
|
+
let hover = this.hud.hover();
|
|
2294
|
+
if (!hover && this.last_pointer_valid) hover = this.pick_at(this.last_pointer.x, this.last_pointer.y, true);
|
|
2295
|
+
if (!hover) return void 0;
|
|
2296
|
+
if (sel.includes(hover)) return void 0;
|
|
2297
|
+
const a_container = sel.map((id) => this.container_box(id)).filter((r) => r !== null);
|
|
2298
|
+
if (a_container.length === 0) return void 0;
|
|
2299
|
+
const b_container = this.container_box(hover);
|
|
2300
|
+
if (!b_container) return void 0;
|
|
2301
|
+
const m_container = (0, _grida_cmath__measurement.measure)(_grida_cmath.default.rect.union(a_container), b_container);
|
|
2302
|
+
if (!m_container) return void 0;
|
|
2303
|
+
const draw = (0, _grida_hud.measurementToHUDDraw)(m_container, this.editor.style.measurement_color);
|
|
2304
|
+
const geometry = this.editor.geometry;
|
|
2305
|
+
if (geometry) {
|
|
2306
|
+
const a_world = sel.map((id) => geometry.bounds_of(id)).filter((r) => r !== null);
|
|
2307
|
+
const b_world = geometry.bounds_of(hover);
|
|
2308
|
+
if (a_world.length > 0 && b_world) {
|
|
2309
|
+
const m_world = (0, _grida_cmath__measurement.measure)(_grida_cmath.default.rect.union(a_world), b_world);
|
|
2310
|
+
if (m_world) {
|
|
2311
|
+
let cursor = 0;
|
|
2312
|
+
for (const line of draw.lines ?? []) {
|
|
2313
|
+
if (line.label === void 0) continue;
|
|
2314
|
+
const side = next_labellable_side(cursor, m_container, m_world);
|
|
2315
|
+
if (side < 0) break;
|
|
2316
|
+
line.label = _grida_cmath.default.ui.formatNumber(m_world.distance[side], 1);
|
|
2317
|
+
cursor = side + 1;
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
}
|
|
2322
|
+
return draw;
|
|
2323
|
+
}
|
|
2324
|
+
/** Pill position is container-space (tracks camera); values are
|
|
2325
|
+
* world-space (zoom-invariant). Hidden in text-edit mode. */
|
|
2326
|
+
compute_size_meter_extra() {
|
|
2327
|
+
if (!this.editor.style.show_size_meter) return void 0;
|
|
2328
|
+
if (this.editor.state.mode === "edit-content") return void 0;
|
|
2329
|
+
const sel = this.editor.state.selection;
|
|
2330
|
+
if (sel.length === 0) return void 0;
|
|
2331
|
+
const geometry = this.editor.geometry;
|
|
2332
|
+
if (!geometry) return void 0;
|
|
2333
|
+
const color = this.editor.style.chrome_color;
|
|
2334
|
+
if (sel.length === 1) {
|
|
2335
|
+
const shape = this.shape_of(sel[0]);
|
|
2336
|
+
if (shape && shape.kind === "transformed") {
|
|
2337
|
+
const { local, matrix } = shape;
|
|
2338
|
+
const { anchor, angle } = pick_lowest_side_anchor(_grida_cmath.default.rect.toCorners(local).map((p) => _grida_cmath.default.vector2.transform(p, matrix)), true);
|
|
2339
|
+
const label = `${_grida_cmath.default.ui.formatNumber(local.width, 1)} × ${_grida_cmath.default.ui.formatNumber(local.height, 1)}`;
|
|
2340
|
+
return { lines: [{
|
|
2341
|
+
x1: anchor[0],
|
|
2342
|
+
y1: anchor[1],
|
|
2343
|
+
x2: anchor[0],
|
|
2344
|
+
y2: anchor[1],
|
|
2345
|
+
label,
|
|
2346
|
+
color,
|
|
2347
|
+
labelAngle: angle,
|
|
2348
|
+
group: SVG_HUD_GROUP.sizeMeter
|
|
2349
|
+
}] };
|
|
2350
|
+
}
|
|
2351
|
+
if (shape && shape.kind === "line") {
|
|
2352
|
+
const { p1, p2 } = shape;
|
|
2353
|
+
const { anchor, angle } = pick_lowest_side_anchor([p1, p2], false);
|
|
2354
|
+
const dx = p2[0] - p1[0];
|
|
2355
|
+
const dy = p2[1] - p1[1];
|
|
2356
|
+
const label = `${_grida_cmath.default.ui.formatNumber(Math.abs(dx), 1)} × ${_grida_cmath.default.ui.formatNumber(Math.abs(dy), 1)}`;
|
|
2357
|
+
return { lines: [{
|
|
2358
|
+
x1: anchor[0],
|
|
2359
|
+
y1: anchor[1],
|
|
2360
|
+
x2: anchor[0],
|
|
2361
|
+
y2: anchor[1],
|
|
2362
|
+
label,
|
|
2363
|
+
color,
|
|
2364
|
+
labelAngle: angle,
|
|
2365
|
+
group: SVG_HUD_GROUP.sizeMeter
|
|
2366
|
+
}] };
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
const world_rects = [];
|
|
2370
|
+
const container_rects = [];
|
|
2371
|
+
for (const id of sel) {
|
|
2372
|
+
const world = geometry.bounds_of(id);
|
|
2373
|
+
const container = this.container_box(id);
|
|
2374
|
+
if (!world || !container) continue;
|
|
2375
|
+
world_rects.push(world);
|
|
2376
|
+
container_rects.push(container);
|
|
2377
|
+
}
|
|
2378
|
+
if (world_rects.length === 0) return void 0;
|
|
2379
|
+
const world_union = _grida_cmath.default.rect.union(world_rects);
|
|
2380
|
+
const container_union = _grida_cmath.default.rect.union(container_rects);
|
|
2381
|
+
const cx = container_union.x + container_union.width / 2;
|
|
2382
|
+
const by = container_union.y + container_union.height;
|
|
2383
|
+
return { lines: [{
|
|
2384
|
+
x1: cx,
|
|
2385
|
+
y1: by,
|
|
2386
|
+
x2: cx,
|
|
2387
|
+
y2: by,
|
|
2388
|
+
label: `${_grida_cmath.default.ui.formatNumber(world_union.width, 1)} × ${_grida_cmath.default.ui.formatNumber(world_union.height, 1)}`,
|
|
2389
|
+
color,
|
|
2390
|
+
group: SVG_HUD_GROUP.sizeMeter
|
|
2391
|
+
}] };
|
|
2392
|
+
}
|
|
2393
|
+
/**
|
|
2394
|
+
* Thin outline rects for each individually-selected member, drawn
|
|
2395
|
+
* inside the single envelope chrome when a multi-selection is active.
|
|
2396
|
+
* Lets the user see _what_ is selected separately from _what will
|
|
2397
|
+
* resize as a unit_ (the envelope itself, with corner handles).
|
|
2398
|
+
*
|
|
2399
|
+
* Single-member selections already get their outline from the
|
|
2400
|
+
* chrome's own rect renderer — emitting outlines here would be
|
|
2401
|
+
* redundant (double-stroked outline). Returns `undefined` then.
|
|
2402
|
+
*/
|
|
2403
|
+
compute_member_outlines_extra() {
|
|
2404
|
+
if (this.editor.state.mode === "edit-content") return void 0;
|
|
2405
|
+
const sel = this.editor.state.selection;
|
|
2406
|
+
if (sel.length < 2) return void 0;
|
|
2407
|
+
const color = this.editor.style.chrome_color;
|
|
2408
|
+
const rects = [];
|
|
2409
|
+
for (const id of sel) {
|
|
2410
|
+
const r = this.container_box(id);
|
|
2411
|
+
if (!r) continue;
|
|
2412
|
+
rects.push({
|
|
2413
|
+
x: r.x,
|
|
2414
|
+
y: r.y,
|
|
2415
|
+
width: r.width,
|
|
2416
|
+
height: r.height,
|
|
2417
|
+
stroke: true,
|
|
2418
|
+
color,
|
|
2419
|
+
group: SVG_HUD_GROUP.memberOutline
|
|
2420
|
+
});
|
|
2421
|
+
}
|
|
2422
|
+
return rects.length > 0 ? { rects } : void 0;
|
|
2423
|
+
}
|
|
2424
|
+
compute_snap_extra() {
|
|
2425
|
+
const insert_guide = this.pending_insert?.phase === "drawing" ? this.pending_insert.snap_session?.last_guide : void 0;
|
|
2426
|
+
if (insert_guide) return (0, _grida_hud.snapGuideToHUDDraw)(this.project_guide_to_screen(insert_guide), this.editor.style.measurement_color);
|
|
2427
|
+
const guides = this.translate_orchestrator.last_guides.length > 0 ? this.translate_orchestrator.last_guides : this.resize_orchestrator.last_guides.length > 0 ? this.resize_orchestrator.last_guides : this.nudge_dwell_watcher.guides;
|
|
2428
|
+
if (guides.length === 0) return void 0;
|
|
2429
|
+
return (0, _grida_hud.snapGuideToHUDDraw)(this.project_guide_to_screen(guides[0]), this.editor.style.measurement_color);
|
|
2430
|
+
}
|
|
2431
|
+
/** Project a snap guide from world space (pipeline output) to screen
|
|
2432
|
+
* CSS-px (the HUD canvas's identity-transform coordinate system).
|
|
2433
|
+
* Lines + points project via `camera.world_to_screen`; rules carry
|
|
2434
|
+
* a single axis-offset, so they project a representative point on
|
|
2435
|
+
* that axis (the camera has no rotation, so per-axis scale +
|
|
2436
|
+
* translate fully describes the projection). */
|
|
2437
|
+
project_guide_to_screen(g) {
|
|
2438
|
+
const cam = this.camera;
|
|
2439
|
+
return {
|
|
2440
|
+
lines: g.lines.map((l) => {
|
|
2441
|
+
const p1 = cam.world_to_screen({
|
|
2442
|
+
x: l.x1,
|
|
2443
|
+
y: l.y1
|
|
2444
|
+
});
|
|
2445
|
+
const p2 = cam.world_to_screen({
|
|
2446
|
+
x: l.x2,
|
|
2447
|
+
y: l.y2
|
|
2448
|
+
});
|
|
2449
|
+
return {
|
|
2450
|
+
...l,
|
|
2451
|
+
x1: p1.x,
|
|
2452
|
+
y1: p1.y,
|
|
2453
|
+
x2: p2.x,
|
|
2454
|
+
y2: p2.y
|
|
2455
|
+
};
|
|
2456
|
+
}),
|
|
2457
|
+
points: g.points.map(([x, y]) => {
|
|
2458
|
+
const p = cam.world_to_screen({
|
|
2459
|
+
x,
|
|
2460
|
+
y
|
|
2461
|
+
});
|
|
2462
|
+
return [p.x, p.y];
|
|
2463
|
+
}),
|
|
2464
|
+
rules: g.rules.map(([axis, offset]) => {
|
|
2465
|
+
const p = cam.world_to_screen(axis === "x" ? {
|
|
2466
|
+
x: offset,
|
|
2467
|
+
y: 0
|
|
2468
|
+
} : {
|
|
2469
|
+
x: 0,
|
|
2470
|
+
y: offset
|
|
2471
|
+
});
|
|
2472
|
+
return [axis, axis === "x" ? p.x : p.y];
|
|
2473
|
+
})
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
/** Freeze snap inputs at gesture start: dragged agent rects +
|
|
2477
|
+
* neighbor candidate rects. Both come from `bbox_world_for_snap` →
|
|
2478
|
+
* `bbox_world`, which projects each element's `getBBox()` through
|
|
2479
|
+
* its own `transform=` so the rects sit in **doc space** — the root
|
|
2480
|
+
* SVG's user-coordinate system, with element-local rotations / etc.
|
|
2481
|
+
* already accounted for. The pipeline operates in this same space,
|
|
2482
|
+
* so snap inputs and pipeline deltas share a frame end-to-end.
|
|
2483
|
+
*
|
|
2484
|
+
* Returns a fresh `SnapSession`; the caller (orchestrator OR
|
|
2485
|
+
* nudge-dwell watcher) owns its lifetime. */
|
|
2486
|
+
open_snap_session_for(ids) {
|
|
2487
|
+
const doc = this.editor.document;
|
|
2488
|
+
const neighbor_ids = compute_neighborhood(doc, ids);
|
|
2489
|
+
const agent_id_set = /* @__PURE__ */ new Set();
|
|
2490
|
+
for (const id of ids) for (const inner of snap_descent(doc, id)) agent_id_set.add(inner);
|
|
2491
|
+
const agents = [];
|
|
2492
|
+
for (const id of agent_id_set) {
|
|
2493
|
+
const r = this.bbox_world_for_snap(id);
|
|
2494
|
+
if (r) agents.push(r);
|
|
2495
|
+
}
|
|
2496
|
+
const neighbors = [];
|
|
2497
|
+
for (const id of neighbor_ids) {
|
|
2498
|
+
const r = this.bbox_world_for_snap(id);
|
|
2499
|
+
if (r) neighbors.push(r);
|
|
2500
|
+
}
|
|
2501
|
+
return new SnapSession({
|
|
2502
|
+
agents,
|
|
2503
|
+
neighbors
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
/** Cancel any in-flight gesture (orchestrator + active_preview). Used
|
|
2507
|
+
* by Escape and the `cancel_gesture` intent. Returns whether anything
|
|
2508
|
+
* was canceled. */
|
|
2509
|
+
cancel_in_flight() {
|
|
2510
|
+
let canceled = false;
|
|
2511
|
+
if (this.translate_orchestrator.has_active_session()) {
|
|
2512
|
+
this.translate_orchestrator.cancel();
|
|
2513
|
+
canceled = true;
|
|
2514
|
+
}
|
|
2515
|
+
if (this.resize_orchestrator.has_active_session()) {
|
|
2516
|
+
this.resize_orchestrator.cancel();
|
|
2517
|
+
canceled = true;
|
|
2518
|
+
}
|
|
2519
|
+
if (this.rotate_orchestrator.has_active_session()) {
|
|
2520
|
+
this.rotate_orchestrator.cancel();
|
|
2521
|
+
canceled = true;
|
|
2522
|
+
}
|
|
2523
|
+
if (this.active_preview) {
|
|
2524
|
+
this.active_preview.session.discard();
|
|
2525
|
+
this.active_preview = null;
|
|
2526
|
+
canceled = true;
|
|
2527
|
+
}
|
|
2528
|
+
if (this.pending_insert) {
|
|
2529
|
+
if (this.pending_insert.phase === "drawing") {
|
|
2530
|
+
this.pending_insert.session.discard();
|
|
2531
|
+
this.pending_insert.snap_session?.dispose?.();
|
|
2532
|
+
}
|
|
2533
|
+
this.pending_insert = null;
|
|
2534
|
+
this.editor.set_tool({ type: "cursor" });
|
|
2535
|
+
canceled = true;
|
|
2536
|
+
}
|
|
2537
|
+
if (canceled) this.request_redraw();
|
|
2538
|
+
return canceled;
|
|
2539
|
+
}
|
|
2540
|
+
sync_surface_selection() {
|
|
2541
|
+
const state = this.editor.state;
|
|
2542
|
+
if (state.mode === "edit-content") {
|
|
2543
|
+
this.hud.setSelection([]);
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
if (state.selection.length <= 1) {
|
|
2547
|
+
this.hud.setSelection(state.selection);
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
this.hud.setSelection(this.build_selection_groups(state.selection));
|
|
2551
|
+
}
|
|
2552
|
+
/**
|
|
2553
|
+
* Build the HUD's `SelectionGroup[]` for a multi-member selection.
|
|
2554
|
+
* Singleton selections do NOT go through this helper — see
|
|
2555
|
+
* {@link sync_surface_selection} for the policy.
|
|
2556
|
+
*/
|
|
2557
|
+
build_selection_groups(selection) {
|
|
2558
|
+
if (selection.length < 2) return [];
|
|
2559
|
+
const rects = [];
|
|
2560
|
+
for (const id of selection) {
|
|
2561
|
+
const r = this.container_box(id);
|
|
2562
|
+
if (r) rects.push(r);
|
|
2563
|
+
}
|
|
2564
|
+
if (rects.length === 0) return [];
|
|
2565
|
+
return [{
|
|
2566
|
+
ids: selection,
|
|
2567
|
+
shape: {
|
|
2568
|
+
kind: "rect",
|
|
2569
|
+
rect: _grida_cmath.default.rect.union(rects)
|
|
2570
|
+
}
|
|
2571
|
+
}];
|
|
2572
|
+
}
|
|
2573
|
+
/**
|
|
2574
|
+
* Return the selection shape for a node. Vector `<line>` nodes return
|
|
2575
|
+
* `{ kind: "line", p1, p2 }` so the HUD lays out endpoint knobs; all
|
|
2576
|
+
* other nodes return `{ kind: "rect", rect }` using the container-space
|
|
2577
|
+
* bounding box.
|
|
2578
|
+
*/
|
|
2579
|
+
shape_of(id) {
|
|
2580
|
+
const tag = this.tag_of(id);
|
|
2581
|
+
if (tag === "line") {
|
|
2582
|
+
const line = this.line_endpoints_in_container(id);
|
|
2583
|
+
if (line) return {
|
|
2584
|
+
kind: "line",
|
|
2585
|
+
p1: line.p1,
|
|
2586
|
+
p2: line.p2
|
|
2587
|
+
};
|
|
2588
|
+
}
|
|
2589
|
+
const el = this.element_index.get(id);
|
|
2590
|
+
if (!(el instanceof SVGGraphicsElement) || typeof el.getBBox !== "function" || typeof el.getScreenCTM !== "function" || tag === "svg") {
|
|
2591
|
+
const rect = this.container_box(id);
|
|
2592
|
+
return rect ? {
|
|
2593
|
+
kind: "rect",
|
|
2594
|
+
rect
|
|
2595
|
+
} : null;
|
|
2596
|
+
}
|
|
2597
|
+
let bbox_local;
|
|
2598
|
+
try {
|
|
2599
|
+
const b = el.getBBox();
|
|
2600
|
+
bbox_local = {
|
|
2601
|
+
x: b.x,
|
|
2602
|
+
y: b.y,
|
|
2603
|
+
width: b.width,
|
|
2604
|
+
height: b.height
|
|
2605
|
+
};
|
|
2606
|
+
} catch {
|
|
2607
|
+
const rect = this.container_box(id);
|
|
2608
|
+
return rect ? {
|
|
2609
|
+
kind: "rect",
|
|
2610
|
+
rect
|
|
2611
|
+
} : null;
|
|
2612
|
+
}
|
|
2613
|
+
const ctm = el.getScreenCTM();
|
|
2614
|
+
if (!ctm) {
|
|
2615
|
+
const rect = this.container_box(id);
|
|
2616
|
+
return rect ? {
|
|
2617
|
+
kind: "rect",
|
|
2618
|
+
rect
|
|
2619
|
+
} : null;
|
|
2620
|
+
}
|
|
2621
|
+
if (ctm.b === 0 && ctm.c === 0) {
|
|
2622
|
+
const rect = this.container_box(id);
|
|
2623
|
+
return rect ? {
|
|
2624
|
+
kind: "rect",
|
|
2625
|
+
rect
|
|
2626
|
+
} : null;
|
|
2627
|
+
}
|
|
2628
|
+
const cr = this.container.getBoundingClientRect();
|
|
2629
|
+
const dx = -cr.left + this.container.scrollLeft;
|
|
2630
|
+
const dy = -cr.top + this.container.scrollTop;
|
|
2631
|
+
return {
|
|
2632
|
+
kind: "transformed",
|
|
2633
|
+
local: bbox_local,
|
|
2634
|
+
matrix: [[
|
|
2635
|
+
ctm.a,
|
|
2636
|
+
ctm.c,
|
|
2637
|
+
ctm.e + dx
|
|
2638
|
+
], [
|
|
2639
|
+
ctm.b,
|
|
2640
|
+
ctm.d,
|
|
2641
|
+
ctm.f + dy
|
|
2642
|
+
]]
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
/**
|
|
2646
|
+
* Project an SVG `<line>`'s `x1,y1,x2,y2` from its own coordinate space
|
|
2647
|
+
* to the container's coordinate space, where the HUD operates.
|
|
2648
|
+
*/
|
|
2649
|
+
line_endpoints_in_container(id) {
|
|
2650
|
+
const el = this.element_index.get(id);
|
|
2651
|
+
if (!(el instanceof SVGGraphicsElement)) return null;
|
|
2652
|
+
if (typeof el.getScreenCTM !== "function") return null;
|
|
2653
|
+
const ctm = el.getScreenCTM();
|
|
2654
|
+
if (!ctm || !this.svg_root) return null;
|
|
2655
|
+
const x1 = parseFloat(el.getAttribute("x1") ?? "0");
|
|
2656
|
+
const y1 = parseFloat(el.getAttribute("y1") ?? "0");
|
|
2657
|
+
const x2 = parseFloat(el.getAttribute("x2") ?? "0");
|
|
2658
|
+
const y2 = parseFloat(el.getAttribute("y2") ?? "0");
|
|
2659
|
+
if (!Number.isFinite(x1) || !Number.isFinite(y1)) return null;
|
|
2660
|
+
if (!Number.isFinite(x2) || !Number.isFinite(y2)) return null;
|
|
2661
|
+
const project = (px, py) => {
|
|
2662
|
+
return [ctm.a * px + ctm.c * py + ctm.e, ctm.b * px + ctm.d * py + ctm.f];
|
|
2663
|
+
};
|
|
2664
|
+
const cr = this.container.getBoundingClientRect();
|
|
2665
|
+
const [s1x, s1y] = project(x1, y1);
|
|
2666
|
+
const [s2x, s2y] = project(x2, y2);
|
|
2667
|
+
return {
|
|
2668
|
+
p1: [s1x - cr.left + this.container.scrollLeft, s1y - cr.top + this.container.scrollTop],
|
|
2669
|
+
p2: [s2x - cr.left + this.container.scrollLeft, s2y - cr.top + this.container.scrollTop]
|
|
2670
|
+
};
|
|
2671
|
+
}
|
|
2672
|
+
/** Container-space bounding rect for a node. Callers running a batch
|
|
2673
|
+
* (snap session open, marquee) can pass a pre-read `container_rect`
|
|
2674
|
+
* to avoid the per-call layout flush.
|
|
2675
|
+
*
|
|
2676
|
+
* `<svg>` elements (root or nested) establish a viewport (SVG 2 §7.2).
|
|
2677
|
+
* Their visible canvas is the viewport rect, NOT the union of
|
|
2678
|
+
* descendant geometry that `getBBox()` reports (SVG 2 §4.6.4). For
|
|
2679
|
+
* those we read `getBoundingClientRect()` — the CSSOM rendered box
|
|
2680
|
+
* of the `<svg>` element itself, independent of children. Every
|
|
2681
|
+
* other element type still goes through `getBBox` + `getScreenCTM`. */
|
|
2682
|
+
container_box(id, container_rect) {
|
|
2683
|
+
const el = this.element_index.get(id);
|
|
2684
|
+
if (!el) return null;
|
|
2685
|
+
const ge = el;
|
|
2686
|
+
if (typeof ge.getBBox !== "function" || typeof ge.getScreenCTM !== "function") return null;
|
|
2687
|
+
const cr = container_rect ?? this.container.getBoundingClientRect();
|
|
2688
|
+
if (this.tag_of(id) === "svg") {
|
|
2689
|
+
const r = el.getBoundingClientRect();
|
|
2690
|
+
return {
|
|
2691
|
+
x: r.left - cr.left + this.container.scrollLeft,
|
|
2692
|
+
y: r.top - cr.top + this.container.scrollTop,
|
|
2693
|
+
width: r.width,
|
|
2694
|
+
height: r.height
|
|
2695
|
+
};
|
|
2696
|
+
}
|
|
2697
|
+
let bbox;
|
|
2698
|
+
try {
|
|
2699
|
+
const b = ge.getBBox();
|
|
2700
|
+
bbox = {
|
|
2701
|
+
x: b.x,
|
|
2702
|
+
y: b.y,
|
|
2703
|
+
width: b.width,
|
|
2704
|
+
height: b.height
|
|
2705
|
+
};
|
|
2706
|
+
} catch {
|
|
2707
|
+
return null;
|
|
2708
|
+
}
|
|
2709
|
+
const ctm = ge.getScreenCTM();
|
|
2710
|
+
if (!ctm) return null;
|
|
2711
|
+
const project = (px, py) => ({
|
|
2712
|
+
x: ctm.a * px + ctm.c * py + ctm.e,
|
|
2713
|
+
y: ctm.b * px + ctm.d * py + ctm.f
|
|
2714
|
+
});
|
|
2715
|
+
const corners = [
|
|
2716
|
+
project(bbox.x, bbox.y),
|
|
2717
|
+
project(bbox.x + bbox.width, bbox.y),
|
|
2718
|
+
project(bbox.x + bbox.width, bbox.y + bbox.height),
|
|
2719
|
+
project(bbox.x, bbox.y + bbox.height)
|
|
2720
|
+
];
|
|
2721
|
+
const xs = corners.map((c) => c.x);
|
|
2722
|
+
const ys = corners.map((c) => c.y);
|
|
2723
|
+
const left = Math.min(...xs);
|
|
2724
|
+
const top = Math.min(...ys);
|
|
2725
|
+
const right = Math.max(...xs);
|
|
2726
|
+
const bottom = Math.max(...ys);
|
|
2727
|
+
return {
|
|
2728
|
+
x: left - cr.left + this.container.scrollLeft,
|
|
2729
|
+
y: top - cr.top + this.container.scrollTop,
|
|
2730
|
+
width: right - left,
|
|
2731
|
+
height: bottom - top
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
wire_events() {
|
|
2735
|
+
const owner_doc = this.container.ownerDocument;
|
|
2736
|
+
const win = owner_doc.defaultView ?? window;
|
|
2737
|
+
const on = (target, event, handler) => {
|
|
2738
|
+
target.addEventListener(event, handler);
|
|
2739
|
+
this.teardown.push(() => target.removeEventListener(event, handler));
|
|
2740
|
+
};
|
|
2741
|
+
on(this.container, "pointerdown", (e) => this.dispatch_pointer(e, "pointer_down"));
|
|
2742
|
+
on(win, "pointermove", (e) => this.dispatch_pointer(e, "pointer_move"));
|
|
2743
|
+
on(win, "pointerup", (e) => this.dispatch_pointer(e, "pointer_up"));
|
|
2744
|
+
on(owner_doc, "keydown", (e) => this.on_keydown(e));
|
|
2745
|
+
on(win, "keydown", (e) => {
|
|
2746
|
+
if (e.repeat || !IS_MODIFIER_KEY[e.key]) return;
|
|
2747
|
+
this.sync_modifiers(e);
|
|
2748
|
+
});
|
|
2749
|
+
on(win, "keyup", (e) => {
|
|
2750
|
+
if (!IS_MODIFIER_KEY[e.key]) return;
|
|
2751
|
+
this.sync_modifiers(e);
|
|
2752
|
+
});
|
|
2753
|
+
on(win, "blur", () => this.sync_modifiers(null));
|
|
2754
|
+
on(this.container, "contextmenu", (e) => e.preventDefault());
|
|
2755
|
+
}
|
|
2756
|
+
/**
|
|
2757
|
+
* Master signal for modifier-driven UX consumers (measurement, future
|
|
2758
|
+
* constrained-resize, …). Modifier changes aren't on the pointer-event
|
|
2759
|
+
* path, so derived overlays would otherwise wait for the next pointer
|
|
2760
|
+
* move; redraw eagerly. `null` means modifiers are forced clear
|
|
2761
|
+
* (blur / focus-out).
|
|
2762
|
+
*/
|
|
2763
|
+
sync_modifiers(e) {
|
|
2764
|
+
const next = e ? {
|
|
2765
|
+
shift: e.shiftKey,
|
|
2766
|
+
alt: e.altKey,
|
|
2767
|
+
meta: e.metaKey,
|
|
2768
|
+
ctrl: e.ctrlKey
|
|
2769
|
+
} : _grida_hud.NO_MODS;
|
|
2770
|
+
const prev = this.hud.modifiers();
|
|
2771
|
+
if (prev.shift === next.shift && prev.alt === next.alt && prev.meta === next.meta && prev.ctrl === next.ctrl) return;
|
|
2772
|
+
const response = this.hud.dispatch({
|
|
2773
|
+
kind: "modifiers",
|
|
2774
|
+
mods: next
|
|
2775
|
+
});
|
|
2776
|
+
if (prev.shift !== next.shift && this.translate_orchestrator.has_active_session()) this.translate_orchestrator.redrive_modifiers(this.current_translate_modifiers());
|
|
2777
|
+
if (prev.shift !== next.shift && this.resize_orchestrator.has_active_session()) this.resize_orchestrator.redrive_modifiers(this.current_resize_modifiers());
|
|
2778
|
+
if (prev.shift !== next.shift && this.rotate_orchestrator.has_active_session()) this.rotate_orchestrator.redrive_modifiers(this.current_rotate_modifiers());
|
|
2779
|
+
this.redraw();
|
|
2780
|
+
if (response.cursorChanged) this.sync_cursor();
|
|
2781
|
+
if (response.hoverChanged) this.editor_hover_internal?.push_surface_hover(this.hud.hover());
|
|
2782
|
+
}
|
|
2783
|
+
dispatch_pointer(e, kind) {
|
|
2784
|
+
if (this.text_edit) {
|
|
2785
|
+
const target_el = this.text_edit_target ? this.element_index.get(this.text_edit_target) : null;
|
|
2786
|
+
const over_target = !!target_el && e.target instanceof Element && (e.target === target_el || target_el.contains(e.target));
|
|
2787
|
+
if (kind === "pointer_down") {
|
|
2788
|
+
e.preventDefault();
|
|
2789
|
+
if (over_target) this.text_edit.pointerDown(e.clientX, e.clientY, e.shiftKey);
|
|
2790
|
+
else this.text_edit.commit();
|
|
2791
|
+
} else if (kind === "pointer_move") {
|
|
2792
|
+
this.text_edit.pointerMove(e.clientX, e.clientY);
|
|
2793
|
+
this.container.style.cursor = over_target ? "text" : "default";
|
|
2794
|
+
} else if (kind === "pointer_up") this.text_edit.pointerUp();
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
const cr = this.container.getBoundingClientRect();
|
|
2798
|
+
const x = e.clientX - cr.left;
|
|
2799
|
+
const y = e.clientY - cr.top;
|
|
2800
|
+
this.last_pointer.x = x;
|
|
2801
|
+
this.last_pointer.y = y;
|
|
2802
|
+
this.last_pointer_valid = true;
|
|
2803
|
+
const mods = {
|
|
2804
|
+
shift: e.shiftKey,
|
|
2805
|
+
alt: e.altKey,
|
|
2806
|
+
meta: e.metaKey,
|
|
2807
|
+
ctrl: e.ctrlKey
|
|
2808
|
+
};
|
|
2809
|
+
const tool = this.current_tool;
|
|
2810
|
+
if (tool.type === "insert") {
|
|
2811
|
+
if (kind === "pointer_down") {
|
|
2812
|
+
if (e.button === 0) {
|
|
2813
|
+
try {
|
|
2814
|
+
this.container.setPointerCapture(e.pointerId);
|
|
2815
|
+
} catch {}
|
|
2816
|
+
this.start_insert_gesture(tool.tag, {
|
|
2817
|
+
x,
|
|
2818
|
+
y
|
|
2819
|
+
});
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
} else if (this.pending_insert) {
|
|
2823
|
+
if (kind === "pointer_move") {
|
|
2824
|
+
this.update_insert_gesture({
|
|
2825
|
+
x,
|
|
2826
|
+
y
|
|
2827
|
+
}, mods);
|
|
2828
|
+
return;
|
|
2829
|
+
}
|
|
2830
|
+
if (kind === "pointer_up" && e.button === 0) {
|
|
2831
|
+
this.commit_insert_gesture({
|
|
2832
|
+
x,
|
|
2833
|
+
y
|
|
2834
|
+
}, mods);
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
const button = e.button === 0 ? "primary" : e.button === 2 ? "secondary" : "middle";
|
|
2840
|
+
let event;
|
|
2841
|
+
if (kind === "pointer_move") event = {
|
|
2842
|
+
kind,
|
|
2843
|
+
x,
|
|
2844
|
+
y,
|
|
2845
|
+
mods
|
|
2846
|
+
};
|
|
2847
|
+
else event = {
|
|
2848
|
+
kind,
|
|
2849
|
+
x,
|
|
2850
|
+
y,
|
|
2851
|
+
button,
|
|
2852
|
+
mods
|
|
2853
|
+
};
|
|
2854
|
+
const gesture_before_kind = kind === "pointer_up" ? null : this.hud.gesture().kind;
|
|
2855
|
+
const response = this.hud.dispatch(event);
|
|
2856
|
+
if (gesture_before_kind === "idle" && this.hud.gesture().kind !== "idle") try {
|
|
2857
|
+
this.container.setPointerCapture(e.pointerId);
|
|
2858
|
+
} catch {}
|
|
2859
|
+
if (response.needsRedraw) this.redraw();
|
|
2860
|
+
if (response.cursorChanged) this.sync_cursor();
|
|
2861
|
+
if (response.hoverChanged) this.editor_hover_internal?.push_surface_hover(this.hud.hover());
|
|
2862
|
+
}
|
|
2863
|
+
static {
|
|
2864
|
+
this.INSERT_DRAG_THRESHOLD_PX_SQ = 4;
|
|
2865
|
+
}
|
|
2866
|
+
/** Arm an insertion gesture on pointer-down. No IR mutation — keeping
|
|
2867
|
+
* the IR pristine through the click window lets click-no-drag commit
|
|
2868
|
+
* as one atomic `commands.insert` rather than create-zero-then-resize. */
|
|
2869
|
+
start_insert_gesture(tag, screen_pt) {
|
|
2870
|
+
this.pending_insert = {
|
|
2871
|
+
phase: "armed",
|
|
2872
|
+
tag,
|
|
2873
|
+
anchor: this.camera.screen_to_world(screen_pt),
|
|
2874
|
+
anchor_screen: {
|
|
2875
|
+
x: screen_pt.x,
|
|
2876
|
+
y: screen_pt.y
|
|
2877
|
+
}
|
|
2878
|
+
};
|
|
2879
|
+
}
|
|
2880
|
+
/** Per-frame update. `armed` waits for the drag threshold; `drawing`
|
|
2881
|
+
* pushes a frame through the preview session. */
|
|
2882
|
+
update_insert_gesture(screen_pt, mods) {
|
|
2883
|
+
const cur = this.pending_insert;
|
|
2884
|
+
if (!cur) return;
|
|
2885
|
+
if (cur.phase === "armed") {
|
|
2886
|
+
const dx = screen_pt.x - cur.anchor_screen.x;
|
|
2887
|
+
const dy = screen_pt.y - cur.anchor_screen.y;
|
|
2888
|
+
if (dx * dx + dy * dy < DomSurface.INSERT_DRAG_THRESHOLD_PX_SQ) return;
|
|
2889
|
+
this.arm_to_draw(cur);
|
|
2890
|
+
}
|
|
2891
|
+
const live = this.pending_insert;
|
|
2892
|
+
if (live?.phase !== "drawing") return;
|
|
2893
|
+
this.push_drawing_frame(live, screen_pt, mods);
|
|
2894
|
+
}
|
|
2895
|
+
/** Transition `armed` → `drawing`: open `insert_preview` + snap session. */
|
|
2896
|
+
arm_to_draw(armed) {
|
|
2897
|
+
const session = this.editor.commands.insert_preview(armed.tag, require_insertions.initial_attrs(armed.tag, armed.anchor));
|
|
2898
|
+
const snap_session = this.editor.style.snap_enabled && (armed.tag === "rect" || armed.tag === "ellipse") ? this.open_snap_session_for([session.id]) : null;
|
|
2899
|
+
this.pending_insert = {
|
|
2900
|
+
phase: "drawing",
|
|
2901
|
+
tag: armed.tag,
|
|
2902
|
+
anchor: armed.anchor,
|
|
2903
|
+
anchor_screen: armed.anchor_screen,
|
|
2904
|
+
session,
|
|
2905
|
+
snap_session
|
|
2906
|
+
};
|
|
2907
|
+
}
|
|
2908
|
+
/** Push one drag frame through the preview session. */
|
|
2909
|
+
push_drawing_frame(drawing, screen_pt, mods) {
|
|
2910
|
+
let world = this.camera.screen_to_world(screen_pt);
|
|
2911
|
+
if (drawing.snap_session) {
|
|
2912
|
+
const corrected = this.snap_insert_point(drawing.tag, drawing.anchor, drawing.anchor_screen, world, drawing.snap_session);
|
|
2913
|
+
if (corrected) world = corrected;
|
|
2914
|
+
}
|
|
2915
|
+
const dm = {
|
|
2916
|
+
shift: mods.shift,
|
|
2917
|
+
alt: mods.alt
|
|
2918
|
+
};
|
|
2919
|
+
drawing.session.update(require_insertions.compute_drag_attrs(drawing.tag, drawing.anchor, world, dm));
|
|
2920
|
+
}
|
|
2921
|
+
/** Commit on pointer-up. `armed` → one-shot `commands.insert` with
|
|
2922
|
+
* `default_attrs` (click-no-drag, never touches the IR mid-gesture).
|
|
2923
|
+
* `drawing` → push final frame + close the preview. */
|
|
2924
|
+
commit_insert_gesture(screen_pt, mods) {
|
|
2925
|
+
const cur = this.pending_insert;
|
|
2926
|
+
if (!cur) return;
|
|
2927
|
+
if (cur.phase === "armed") this.editor.commands.insert(cur.tag, require_insertions.default_attrs(cur.tag, cur.anchor));
|
|
2928
|
+
else {
|
|
2929
|
+
this.push_drawing_frame(cur, screen_pt, mods);
|
|
2930
|
+
cur.session.commit();
|
|
2931
|
+
cur.snap_session?.dispose?.();
|
|
2932
|
+
}
|
|
2933
|
+
this.pending_insert = null;
|
|
2934
|
+
this.editor.set_tool({ type: "cursor" });
|
|
2935
|
+
}
|
|
2936
|
+
/** Snap the in-progress insert's moving corner to neighbor geometry.
|
|
2937
|
+
* Returns a corrected world-space pointer, or `null` to leave the
|
|
2938
|
+
* input uncorrected. Operates in container CSS-px (the snap engine's
|
|
2939
|
+
* native space) and projects the correction back to world via
|
|
2940
|
+
* `camera.zoom`. Rect / ellipse only. */
|
|
2941
|
+
snap_insert_point(tag, anchor, anchor_screen, current, snap_session) {
|
|
2942
|
+
if (tag !== "rect" && tag !== "ellipse") return null;
|
|
2943
|
+
const zoom = this.camera.zoom;
|
|
2944
|
+
if (zoom <= 0) return null;
|
|
2945
|
+
if (current.x === anchor.x && current.y === anchor.y) return null;
|
|
2946
|
+
const current_screen = this.camera.world_to_screen(current);
|
|
2947
|
+
const effective = {
|
|
2948
|
+
x: Math.min(anchor_screen.x, current_screen.x),
|
|
2949
|
+
y: Math.min(anchor_screen.y, current_screen.y),
|
|
2950
|
+
width: Math.abs(current_screen.x - anchor_screen.x),
|
|
2951
|
+
height: Math.abs(current_screen.y - anchor_screen.y)
|
|
2952
|
+
};
|
|
2953
|
+
const edges_x = current.x === anchor.x ? null : current.x > anchor.x ? "right" : "left";
|
|
2954
|
+
const edges_y = current.y === anchor.y ? null : current.y > anchor.y ? "bottom" : "top";
|
|
2955
|
+
const opts = {
|
|
2956
|
+
enabled: this.editor.style.snap_enabled,
|
|
2957
|
+
threshold_px: this.editor.style.snap_threshold_px
|
|
2958
|
+
};
|
|
2959
|
+
const result = snap_session.snap_resize(effective, {
|
|
2960
|
+
x: edges_x,
|
|
2961
|
+
y: edges_y
|
|
2962
|
+
}, opts);
|
|
2963
|
+
if (result.dx === 0 && result.dy === 0) return current;
|
|
2964
|
+
return {
|
|
2965
|
+
x: current.x + result.dx / zoom,
|
|
2966
|
+
y: current.y + result.dy / zoom
|
|
2967
|
+
};
|
|
2968
|
+
}
|
|
2969
|
+
sync_cursor() {
|
|
2970
|
+
if (this.text_edit) {
|
|
2971
|
+
this.container.style.cursor = "default";
|
|
2972
|
+
return;
|
|
2973
|
+
}
|
|
2974
|
+
if (this.current_tool.type === "insert") {
|
|
2975
|
+
this.container.style.cursor = "crosshair";
|
|
2976
|
+
return;
|
|
2977
|
+
}
|
|
2978
|
+
this.container.style.cursor = this.hud.cursorCss();
|
|
2979
|
+
}
|
|
2980
|
+
on_keydown(e) {
|
|
2981
|
+
if (this.text_edit) return;
|
|
2982
|
+
if (e.code === "Escape") this.cancel_in_flight();
|
|
2983
|
+
if ((this.active_preview || this.translate_orchestrator.has_active_session() || this.resize_orchestrator.has_active_session() || this.rotate_orchestrator.has_active_session() || this.pending_insert) && e.code !== "Escape") return;
|
|
2984
|
+
if (this.editor.keymap.claims(e)) e.preventDefault();
|
|
2985
|
+
this.editor.keymap.dispatch(e);
|
|
2986
|
+
}
|
|
2987
|
+
commit_intent(intent) {
|
|
2988
|
+
switch (intent.kind) {
|
|
2989
|
+
case "select":
|
|
2990
|
+
this.editor.commands.select(intent.ids, { mode: intent.mode });
|
|
2991
|
+
return;
|
|
2992
|
+
case "deselect_all":
|
|
2993
|
+
this.editor.commands.deselect();
|
|
2994
|
+
return;
|
|
2995
|
+
case "translate":
|
|
2996
|
+
this.handle_translate(intent);
|
|
2997
|
+
return;
|
|
2998
|
+
case "resize":
|
|
2999
|
+
this.handle_resize(intent);
|
|
3000
|
+
return;
|
|
3001
|
+
case "rotate":
|
|
3002
|
+
this.handle_rotate(intent);
|
|
3003
|
+
return;
|
|
3004
|
+
case "marquee_select":
|
|
3005
|
+
this.handle_marquee(intent);
|
|
3006
|
+
return;
|
|
3007
|
+
case "set_endpoint":
|
|
3008
|
+
this.handle_set_endpoint(intent);
|
|
3009
|
+
return;
|
|
3010
|
+
case "enter_content_edit":
|
|
3011
|
+
this.editor.commands.select(intent.id);
|
|
3012
|
+
this.editor.enter_content_edit(intent.id);
|
|
3013
|
+
return;
|
|
3014
|
+
case "cancel_gesture":
|
|
3015
|
+
this.cancel_in_flight();
|
|
3016
|
+
return;
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
handle_translate(intent) {
|
|
3020
|
+
if (intent.ids.length === 0) return;
|
|
3021
|
+
const zoom = this.camera.zoom || 1;
|
|
3022
|
+
const dx_world = intent.dx / zoom;
|
|
3023
|
+
const dy_world = intent.dy / zoom;
|
|
3024
|
+
this.translate_orchestrator.drive({
|
|
3025
|
+
ids: intent.ids,
|
|
3026
|
+
movement: [dx_world, dy_world]
|
|
3027
|
+
}, this.current_translate_modifiers(), {
|
|
3028
|
+
phase: intent.phase,
|
|
3029
|
+
policy: "engine",
|
|
3030
|
+
snap: true
|
|
3031
|
+
});
|
|
3032
|
+
if (intent.phase === "commit") this.request_redraw();
|
|
3033
|
+
}
|
|
3034
|
+
/** Snapshot of HUD modifier state mapped to pipeline `TranslateModifiers`.
|
|
3035
|
+
* Pull-at-consume: HUD is the canonical store (see `sync_modifiers`),
|
|
3036
|
+
* read live so mid-drag Shift press/release reflects on the next pass. */
|
|
3037
|
+
current_translate_modifiers() {
|
|
3038
|
+
return {
|
|
3039
|
+
axis_lock: this.hud.modifiers().shift ? "by_dominance" : "off",
|
|
3040
|
+
force_disable_snap: false
|
|
3041
|
+
};
|
|
3042
|
+
}
|
|
3043
|
+
/** Snapshot of HUD modifier state mapped to `ResizeModifiers`. Same
|
|
3044
|
+
* pull-at-consume discipline as `current_translate_modifiers`. */
|
|
3045
|
+
current_resize_modifiers() {
|
|
3046
|
+
return {
|
|
3047
|
+
aspect_lock: this.hud.modifiers().shift ? "uniform" : "off",
|
|
3048
|
+
force_disable_snap: false
|
|
3049
|
+
};
|
|
3050
|
+
}
|
|
3051
|
+
handle_resize(intent) {
|
|
3052
|
+
if (intent.ids.length === 0) return;
|
|
3053
|
+
for (const id of intent.ids) if (!require_insertions.is_resizable_node(this.editor.document, id)) return;
|
|
3054
|
+
const dir = intent.anchor;
|
|
3055
|
+
let target_width;
|
|
3056
|
+
let target_height;
|
|
3057
|
+
if (intent.shape && intent.shape.kind === "transformed") {
|
|
3058
|
+
target_width = intent.shape.local.width;
|
|
3059
|
+
target_height = intent.shape.local.height;
|
|
3060
|
+
} else {
|
|
3061
|
+
const zoom = this.camera.zoom || 1;
|
|
3062
|
+
target_width = intent.rect.width / zoom;
|
|
3063
|
+
target_height = intent.rect.height / zoom;
|
|
3064
|
+
}
|
|
3065
|
+
this.resize_orchestrator.drive({
|
|
3066
|
+
ids: intent.ids,
|
|
3067
|
+
direction: dir,
|
|
3068
|
+
target_width,
|
|
3069
|
+
target_height
|
|
3070
|
+
}, this.current_resize_modifiers(), {
|
|
3071
|
+
phase: intent.phase === "commit" ? "commit" : "preview",
|
|
3072
|
+
snap: true
|
|
3073
|
+
});
|
|
3074
|
+
if (intent.phase === "commit") this.request_redraw();
|
|
3075
|
+
}
|
|
3076
|
+
/** Snapshot of HUD modifier state mapped to `RotateModifiers`. Same
|
|
3077
|
+
* pull-at-consume discipline as the translate / resize equivalents. */
|
|
3078
|
+
current_rotate_modifiers() {
|
|
3079
|
+
return {
|
|
3080
|
+
angle_snap: this.hud.modifiers().shift ? "step" : "off",
|
|
3081
|
+
force_disable_snap: false
|
|
3082
|
+
};
|
|
3083
|
+
}
|
|
3084
|
+
handle_rotate(intent) {
|
|
3085
|
+
if (intent.ids.length === 0) return;
|
|
3086
|
+
const result = this.rotate_orchestrator.drive({
|
|
3087
|
+
ids: intent.ids,
|
|
3088
|
+
angle_radians: intent.angle
|
|
3089
|
+
}, this.current_rotate_modifiers(), { phase: intent.phase === "commit" ? "commit" : "preview" });
|
|
3090
|
+
if (result && result.outcome && result.outcome.kind === "refused") this.emit_rotate_refusal(result.outcome.verdicts);
|
|
3091
|
+
if (intent.phase === "commit") this.request_redraw();
|
|
3092
|
+
}
|
|
3093
|
+
/** Map each refusal verdict to a user-facing chip message. v1 fires
|
|
3094
|
+
* one toast for the first refusal encountered — the user can address
|
|
3095
|
+
* it and try again. Refusal verdicts come straight from
|
|
3096
|
+
* `is_rotatable`. */
|
|
3097
|
+
emit_rotate_refusal(verdicts) {
|
|
3098
|
+
for (const v of verdicts.values()) {
|
|
3099
|
+
if (v.kind !== "refuse") continue;
|
|
3100
|
+
const message = v.reason === "non-trivial-transform" ? "Cannot rotate cleanly — element has a composite transform. Use Flatten Transform first." : v.reason === "text-with-glyph-rotate" ? "Cannot rotate — text has per-glyph rotation. Edit `rotate=` or remove it first." : v.reason === "css-property-transform" ? "Cannot rotate — transform is set via CSS. Move the declaration to the `transform` attribute first." : "Cannot rotate — element has an animated transform. Remove `<animateTransform>` first.";
|
|
3101
|
+
const hud = this.hud;
|
|
3102
|
+
if (typeof hud.setTransientToast === "function") hud.setTransientToast(message);
|
|
3103
|
+
else console.warn(`[svg-editor] ${message}`);
|
|
3104
|
+
return;
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
/**
|
|
3108
|
+
* Apply a `set_endpoint` intent — moving one endpoint of a vector
|
|
3109
|
+
* `<line>` to a new container-space position. Unprojects to the element's
|
|
3110
|
+
* own (SVG) coord space and updates the corresponding attribute.
|
|
3111
|
+
*/
|
|
3112
|
+
handle_set_endpoint(intent) {
|
|
3113
|
+
const id = intent.id;
|
|
3114
|
+
if (this.tag_of(id) !== "line") return;
|
|
3115
|
+
const internal = this.editor_internal();
|
|
3116
|
+
const doc = internal.doc;
|
|
3117
|
+
const emit = internal.emit;
|
|
3118
|
+
if (!this.active_preview || this.active_preview.kind !== "endpoint" || this.active_preview.id !== id || this.active_preview.endpoint !== intent.endpoint) {
|
|
3119
|
+
if (this.active_preview) this.active_preview.session.discard();
|
|
3120
|
+
const initial = {
|
|
3121
|
+
x1: numAttr(doc, id, "x1"),
|
|
3122
|
+
y1: numAttr(doc, id, "y1"),
|
|
3123
|
+
x2: numAttr(doc, id, "x2"),
|
|
3124
|
+
y2: numAttr(doc, id, "y2")
|
|
3125
|
+
};
|
|
3126
|
+
this.active_preview = {
|
|
3127
|
+
kind: "endpoint",
|
|
3128
|
+
id,
|
|
3129
|
+
endpoint: intent.endpoint,
|
|
3130
|
+
initial,
|
|
3131
|
+
session: internal.history.preview("set-endpoint")
|
|
3132
|
+
};
|
|
3133
|
+
}
|
|
3134
|
+
const initial = this.active_preview.initial;
|
|
3135
|
+
const endpoint = this.active_preview.endpoint;
|
|
3136
|
+
const pos_own = this.container_point_in_own_frame(id, intent.pos[0], intent.pos[1]);
|
|
3137
|
+
if (!pos_own) return;
|
|
3138
|
+
const target_x = pos_own.x;
|
|
3139
|
+
const target_y = pos_own.y;
|
|
3140
|
+
const apply = () => {
|
|
3141
|
+
if (endpoint === "p1") {
|
|
3142
|
+
doc.set_attr(id, "x1", String(target_x));
|
|
3143
|
+
doc.set_attr(id, "y1", String(target_y));
|
|
3144
|
+
} else {
|
|
3145
|
+
doc.set_attr(id, "x2", String(target_x));
|
|
3146
|
+
doc.set_attr(id, "y2", String(target_y));
|
|
3147
|
+
}
|
|
3148
|
+
emit();
|
|
3149
|
+
};
|
|
3150
|
+
const revert = () => {
|
|
3151
|
+
doc.set_attr(id, "x1", String(initial.x1));
|
|
3152
|
+
doc.set_attr(id, "y1", String(initial.y1));
|
|
3153
|
+
doc.set_attr(id, "x2", String(initial.x2));
|
|
3154
|
+
doc.set_attr(id, "y2", String(initial.y2));
|
|
3155
|
+
emit();
|
|
3156
|
+
};
|
|
3157
|
+
this.active_preview.session.set({
|
|
3158
|
+
providerId: "svg-editor",
|
|
3159
|
+
apply,
|
|
3160
|
+
revert
|
|
3161
|
+
});
|
|
3162
|
+
if (intent.phase === "commit") {
|
|
3163
|
+
this.active_preview.session.commit();
|
|
3164
|
+
this.active_preview = null;
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
/**
|
|
3168
|
+
* Convert a container-space point to the element's own SVG coord space.
|
|
3169
|
+
* Inverse of `line_endpoints_in_container`'s projection.
|
|
3170
|
+
*/
|
|
3171
|
+
container_point_in_own_frame(id, cx, cy) {
|
|
3172
|
+
const el = this.element_index.get(id);
|
|
3173
|
+
if (!(el instanceof SVGGraphicsElement)) return null;
|
|
3174
|
+
if (typeof el.getScreenCTM !== "function") return null;
|
|
3175
|
+
const ctm = el.getScreenCTM();
|
|
3176
|
+
if (!ctm || !this.svg_root) return null;
|
|
3177
|
+
const cr = this.container.getBoundingClientRect();
|
|
3178
|
+
const inv = ctm.inverse();
|
|
3179
|
+
const p = this.svg_root.createSVGPoint();
|
|
3180
|
+
p.x = cx + cr.left - this.container.scrollLeft;
|
|
3181
|
+
p.y = cy + cr.top - this.container.scrollTop;
|
|
3182
|
+
const t = p.matrixTransform(inv);
|
|
3183
|
+
return {
|
|
3184
|
+
x: t.x,
|
|
3185
|
+
y: t.y
|
|
3186
|
+
};
|
|
3187
|
+
}
|
|
3188
|
+
handle_marquee(intent) {
|
|
3189
|
+
if (intent.phase !== "commit") return;
|
|
3190
|
+
const ids = [];
|
|
3191
|
+
for (const id of this.element_index.keys()) {
|
|
3192
|
+
if (id === this.editor.tree().root) continue;
|
|
3193
|
+
const box = this.container_box(id);
|
|
3194
|
+
if (!box) continue;
|
|
3195
|
+
if (_grida_cmath.default.rect.intersects(box, intent.rect)) ids.push(id);
|
|
3196
|
+
}
|
|
3197
|
+
if (ids.length === 0) {
|
|
3198
|
+
if (!intent.additive) this.editor.commands.deselect();
|
|
3199
|
+
return;
|
|
3200
|
+
}
|
|
3201
|
+
this.editor.commands.select(ids, { mode: intent.additive ? "add" : "replace" });
|
|
3202
|
+
}
|
|
3203
|
+
enter_content_edit(id) {
|
|
3204
|
+
if (this.text_edit) return false;
|
|
3205
|
+
const el = this.element_index.get(id);
|
|
3206
|
+
if (!(el instanceof SVGElement)) return false;
|
|
3207
|
+
const doc = this.editor._internal;
|
|
3208
|
+
if (!(el instanceof SVGTextContentElement)) return false;
|
|
3209
|
+
this.text_edit_target = id;
|
|
3210
|
+
this.text_edit_original = doc.doc.text_of(id);
|
|
3211
|
+
this.text_edit = TEXT_EDIT_PENDING;
|
|
3212
|
+
this.editor.commands.set_mode("edit-content");
|
|
3213
|
+
this.sync_surface_selection();
|
|
3214
|
+
this.sync_cursor();
|
|
3215
|
+
this.redraw();
|
|
3216
|
+
const text_surface = new SvgTextSurface(this.element_index.get(id) ?? el);
|
|
3217
|
+
const is_mac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
|
|
3218
|
+
let settled = false;
|
|
3219
|
+
const cleanup_after_commit_or_cancel = () => {
|
|
3220
|
+
this.editor.commands.set_mode("select");
|
|
3221
|
+
this.render();
|
|
3222
|
+
this.sync_surface_selection();
|
|
3223
|
+
this.sync_cursor();
|
|
3224
|
+
this.redraw();
|
|
3225
|
+
this.text_edit = null;
|
|
3226
|
+
this.text_edit_target = null;
|
|
3227
|
+
};
|
|
3228
|
+
this.text_edit = (0, _grida_text_editor_dom.createTextEditor)({
|
|
3229
|
+
container: this.container,
|
|
3230
|
+
initialText: this.text_edit_original,
|
|
3231
|
+
layout: text_surface,
|
|
3232
|
+
surface: text_surface,
|
|
3233
|
+
isMac: is_mac,
|
|
3234
|
+
ariaLabel: "edit svg text",
|
|
3235
|
+
requiresMutationsForCommit: (text) => /\s{2,}|^\s|\s$/.test(text),
|
|
3236
|
+
callbacks: {
|
|
3237
|
+
onChange: (text) => {
|
|
3238
|
+
doc.doc.set_text(id, text);
|
|
3239
|
+
},
|
|
3240
|
+
onCommit: (final_text) => {
|
|
3241
|
+
if (settled) return;
|
|
3242
|
+
settled = true;
|
|
3243
|
+
doc.doc.set_text(id, this.text_edit_original);
|
|
3244
|
+
cleanup_after_commit_or_cancel();
|
|
3245
|
+
if (final_text !== this.text_edit_original) this.editor.commands.set_text(final_text);
|
|
3246
|
+
},
|
|
3247
|
+
onCancel: () => {
|
|
3248
|
+
if (settled) return;
|
|
3249
|
+
settled = true;
|
|
3250
|
+
doc.doc.set_text(id, this.text_edit_original);
|
|
3251
|
+
cleanup_after_commit_or_cancel();
|
|
3252
|
+
doc.emit();
|
|
3253
|
+
},
|
|
3254
|
+
onUndoFallthrough: () => {
|
|
3255
|
+
this.text_edit?.commit();
|
|
3256
|
+
this.editor.commands.undo();
|
|
3257
|
+
},
|
|
3258
|
+
onRedoFallthrough: () => {
|
|
3259
|
+
this.text_edit?.commit();
|
|
3260
|
+
this.editor.commands.redo();
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
});
|
|
3264
|
+
return true;
|
|
3265
|
+
}
|
|
3266
|
+
tag_of(id) {
|
|
3267
|
+
return this.editor.tree().nodes.get(id)?.tag ?? "";
|
|
3268
|
+
}
|
|
3269
|
+
bbox_local(id) {
|
|
3270
|
+
const el = this.element_index.get(id);
|
|
3271
|
+
if (!el) return null;
|
|
3272
|
+
const ge = el;
|
|
3273
|
+
if (typeof ge.getBBox !== "function") return null;
|
|
3274
|
+
try {
|
|
3275
|
+
const b = ge.getBBox();
|
|
3276
|
+
return {
|
|
3277
|
+
x: b.x,
|
|
3278
|
+
y: b.y,
|
|
3279
|
+
width: b.width,
|
|
3280
|
+
height: b.height
|
|
3281
|
+
};
|
|
3282
|
+
} catch {
|
|
3283
|
+
return null;
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
/** Doc-space AABB of `id`'s rendered geometry — local box projected
|
|
3287
|
+
* through the element's own `transform=`. This is the rect snap,
|
|
3288
|
+
* resize-baseline, and rotate-pivot consumers want: where the user
|
|
3289
|
+
* actually sees the element in the root SVG's user-coordinate system.
|
|
3290
|
+
*
|
|
3291
|
+
* Flat-doc design target: ancestor transforms (`<g transform=...>`)
|
|
3292
|
+
* are out of scope; only the element's own transform is projected.
|
|
3293
|
+
* `getBBox` (called via `bbox_local`) ignores the element's transform
|
|
3294
|
+
* per SVG 2 §4.6.4, so the projection here is what bridges the gap. */
|
|
3295
|
+
bbox_world(id) {
|
|
3296
|
+
const local = this.bbox_local(id);
|
|
3297
|
+
if (!local) return null;
|
|
3298
|
+
return require_insertions.project_local_bbox(local, this.editor.document.get_attr(id, "transform"));
|
|
3299
|
+
}
|
|
3300
|
+
/** World-space rect for snap purposes. Differs from `bbox_world` for
|
|
3301
|
+
* `<svg>` viewport-establishing elements: `getBBox()` on an `<svg>`
|
|
3302
|
+
* reports the union of descendant geometry (SVG 2 §4.6.4), which —
|
|
3303
|
+
* when the dragged element is a descendant — silently turns the
|
|
3304
|
+
* dragged element's own pre-gesture position into a snap target via
|
|
3305
|
+
* the parent's edges. Use the viewport extent instead so the root
|
|
3306
|
+
* SVG's snap edges represent the canvas boundary, not "wherever the
|
|
3307
|
+
* children happen to be right now". */
|
|
3308
|
+
bbox_world_for_snap(id) {
|
|
3309
|
+
if (this.tag_of(id) === "svg") {
|
|
3310
|
+
const el = this.element_index.get(id);
|
|
3311
|
+
if (el instanceof SVGSVGElement) {
|
|
3312
|
+
const vp = svg_viewport_bounds(el);
|
|
3313
|
+
if (vp) return vp;
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
return this.bbox_world(id);
|
|
3317
|
+
}
|
|
3318
|
+
editor_internal() {
|
|
3319
|
+
return this.editor._internal;
|
|
3320
|
+
}
|
|
3321
|
+
};
|
|
3322
|
+
function numAttr(doc, id, name) {
|
|
3323
|
+
return _grida_svg_parse.svg_parse.parse_number(doc.get_attr(id, name));
|
|
3324
|
+
}
|
|
3325
|
+
/** World-space viewport rect of an `<svg>` element. Prefers `viewBox`
|
|
3326
|
+
* (the declared user-space rect — what the user perceives as canvas),
|
|
3327
|
+
* falls back to `width`/`height` at (0,0). For nested `<svg>` with a
|
|
3328
|
+
* positional `x`/`y`, the declared viewBox/(0,0) is in the nested
|
|
3329
|
+
* element's OWN user space; callers are responsible for CTM
|
|
3330
|
+
* projection if a different frame is desired. v1 nested-svg story is
|
|
3331
|
+
* documented in docs/wg/feat-svg-editor/geometry.md as out of scope. */
|
|
3332
|
+
function svg_viewport_bounds(el) {
|
|
3333
|
+
const vb = el.getAttribute("viewBox");
|
|
3334
|
+
if (vb) {
|
|
3335
|
+
const parts = vb.trim().split(/[\s,]+/).map(Number);
|
|
3336
|
+
if (parts.length === 4 && parts.every(Number.isFinite)) return {
|
|
3337
|
+
x: parts[0],
|
|
3338
|
+
y: parts[1],
|
|
3339
|
+
width: parts[2],
|
|
3340
|
+
height: parts[3]
|
|
3341
|
+
};
|
|
3342
|
+
}
|
|
3343
|
+
const w = parseFloat(el.getAttribute("width") ?? "");
|
|
3344
|
+
const h = parseFloat(el.getAttribute("height") ?? "");
|
|
3345
|
+
if (Number.isFinite(w) && Number.isFinite(h)) return {
|
|
3346
|
+
x: 0,
|
|
3347
|
+
y: 0,
|
|
3348
|
+
width: w,
|
|
3349
|
+
height: h
|
|
3350
|
+
};
|
|
3351
|
+
return null;
|
|
3352
|
+
}
|
|
3353
|
+
/** Index of the next side ≥ `start` (in [top, right, bottom, left] order)
|
|
3354
|
+
* where both measurements have a positive distance — i.e. the next side
|
|
3355
|
+
* that `measurementToHUDDraw` would emit a labelled line for. -1 if
|
|
3356
|
+
* none. */
|
|
3357
|
+
/** Among the edges of a point sequence, pick the one whose outward
|
|
3358
|
+
* normal points most downward — i.e. which edge visually FORMS the
|
|
3359
|
+
* bottom of the shape. For a CW-wound polygon (TL/TR/BR/BL, in y-down
|
|
3360
|
+
* screen space) the outward normal of an edge `(p1, p2)` is the edge
|
|
3361
|
+
* direction rotated 90° CW: `(dy, -dx)`, so the normal's Y component
|
|
3362
|
+
* equals `-dx / |edge|`. Ranking by *normalized* outward-Y, not by
|
|
3363
|
+
* midpoint-Y, makes a long-thin rotated rect anchor the label on its
|
|
3364
|
+
* long bottom edge (correct) instead of the short bottom corner
|
|
3365
|
+
* (which happens to have a lower midpoint but doesn't read as "the
|
|
3366
|
+
* bottom side"). Returns the midpoint as `anchor` and the edge's
|
|
3367
|
+
* direction angle normalized to `(-π/2, π/2]` — so a HUDLine label
|
|
3368
|
+
* drawn at the anchor with `labelAngle = angle` reads right-side-up
|
|
3369
|
+
* and its perpendicular offset points further down.
|
|
3370
|
+
*
|
|
3371
|
+
* - `closed = true`: pts are polygon vertices, all consecutive pairs
|
|
3372
|
+
* (incl. last→first) are edges. Use for rects (4 corners → 4 edges).
|
|
3373
|
+
* - `closed = false`: only consecutive pairs in order are edges. Use
|
|
3374
|
+
* for lines (2 endpoints → 1 edge). With a single edge there's no
|
|
3375
|
+
* "outward" to rank — the function just returns the midpoint and
|
|
3376
|
+
* normalized angle. */
|
|
3377
|
+
function pick_lowest_side_anchor(pts, closed) {
|
|
3378
|
+
const edge_count = closed ? pts.length : pts.length - 1;
|
|
3379
|
+
let best_score = -Infinity;
|
|
3380
|
+
let best = null;
|
|
3381
|
+
for (let i = 0; i < edge_count; i++) {
|
|
3382
|
+
const p1 = pts[i];
|
|
3383
|
+
const p2 = pts[(i + 1) % pts.length];
|
|
3384
|
+
const dx = p2[0] - p1[0];
|
|
3385
|
+
const dy = p2[1] - p1[1];
|
|
3386
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
3387
|
+
const score = -dx / len;
|
|
3388
|
+
if (score > best_score) {
|
|
3389
|
+
best_score = score;
|
|
3390
|
+
let theta = Math.atan2(dy, dx);
|
|
3391
|
+
if (theta > Math.PI / 2) theta -= Math.PI;
|
|
3392
|
+
else if (theta <= -Math.PI / 2) theta += Math.PI;
|
|
3393
|
+
best = {
|
|
3394
|
+
anchor: [(p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2],
|
|
3395
|
+
angle: theta
|
|
3396
|
+
};
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
return best;
|
|
3400
|
+
}
|
|
3401
|
+
function next_labellable_side(start, a, b) {
|
|
3402
|
+
for (let i = start; i < 4; i++) if (a.distance[i] > 0 && b.distance[i] > 0) return i;
|
|
3403
|
+
return -1;
|
|
3404
|
+
}
|
|
3405
|
+
/** Concatenate the primitive arrays of N `HUDDraw`s. `undefined` inputs
|
|
3406
|
+
* collapse cleanly so callers can pass per-feature builders without
|
|
3407
|
+
* null-guarding each one. */
|
|
3408
|
+
function merge_hud_draws(...draws) {
|
|
3409
|
+
const present = draws.filter((d) => d !== void 0);
|
|
3410
|
+
if (present.length === 0) return void 0;
|
|
3411
|
+
if (present.length === 1) return present[0];
|
|
3412
|
+
const out = {};
|
|
3413
|
+
for (const d of present) {
|
|
3414
|
+
if (d.lines) (out.lines ??= []).push(...d.lines);
|
|
3415
|
+
if (d.rects) (out.rects ??= []).push(...d.rects);
|
|
3416
|
+
if (d.rules) (out.rules ??= []).push(...d.rules);
|
|
3417
|
+
if (d.points) (out.points ??= []).push(...d.points);
|
|
3418
|
+
if (d.polylines) (out.polylines ??= []).push(...d.polylines);
|
|
3419
|
+
if (d.screenRects) (out.screenRects ??= []).push(...d.screenRects);
|
|
3420
|
+
}
|
|
3421
|
+
return out;
|
|
3422
|
+
}
|
|
3423
|
+
var SvgGeometryDriver = class {
|
|
3424
|
+
constructor(accessors) {
|
|
3425
|
+
this.accessors = accessors;
|
|
3426
|
+
}
|
|
3427
|
+
bounds_of(id) {
|
|
3428
|
+
const el = this.accessors.element_for(id);
|
|
3429
|
+
if (!el) return null;
|
|
3430
|
+
if (el instanceof SVGSVGElement) return svg_viewport_bounds(el);
|
|
3431
|
+
const ge = el;
|
|
3432
|
+
if (typeof ge.getBBox !== "function" || typeof ge.getCTM !== "function") return null;
|
|
3433
|
+
let bbox;
|
|
3434
|
+
try {
|
|
3435
|
+
const b = ge.getBBox();
|
|
3436
|
+
bbox = {
|
|
3437
|
+
x: b.x,
|
|
3438
|
+
y: b.y,
|
|
3439
|
+
width: b.width,
|
|
3440
|
+
height: b.height
|
|
3441
|
+
};
|
|
3442
|
+
} catch {
|
|
3443
|
+
return null;
|
|
3444
|
+
}
|
|
3445
|
+
const ctm = ge.getCTM();
|
|
3446
|
+
if (!ctm) return bbox;
|
|
3447
|
+
const project = (px, py) => ({
|
|
3448
|
+
x: ctm.a * px + ctm.c * py + ctm.e,
|
|
3449
|
+
y: ctm.b * px + ctm.d * py + ctm.f
|
|
3450
|
+
});
|
|
3451
|
+
const corners = [
|
|
3452
|
+
project(bbox.x, bbox.y),
|
|
3453
|
+
project(bbox.x + bbox.width, bbox.y),
|
|
3454
|
+
project(bbox.x + bbox.width, bbox.y + bbox.height),
|
|
3455
|
+
project(bbox.x, bbox.y + bbox.height)
|
|
3456
|
+
];
|
|
3457
|
+
const xs = corners.map((c) => c.x);
|
|
3458
|
+
const ys = corners.map((c) => c.y);
|
|
3459
|
+
const left = Math.min(...xs);
|
|
3460
|
+
const top = Math.min(...ys);
|
|
3461
|
+
const right = Math.max(...xs);
|
|
3462
|
+
const bottom = Math.max(...ys);
|
|
3463
|
+
return {
|
|
3464
|
+
x: left,
|
|
3465
|
+
y: top,
|
|
3466
|
+
width: right - left,
|
|
3467
|
+
height: bottom - top
|
|
3468
|
+
};
|
|
3469
|
+
}
|
|
3470
|
+
bounds_of_many(ids) {
|
|
3471
|
+
const out = /* @__PURE__ */ new Map();
|
|
3472
|
+
for (const id of ids) {
|
|
3473
|
+
const r = this.bounds_of(id);
|
|
3474
|
+
if (r) out.set(id, r);
|
|
3475
|
+
}
|
|
3476
|
+
return out;
|
|
3477
|
+
}
|
|
3478
|
+
nodes_in_rect(rect) {
|
|
3479
|
+
const root = this.accessors.root();
|
|
3480
|
+
if (!root) return [];
|
|
3481
|
+
const hits = [];
|
|
3482
|
+
root.querySelectorAll(`[${ID_ATTR}]`).forEach((el) => {
|
|
3483
|
+
const id = el.getAttribute(ID_ATTR);
|
|
3484
|
+
if (!id) return;
|
|
3485
|
+
const b = this.bounds_of(id);
|
|
3486
|
+
if (b && _grida_cmath.default.rect.intersects(b, rect)) hits.push(id);
|
|
3487
|
+
});
|
|
3488
|
+
return hits;
|
|
3489
|
+
}
|
|
3490
|
+
node_at_point(p) {
|
|
3491
|
+
return this.accessors.pick_at_world(p, true);
|
|
3492
|
+
}
|
|
3493
|
+
};
|
|
3494
|
+
var SvgHitShapeDriver = class {
|
|
3495
|
+
constructor(accessors) {
|
|
3496
|
+
this.accessors = accessors;
|
|
3497
|
+
}
|
|
3498
|
+
hit_shape_of(id) {
|
|
3499
|
+
const doc = this.accessors.doc();
|
|
3500
|
+
if (!doc) return null;
|
|
3501
|
+
const intrinsic = require_insertions.hit_shape_of_doc(doc, id);
|
|
3502
|
+
if (intrinsic) return intrinsic;
|
|
3503
|
+
if (require_insertions.is_transparent_tag(doc.tag_of(id))) return null;
|
|
3504
|
+
const bounds = this.accessors.bounds_of(id);
|
|
3505
|
+
if (!bounds) return null;
|
|
3506
|
+
return {
|
|
3507
|
+
kind: "rect",
|
|
3508
|
+
x: bounds.x,
|
|
3509
|
+
y: bounds.y,
|
|
3510
|
+
width: bounds.width,
|
|
3511
|
+
height: bounds.height
|
|
3512
|
+
};
|
|
3513
|
+
}
|
|
3514
|
+
};
|
|
3515
|
+
//#endregion
|
|
3516
|
+
Object.defineProperty(exports, "Camera", {
|
|
3517
|
+
enumerable: true,
|
|
3518
|
+
get: function() {
|
|
3519
|
+
return Camera;
|
|
3520
|
+
}
|
|
3521
|
+
});
|
|
3522
|
+
Object.defineProperty(exports, "DEFAULT_SNAP_OPTIONS", {
|
|
3523
|
+
enumerable: true,
|
|
3524
|
+
get: function() {
|
|
3525
|
+
return DEFAULT_SNAP_OPTIONS;
|
|
3526
|
+
}
|
|
3527
|
+
});
|
|
3528
|
+
Object.defineProperty(exports, "Gestures", {
|
|
3529
|
+
enumerable: true,
|
|
3530
|
+
get: function() {
|
|
3531
|
+
return Gestures;
|
|
3532
|
+
}
|
|
3533
|
+
});
|
|
3534
|
+
Object.defineProperty(exports, "MemoizedGeometryProvider", {
|
|
3535
|
+
enumerable: true,
|
|
3536
|
+
get: function() {
|
|
3537
|
+
return MemoizedGeometryProvider;
|
|
3538
|
+
}
|
|
3539
|
+
});
|
|
3540
|
+
Object.defineProperty(exports, "attach_dom_surface", {
|
|
3541
|
+
enumerable: true,
|
|
3542
|
+
get: function() {
|
|
3543
|
+
return attach_dom_surface;
|
|
3544
|
+
}
|
|
3545
|
+
});
|