@grida/hud 0.1.0

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.
@@ -0,0 +1,1890 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __copyProps = (to, from, except, desc) => {
9
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
10
+ key = keys[i];
11
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
12
+ get: ((k) => from[k]).bind(null, key),
13
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
14
+ });
15
+ }
16
+ return to;
17
+ };
18
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
19
+ value: mod,
20
+ enumerable: true
21
+ }) : target, mod));
22
+ //#endregion
23
+ let _grida_cmath__measurement = require("@grida/cmath/_measurement");
24
+ let _grida_cmath = require("@grida/cmath");
25
+ _grida_cmath = __toESM(_grida_cmath);
26
+ //#region primitives/canvas.ts
27
+ const DEFAULT_COLOR = "#f44336";
28
+ const DEFAULT_LABEL_FG = "#ffffff";
29
+ const DEFAULT_LINE_WIDTH = .5;
30
+ const CROSSHAIR_SIZE = 4;
31
+ const LABEL_FONT = "10px sans-serif";
32
+ const LABEL_FONT_HEIGHT = 14;
33
+ const LABEL_PADDING_X = 4;
34
+ const LABEL_PADDING_Y = 2;
35
+ const LABEL_BORDER_RADIUS = 4;
36
+ const LABEL_OFFSET = 16;
37
+ const SCREEN_RECT_LINE_WIDTH = 1;
38
+ /**
39
+ * Imperative Canvas 2D renderer for the HUD overlay.
40
+ *
41
+ * Owns a single `<canvas>` element and draws {@link HUDDraw} command lists
42
+ * each frame. All drawing is immediate-mode: the canvas is cleared and
43
+ * fully redrawn on every `draw()` call.
44
+ *
45
+ * The viewport transform is assumed to be axis-aligned (scale + translate only,
46
+ * no rotation/shear). The off-diagonal components of the transform matrix are
47
+ * ignored.
48
+ */
49
+ var HUDCanvas = class {
50
+ constructor(canvas, options) {
51
+ this.canvas = canvas;
52
+ this.transform = [[
53
+ 1,
54
+ 0,
55
+ 0
56
+ ], [
57
+ 0,
58
+ 1,
59
+ 0
60
+ ]];
61
+ this.width = 0;
62
+ this.height = 0;
63
+ this.ctx = canvas.getContext("2d");
64
+ this.dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
65
+ this.color = options?.color ?? DEFAULT_COLOR;
66
+ }
67
+ setColor(color) {
68
+ this.color = color ?? DEFAULT_COLOR;
69
+ }
70
+ setSize(w, h) {
71
+ const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
72
+ if (this.width === w && this.height === h && this.dpr === dpr) return;
73
+ this.dpr = dpr;
74
+ this.width = w;
75
+ this.height = h;
76
+ this.canvas.width = w * this.dpr;
77
+ this.canvas.height = h * this.dpr;
78
+ this.canvas.style.width = `${w}px`;
79
+ this.canvas.style.height = `${h}px`;
80
+ }
81
+ setTransform(transform) {
82
+ this.transform = transform;
83
+ }
84
+ /**
85
+ * Clear the canvas and draw all primitives in `commands`.
86
+ * Pass `undefined` to clear without drawing (e.g. when no overlay is active).
87
+ */
88
+ draw(commands) {
89
+ const ctx = this.ctx;
90
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
91
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
92
+ if (!commands) return;
93
+ const { lines, rules, points, rects, polylines, screenRects } = commands;
94
+ if (rules && rules.length > 0) this.drawRules(rules);
95
+ if (rects && rects.length > 0) this.drawRects(rects);
96
+ if (polylines && polylines.length > 0) this.drawPolylines(polylines);
97
+ if (lines && lines.length > 0) this.drawLines(lines);
98
+ if (points && points.length > 0) this.drawPoints(points);
99
+ if (screenRects && screenRects.length > 0) this.drawScreenRects(screenRects);
100
+ }
101
+ applyViewTransform() {
102
+ const [[sx, , tx], [, sy, ty]] = this.transform;
103
+ this.ctx.setTransform(sx * this.dpr, 0, 0, sy * this.dpr, tx * this.dpr, ty * this.dpr);
104
+ }
105
+ applyScreenTransform() {
106
+ this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
107
+ }
108
+ /** Project a scalar offset on `axis` to screen-space. */
109
+ deltaToScreen(offset, axis) {
110
+ const i = axis === "x" ? 0 : 1;
111
+ const row = this.transform[i];
112
+ return row[i] * offset + row[2];
113
+ }
114
+ drawRules(rules) {
115
+ const ctx = this.ctx;
116
+ this.applyScreenTransform();
117
+ ctx.strokeStyle = this.color;
118
+ ctx.lineWidth = DEFAULT_LINE_WIDTH;
119
+ for (const { axis, offset } of rules) {
120
+ const screenOffset = this.deltaToScreen(offset, axis);
121
+ ctx.beginPath();
122
+ if (axis === "x") {
123
+ ctx.moveTo(screenOffset, 0);
124
+ ctx.lineTo(screenOffset, this.height);
125
+ } else {
126
+ ctx.moveTo(0, screenOffset);
127
+ ctx.lineTo(this.width, screenOffset);
128
+ }
129
+ ctx.stroke();
130
+ }
131
+ }
132
+ drawLines(lines) {
133
+ const ctx = this.ctx;
134
+ const zoom = this.transform[0][0];
135
+ const [[sx, , tx], [, sy, ty]] = this.transform;
136
+ this.applyViewTransform();
137
+ ctx.strokeStyle = this.color;
138
+ ctx.lineWidth = DEFAULT_LINE_WIDTH / zoom;
139
+ let dashed = false;
140
+ let currentWidth = DEFAULT_LINE_WIDTH;
141
+ let currentColor = this.color;
142
+ for (const line of lines) {
143
+ if (line.dashed && !dashed) {
144
+ ctx.setLineDash([4 / zoom, 3 / zoom]);
145
+ dashed = true;
146
+ } else if (!line.dashed && dashed) {
147
+ ctx.setLineDash([]);
148
+ dashed = false;
149
+ }
150
+ const w = line.strokeWidth ?? DEFAULT_LINE_WIDTH;
151
+ if (w !== currentWidth) {
152
+ ctx.lineWidth = w / zoom;
153
+ currentWidth = w;
154
+ }
155
+ const c = line.color ?? this.color;
156
+ if (c !== currentColor) {
157
+ ctx.strokeStyle = c;
158
+ currentColor = c;
159
+ }
160
+ ctx.beginPath();
161
+ ctx.moveTo(line.x1, line.y1);
162
+ ctx.lineTo(line.x2, line.y2);
163
+ ctx.stroke();
164
+ }
165
+ if (dashed) ctx.setLineDash([]);
166
+ this.applyScreenTransform();
167
+ ctx.font = LABEL_FONT;
168
+ ctx.textAlign = "center";
169
+ ctx.textBaseline = "middle";
170
+ for (const line of lines) {
171
+ if (!line.label) continue;
172
+ const midX = (line.x1 + line.x2) / 2;
173
+ const midY = (line.y1 + line.y2) / 2;
174
+ const lx = sx * midX + tx;
175
+ const ly = sy * midY + ty;
176
+ const isVertical = Math.abs(line.x2 - line.x1) < Math.abs(line.y2 - line.y1);
177
+ const labelX = isVertical ? lx + LABEL_OFFSET : lx;
178
+ const labelY = isVertical ? ly : ly + LABEL_OFFSET;
179
+ const tw = ctx.measureText(line.label).width + LABEL_PADDING_X * 2;
180
+ const th = LABEL_FONT_HEIGHT + LABEL_PADDING_Y * 2;
181
+ ctx.fillStyle = line.color ?? this.color;
182
+ ctx.beginPath();
183
+ ctx.roundRect(labelX - tw / 2, labelY - th / 2, tw, th, LABEL_BORDER_RADIUS);
184
+ ctx.fill();
185
+ ctx.fillStyle = DEFAULT_LABEL_FG;
186
+ ctx.fillText(line.label, labelX, labelY);
187
+ }
188
+ }
189
+ drawRects(rects) {
190
+ const ctx = this.ctx;
191
+ const zoom = this.transform[0][0];
192
+ this.applyViewTransform();
193
+ ctx.lineWidth = DEFAULT_LINE_WIDTH / zoom;
194
+ let currentWidth = DEFAULT_LINE_WIDTH;
195
+ for (const rect of rects) {
196
+ const doStroke = rect.stroke !== false;
197
+ const doFill = rect.fill === true;
198
+ const color = rect.color ?? this.color;
199
+ if (rect.dashed) ctx.setLineDash([4 / zoom, 3 / zoom]);
200
+ if (doFill) {
201
+ ctx.globalAlpha = rect.fillOpacity ?? 1;
202
+ ctx.fillStyle = color;
203
+ ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
204
+ ctx.globalAlpha = 1;
205
+ }
206
+ if (doStroke) {
207
+ const w = rect.strokeWidth ?? DEFAULT_LINE_WIDTH;
208
+ if (w !== currentWidth) {
209
+ ctx.lineWidth = w / zoom;
210
+ currentWidth = w;
211
+ }
212
+ ctx.strokeStyle = color;
213
+ ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
214
+ }
215
+ if (rect.dashed) ctx.setLineDash([]);
216
+ }
217
+ }
218
+ drawPolylines(polylines) {
219
+ const ctx = this.ctx;
220
+ const zoom = this.transform[0][0];
221
+ this.applyViewTransform();
222
+ ctx.lineWidth = DEFAULT_LINE_WIDTH / zoom;
223
+ for (const poly of polylines) {
224
+ if (poly.points.length < 2) continue;
225
+ ctx.beginPath();
226
+ ctx.moveTo(poly.points[0][0], poly.points[0][1]);
227
+ for (let i = 1; i < poly.points.length; i++) ctx.lineTo(poly.points[i][0], poly.points[i][1]);
228
+ const doFill = poly.fill === true;
229
+ const doStroke = poly.stroke !== false;
230
+ const color = poly.color ?? this.color;
231
+ if (doFill) {
232
+ ctx.closePath();
233
+ ctx.globalAlpha = poly.fillOpacity ?? 1;
234
+ ctx.fillStyle = color;
235
+ ctx.fill("evenodd");
236
+ ctx.globalAlpha = 1;
237
+ }
238
+ if (doStroke) {
239
+ if (poly.dashed) ctx.setLineDash([4 / zoom, 3 / zoom]);
240
+ ctx.strokeStyle = color;
241
+ ctx.stroke();
242
+ if (poly.dashed) ctx.setLineDash([]);
243
+ }
244
+ }
245
+ }
246
+ drawPoints(points) {
247
+ const ctx = this.ctx;
248
+ this.applyScreenTransform();
249
+ ctx.strokeStyle = this.color;
250
+ ctx.lineWidth = DEFAULT_LINE_WIDTH;
251
+ const half = CROSSHAIR_SIZE / 2;
252
+ const [[sx, , tx], [, sy, ty]] = this.transform;
253
+ ctx.beginPath();
254
+ for (const [px, py] of points) {
255
+ const scrX = sx * px + tx;
256
+ const scrY = sy * py + ty;
257
+ ctx.moveTo(scrX - half, scrY - half);
258
+ ctx.lineTo(scrX + half, scrY + half);
259
+ ctx.moveTo(scrX + half, scrY - half);
260
+ ctx.lineTo(scrX - half, scrY + half);
261
+ }
262
+ ctx.stroke();
263
+ }
264
+ /**
265
+ * Draw rects whose **size is in screen-space** but whose **anchor is in
266
+ * document-space**. The doc-space point is projected via the current
267
+ * transform; the rect is then drawn at fixed CSS-pixel dimensions.
268
+ *
269
+ * This is the primitive used to draw resize / rotate handles — they must
270
+ * remain a constant visual size regardless of viewport zoom.
271
+ */
272
+ drawScreenRects(rects) {
273
+ const ctx = this.ctx;
274
+ const [[sx, , tx], [, sy, ty]] = this.transform;
275
+ this.applyScreenTransform();
276
+ ctx.lineWidth = SCREEN_RECT_LINE_WIDTH;
277
+ for (const r of rects) {
278
+ const scrX = sx * r.x + tx;
279
+ const scrY = sy * r.y + ty;
280
+ const w = r.width;
281
+ const h = r.height;
282
+ const anchor = r.anchor ?? "center";
283
+ let x = scrX;
284
+ let y = scrY;
285
+ switch (anchor) {
286
+ case "center":
287
+ x = scrX - w / 2;
288
+ y = scrY - h / 2;
289
+ break;
290
+ case "tl":
291
+ x = scrX;
292
+ y = scrY;
293
+ break;
294
+ case "tr":
295
+ x = scrX - w;
296
+ y = scrY;
297
+ break;
298
+ case "bl":
299
+ x = scrX;
300
+ y = scrY - h;
301
+ break;
302
+ case "br":
303
+ x = scrX - w;
304
+ y = scrY - h;
305
+ break;
306
+ }
307
+ const doFill = r.fill !== false;
308
+ const doStroke = r.stroke !== false;
309
+ if (doFill) {
310
+ ctx.fillStyle = r.fillColor ?? this.color;
311
+ ctx.fillRect(x, y, w, h);
312
+ }
313
+ if (doStroke) {
314
+ ctx.strokeStyle = r.strokeColor ?? this.color;
315
+ ctx.strokeRect(x, y, w, h);
316
+ }
317
+ }
318
+ }
319
+ };
320
+ //#endregion
321
+ //#region primitives/snap-guide.ts
322
+ /**
323
+ * Convert a `guide.SnapGuide` (the output of `guide.plot()`) into a
324
+ * generic {@link HUDDraw} command list.
325
+ *
326
+ * Lines pass through directly (HUDLine extends cmath.ui.Line).
327
+ * Points pass through directly (both are cmath.Vector2).
328
+ * Rules are destructured from tuples to objects.
329
+ */
330
+ function snapGuideToHUDDraw(sg) {
331
+ if (!sg) return void 0;
332
+ return {
333
+ lines: sg.lines,
334
+ rules: sg.rules.map(([axis, offset]) => ({
335
+ axis,
336
+ offset
337
+ })),
338
+ points: sg.points
339
+ };
340
+ }
341
+ //#endregion
342
+ //#region primitives/measurement-guide.ts
343
+ const SIDES = [
344
+ "top",
345
+ "right",
346
+ "bottom",
347
+ "left"
348
+ ];
349
+ /**
350
+ * Convert a {@link Measurement} (the output of `measure()`) into a
351
+ * generic {@link HUDDraw} command list.
352
+ *
353
+ * All coordinates are in **document space** — the HUD canvas applies
354
+ * the viewport transform.
355
+ *
356
+ * Produces:
357
+ * - Two stroke-only rects for the A and B bounding boxes
358
+ * - One labelled guide line per non-zero distance (solid)
359
+ * - One auxiliary line per non-zero side connecting the guide to B (dashed)
360
+ *
361
+ * If `color` is provided, every emitted line and rect carries that color so
362
+ * the guides render distinctly from the canvas's chrome color. When used via
363
+ * `surface.draw(extra)` (the host-fed-extras channel) this is required to
364
+ * separate measurement from selection chrome on a shared canvas.
365
+ */
366
+ function measurementToHUDDraw(m, color) {
367
+ const { a, b, box, distance } = m;
368
+ const rects = [{
369
+ x: a.x,
370
+ y: a.y,
371
+ width: a.width,
372
+ height: a.height,
373
+ color
374
+ }, {
375
+ x: b.x,
376
+ y: b.y,
377
+ width: b.width,
378
+ height: b.height,
379
+ color
380
+ }];
381
+ const lines = [];
382
+ for (let i = 0; i < 4; i++) {
383
+ const dist = distance[i];
384
+ if (dist <= 0) continue;
385
+ const side = SIDES[i];
386
+ const label = _grida_cmath.default.ui.formatNumber(dist, 1);
387
+ const [x1, y1, x2, y2] = (0, _grida_cmath__measurement.guide_line_xylr)(box, side, dist);
388
+ lines.push({
389
+ x1,
390
+ y1,
391
+ x2,
392
+ y2,
393
+ label,
394
+ color
395
+ });
396
+ const [ax1, ay1, ax2, ay2, aLen] = (0, _grida_cmath__measurement.auxiliary_line_xylr)([x2, y2], b, side);
397
+ if (aLen > 0) lines.push({
398
+ x1: ax1,
399
+ y1: ay1,
400
+ x2: ax2,
401
+ y2: ay2,
402
+ dashed: true,
403
+ color
404
+ });
405
+ }
406
+ return {
407
+ rects,
408
+ lines
409
+ };
410
+ }
411
+ //#endregion
412
+ //#region primitives/marquee.ts
413
+ /**
414
+ * Convert two marquee corner points into a {@link HUDDraw} command list.
415
+ *
416
+ * All coordinates are in **document space**.
417
+ *
418
+ * Produces a single rectangle with a stroke outline and a semi-transparent fill.
419
+ */
420
+ function marqueeToHUDDraw(a, b) {
421
+ const rect = _grida_cmath.default.rect.fromPoints([a, b]);
422
+ return { rects: [{
423
+ x: rect.x,
424
+ y: rect.y,
425
+ width: rect.width,
426
+ height: rect.height,
427
+ fill: true,
428
+ fillOpacity: .2
429
+ }] };
430
+ }
431
+ //#endregion
432
+ //#region primitives/lasso.ts
433
+ /**
434
+ * Convert a lasso point sequence into a {@link HUDDraw} command list.
435
+ *
436
+ * All coordinates are in **document space**.
437
+ *
438
+ * Produces a single polyline with a dashed stroke and a semi-transparent fill.
439
+ */
440
+ function lassoToHUDDraw(points) {
441
+ if (points.length < 2) return void 0;
442
+ return { polylines: [{
443
+ points,
444
+ fill: true,
445
+ fillOpacity: .2,
446
+ dashed: true
447
+ }] };
448
+ }
449
+ //#endregion
450
+ //#region event/event.ts
451
+ const NO_MODS = {
452
+ shift: false,
453
+ alt: false,
454
+ meta: false,
455
+ ctrl: false
456
+ };
457
+ function emptyResponse() {
458
+ return {
459
+ needsRedraw: false,
460
+ cursorChanged: false,
461
+ hoverChanged: false
462
+ };
463
+ }
464
+ //#endregion
465
+ //#region event/gesture.ts
466
+ const IDLE = { kind: "idle" };
467
+ /**
468
+ * Compute a normalized rectangle from two corner points in document-space.
469
+ * Thin alias over `cmath.rect.fromPoints` for ergonomic two-point use.
470
+ */
471
+ function rectFromPoints(a, b) {
472
+ return _grida_cmath.default.rect.fromPoints([a, b]);
473
+ }
474
+ /**
475
+ * Apply a resize handle drag to an initial rect and return the new rect.
476
+ *
477
+ * `dx, dy` is the total drag delta in document-space, measured from
478
+ * `anchor_doc` (the pointer-down point) to the current pointer.
479
+ *
480
+ * No constraints: width/height can go negative — host is responsible for
481
+ * normalizing if it cares (most callers clamp to a min-size).
482
+ */
483
+ function applyResize(initial, direction, dx, dy) {
484
+ let { x, y, width, height } = initial;
485
+ switch (direction) {
486
+ case "n":
487
+ y += dy;
488
+ height -= dy;
489
+ break;
490
+ case "s":
491
+ height += dy;
492
+ break;
493
+ case "e":
494
+ width += dx;
495
+ break;
496
+ case "w":
497
+ x += dx;
498
+ width -= dx;
499
+ break;
500
+ case "ne":
501
+ y += dy;
502
+ height -= dy;
503
+ width += dx;
504
+ break;
505
+ case "nw":
506
+ y += dy;
507
+ height -= dy;
508
+ x += dx;
509
+ width -= dx;
510
+ break;
511
+ case "se":
512
+ width += dx;
513
+ height += dy;
514
+ break;
515
+ case "sw":
516
+ x += dx;
517
+ width -= dx;
518
+ height += dy;
519
+ break;
520
+ }
521
+ return {
522
+ x,
523
+ y,
524
+ width,
525
+ height
526
+ };
527
+ }
528
+ //#endregion
529
+ //#region event/cursor.ts
530
+ /** Cursor-equality used to detect changes without object allocations. */
531
+ function cursorEquals(a, b) {
532
+ if (typeof a === "string" && typeof b === "string") return a === b;
533
+ if (typeof a !== "string" && typeof b !== "string") {
534
+ if (a.kind === "resize" && b.kind === "resize") return a.direction === b.direction;
535
+ if (a.kind === "rotate" && b.kind === "rotate") return a.corner === b.corner;
536
+ return false;
537
+ }
538
+ return false;
539
+ }
540
+ //#endregion
541
+ //#region event/click-tracker.ts
542
+ var ClickTracker = class {
543
+ constructor(opts = {}) {
544
+ this.last_time = 0;
545
+ this.last_x = 0;
546
+ this.last_y = 0;
547
+ this.count = 0;
548
+ this.window_ms = opts.windowMs ?? 500;
549
+ this.distance_px = opts.distancePx ?? 5;
550
+ }
551
+ /**
552
+ * Register a click at `(x, y)` and return the current consecutive-click
553
+ * count (1 for single, 2 for double, etc.). `now` is in milliseconds.
554
+ */
555
+ register(x, y, now = nowMs()) {
556
+ const dt = now - this.last_time;
557
+ const dx = x - this.last_x;
558
+ const dy = y - this.last_y;
559
+ const dist2 = dx * dx + dy * dy;
560
+ const max2 = this.distance_px * this.distance_px;
561
+ if (dt <= this.window_ms && dist2 <= max2 && this.count > 0) this.count += 1;
562
+ else this.count = 1;
563
+ this.last_time = now;
564
+ this.last_x = x;
565
+ this.last_y = y;
566
+ return this.count;
567
+ }
568
+ reset() {
569
+ this.count = 0;
570
+ this.last_time = 0;
571
+ }
572
+ };
573
+ function nowMs() {
574
+ if (typeof performance !== "undefined" && performance.now) return performance.now();
575
+ return Date.now();
576
+ }
577
+ //#endregion
578
+ //#region event/hit-regions.ts
579
+ /**
580
+ * Registry of overlay UI hit regions.
581
+ *
582
+ * Regions are appended in draw order (back-to-front). `hitTest` iterates
583
+ * in reverse so the topmost region wins — matching how the chrome is
584
+ * visually layered.
585
+ */
586
+ var HitRegions = class {
587
+ constructor() {
588
+ this.regions = [];
589
+ }
590
+ clear() {
591
+ this.regions.length = 0;
592
+ }
593
+ push(region) {
594
+ this.regions.push(region);
595
+ }
596
+ hitTest(point) {
597
+ for (let i = this.regions.length - 1; i >= 0; i--) {
598
+ const r = this.regions[i];
599
+ if (_grida_cmath.default.rect.containsPoint(r.rect, point)) return r.action;
600
+ }
601
+ return null;
602
+ }
603
+ isEmpty() {
604
+ return this.regions.length === 0;
605
+ }
606
+ /** Read-only access for tests. */
607
+ toArray() {
608
+ return this.regions;
609
+ }
610
+ };
611
+ //#endregion
612
+ //#region event/decision.ts
613
+ /**
614
+ * Recognize which scenario a pointer-down belongs to. Total over inputs.
615
+ * Pure, no I/O. The single source of truth for "which atomic intent did the
616
+ * user just express?"
617
+ */
618
+ function classifyScenario(input) {
619
+ const { ui_action, hovered_id, selection_ids, modifiers, click_count, readonly } = input;
620
+ if (ui_action) switch (ui_action.kind) {
621
+ case "resize_handle": return readonly ? "Noop" : "HandleResize";
622
+ case "rotate_handle": return readonly ? "Noop" : "HandleRotate";
623
+ case "endpoint_handle": return readonly ? "Noop" : "HandleEndpoint";
624
+ case "select_node": {
625
+ const id = ui_action.id;
626
+ if (click_count >= 2) return "EnterEdit";
627
+ return classifyContent(id, selection_ids, modifiers);
628
+ }
629
+ case "translate_handle": return classifyBodyRegion(hovered_id, selection_ids, modifiers, click_count);
630
+ }
631
+ if (hovered_id) {
632
+ if (click_count >= 2) return "EnterEdit";
633
+ return classifyContent(hovered_id, selection_ids, modifiers);
634
+ }
635
+ if (modifiers.shift) return "EmptyAdditiveMarquee";
636
+ if (selection_ids.length > 0) return "EmptyDeselectThenMarquee";
637
+ return "EmptyMarquee";
638
+ }
639
+ /**
640
+ * Tier-2 content classification (also reused for the Tier-1 `select_node`
641
+ * overlay variant). The asymmetry lives here in one place:
642
+ *
643
+ * would-deselect (in selection) → ambiguous (defer)
644
+ * would-select (not in selection) → singleton (immediate)
645
+ */
646
+ function classifyContent(id, selection_ids, modifiers) {
647
+ if (selection_ids.includes(id)) return modifiers.shift ? "ContentToggleOrDrag" : "ContentNarrowOrDrag";
648
+ return modifiers.shift ? "ContentAdd" : "ContentReplace";
649
+ }
650
+ /**
651
+ * Body-region classification. Every variant defers, because in the body
652
+ * region "drag the existing selection" is always a candidate intent — even
653
+ * with shift, even when the underlying hover would otherwise be a clear
654
+ * select. The body region's whole purpose is to claim drag.
655
+ */
656
+ function classifyBodyRegion(hovered_id, selection_ids, modifiers, click_count) {
657
+ if (click_count >= 2) return "EnterEdit";
658
+ if (!hovered_id) return "BodyDragOnly";
659
+ if (selection_ids.includes(hovered_id)) return modifiers.shift ? "BodyToggleOrDrag" : "BodyNarrowOrDrag";
660
+ return modifiers.shift ? "BodyAddOrDrag" : "BodySwapOrDrag";
661
+ }
662
+ /**
663
+ * Decide what a primary-button pointer-down should do. Thin wrapper:
664
+ * classify the scenario, then dispatch declaratively. The dispatch table is
665
+ * a flat switch — adding a new scenario shows up as exactly one new case.
666
+ */
667
+ function decidePointerDown(input) {
668
+ return dispatch(classifyScenario(input), input);
669
+ }
670
+ function dispatch(scenario, input) {
671
+ const { ui_action, hovered_id, selection_ids, modifiers } = input;
672
+ switch (scenario) {
673
+ case "Noop": return { kind: "noop" };
674
+ case "HandleResize": {
675
+ const a = ui_action;
676
+ return {
677
+ kind: "start_resize",
678
+ ids: a.ids,
679
+ direction: a.direction,
680
+ initial_rect: a.initial_rect
681
+ };
682
+ }
683
+ case "HandleRotate": {
684
+ const a = ui_action;
685
+ return {
686
+ kind: "start_rotate",
687
+ ids: a.ids,
688
+ corner: a.corner,
689
+ initial_rect: a.initial_rect
690
+ };
691
+ }
692
+ case "HandleEndpoint": {
693
+ const a = ui_action;
694
+ return {
695
+ kind: "start_endpoint",
696
+ id: a.id,
697
+ endpoint: a.endpoint,
698
+ p1: a.p1,
699
+ p2: a.p2
700
+ };
701
+ }
702
+ case "EnterEdit": {
703
+ const chrome_ids = ui_action && ui_action.kind === "translate_handle" ? ui_action.ids : null;
704
+ const id = hovered_id ?? (chrome_ids && chrome_ids.length === 1 ? chrome_ids[0] : null);
705
+ if (id === null) return { kind: "noop" };
706
+ return {
707
+ kind: "enter_edit",
708
+ id
709
+ };
710
+ }
711
+ case "ContentReplace": {
712
+ const id = contentId(ui_action, hovered_id);
713
+ return {
714
+ kind: "immediate_select",
715
+ select_ids: [id],
716
+ mode: "replace",
717
+ pending: { ids_at_down: [id] }
718
+ };
719
+ }
720
+ case "ContentAdd": {
721
+ const id = contentId(ui_action, hovered_id);
722
+ return {
723
+ kind: "immediate_select",
724
+ select_ids: [id],
725
+ mode: "toggle",
726
+ pending: { ids_at_down: [id, ...selection_ids.filter((s) => s !== id)] }
727
+ };
728
+ }
729
+ case "ContentNarrowOrDrag":
730
+ case "ContentToggleOrDrag": {
731
+ const id = contentId(ui_action, hovered_id);
732
+ return {
733
+ kind: "pend",
734
+ pending: {
735
+ ids_at_down: [...selection_ids],
736
+ deferred: {
737
+ node_id: id,
738
+ shift: modifiers.shift
739
+ }
740
+ }
741
+ };
742
+ }
743
+ case "BodyDragOnly": return {
744
+ kind: "pend",
745
+ pending: { ids_at_down: [...ui_action.ids] }
746
+ };
747
+ case "BodyNarrowOrDrag":
748
+ case "BodyToggleOrDrag":
749
+ case "BodySwapOrDrag":
750
+ case "BodyAddOrDrag": return {
751
+ kind: "pend",
752
+ pending: {
753
+ ids_at_down: [...ui_action.ids],
754
+ deferred: {
755
+ node_id: hovered_id,
756
+ shift: modifiers.shift
757
+ }
758
+ }
759
+ };
760
+ case "EmptyDeselectThenMarquee": return {
761
+ kind: "start_marquee_pend",
762
+ emit_deselect_all: true
763
+ };
764
+ case "EmptyMarquee":
765
+ case "EmptyAdditiveMarquee": return {
766
+ kind: "start_marquee_pend",
767
+ emit_deselect_all: false
768
+ };
769
+ }
770
+ }
771
+ /** Resolve the id that the user clicked on for content-class scenarios. */
772
+ function contentId(ui_action, hovered_id) {
773
+ if (ui_action && ui_action.kind === "select_node") return ui_action.id;
774
+ return hovered_id;
775
+ }
776
+ /**
777
+ * What the cursor should show while idle. Drives the cursor in lockstep
778
+ * with the pointer-down decision — both read the same inputs, so cursor
779
+ * and intent can't drift.
780
+ *
781
+ * Hover (the visual outline) is NOT decided here — it's always
782
+ * `hovered_id`. Cursor is about INTENT (what the next pointer-down would
783
+ * do), not what's visually under the pointer.
784
+ */
785
+ function decideIdleCursor(input) {
786
+ const { ui_action, hovered_id, selection_ids } = input;
787
+ if (ui_action) switch (ui_action.kind) {
788
+ case "resize_handle": return {
789
+ kind: "resize",
790
+ direction: ui_action.direction
791
+ };
792
+ case "rotate_handle": return {
793
+ kind: "rotate",
794
+ corner: ui_action.corner
795
+ };
796
+ case "translate_handle": return "move";
797
+ case "select_node":
798
+ case "endpoint_handle": return "pointer";
799
+ }
800
+ if (hovered_id && selection_ids.includes(hovered_id)) return "move";
801
+ return "default";
802
+ }
803
+ //#endregion
804
+ //#region event/transform.ts
805
+ const IDENTITY = [[
806
+ 1,
807
+ 0,
808
+ 0
809
+ ], [
810
+ 0,
811
+ 1,
812
+ 0
813
+ ]];
814
+ /** Project a screen-space point into document-space. */
815
+ function screenToDoc(t, x, y) {
816
+ const [[sx, , tx], [, sy, ty]] = t;
817
+ return [(x - tx) / (sx || 1), (y - ty) / (sy || 1)];
818
+ }
819
+ /** Project a document-space point into screen-space. */
820
+ function docToScreen(t, x, y) {
821
+ const [[sx, , tx], [, sy, ty]] = t;
822
+ return [sx * x + tx, sy * y + ty];
823
+ }
824
+ //#endregion
825
+ //#region event/state.ts
826
+ const DRAG_THRESHOLD_PX = 3;
827
+ /**
828
+ * Pure-logic surface state machine.
829
+ *
830
+ * Owns gesture, hover, modifiers, cursor, click-tracker, hit-regions, and
831
+ * the current selection **mirror** pushed by the host. Does not own the
832
+ * authoritative selection — emits `select` intents the host commits.
833
+ *
834
+ * No canvas knowledge. No DOM knowledge. No React.
835
+ */
836
+ var SurfaceState = class {
837
+ constructor() {
838
+ this.gesture = IDLE;
839
+ this.hover = null;
840
+ this.hover_override = null;
841
+ this.cursor = "default";
842
+ this.modifiers = { ...NO_MODS };
843
+ this.readonly = false;
844
+ this.groups = [];
845
+ this.selection_ids = [];
846
+ this.transform = IDENTITY;
847
+ this.hit_regions = new HitRegions();
848
+ this.click_tracker = new ClickTracker();
849
+ this.pending = null;
850
+ }
851
+ /**
852
+ * The effective hover: override beats pick.
853
+ * Used by chrome rendering and by `Surface.hover()`.
854
+ */
855
+ getEffectiveHover() {
856
+ return this.hover_override ?? this.hover;
857
+ }
858
+ /**
859
+ * Push a new selection from the host. Accepts either:
860
+ *
861
+ * - **A flat `NodeId[]`** — each id becomes its own single-member group
862
+ * with shape resolved via `shapeOf(id)` at chrome build time. Simple
863
+ * hosts (e.g. svg-editor v1) use this overload.
864
+ * - **A `SelectionGroup[]`** — pre-computed groups (typically grouped by
865
+ * parent), each with its own pre-unioned shape. Hosts that already
866
+ * compute groups (e.g. the main editor) use this overload.
867
+ *
868
+ * The flat-ids form is resolved lazily — `shapeOf` is called by the chrome
869
+ * builder, not here. This keeps `setSelection` cheap and lets host shape
870
+ * changes (e.g. after a node move) be reflected without re-calling
871
+ * `setSelection`.
872
+ */
873
+ setSelection(input) {
874
+ if (input.length === 0) {
875
+ this.groups = [];
876
+ this.selection_ids = [];
877
+ return;
878
+ }
879
+ if (typeof input[0] === "string") {
880
+ const ids = input;
881
+ this.selection_ids = [...ids];
882
+ this.groups = ids.map((id) => ({
883
+ ids: [id],
884
+ shape: {
885
+ kind: "unresolved",
886
+ id
887
+ }
888
+ }));
889
+ } else {
890
+ const groups = input;
891
+ this.groups = groups.map((g) => ({
892
+ ids: [...g.ids],
893
+ shape: g.shape
894
+ }));
895
+ const flat = [];
896
+ for (const g of groups) flat.push(...g.ids);
897
+ this.selection_ids = flat;
898
+ }
899
+ }
900
+ /** Read-only access to the flat list of selected ids (for hover logic). */
901
+ getSelectionIds() {
902
+ return this.selection_ids;
903
+ }
904
+ /** Read-only access to the selection groups (for chrome rendering). */
905
+ getSelectionGroups() {
906
+ return this.groups;
907
+ }
908
+ setTransform(t) {
909
+ this.transform = t;
910
+ }
911
+ getTransform() {
912
+ return this.transform;
913
+ }
914
+ setReadonly(v) {
915
+ this.readonly = v;
916
+ }
917
+ hitRegions() {
918
+ return this.hit_regions;
919
+ }
920
+ dispatch(event, deps) {
921
+ switch (event.kind) {
922
+ case "pointer_move": return this.onPointerMove(event.x, event.y, event.mods, deps);
923
+ case "pointer_down":
924
+ this.modifiers = event.mods;
925
+ return this.onPointerDown(event.x, event.y, event.button, deps);
926
+ case "pointer_up":
927
+ this.modifiers = event.mods;
928
+ return this.onPointerUp(event.x, event.y, event.button, deps);
929
+ case "modifiers":
930
+ this.modifiers = event.mods;
931
+ return emptyResponse();
932
+ case "wheel":
933
+ case "key": return emptyResponse();
934
+ case "blur": return this.onBlur(deps);
935
+ }
936
+ }
937
+ onPointerMove(sx, sy, mods, deps) {
938
+ this.modifiers = mods;
939
+ const response = emptyResponse();
940
+ const point_doc = screenToDoc(this.transform, sx, sy);
941
+ if (this.pending && this.gesture.kind === "idle") {
942
+ const ax = this.pending.anchor_screen[0];
943
+ const ay = this.pending.anchor_screen[1];
944
+ const dx = sx - ax;
945
+ const dy = sy - ay;
946
+ if (dx * dx + dy * dy >= DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) {
947
+ const ids = this.pending.ids_at_down;
948
+ this.pending.deferred = void 0;
949
+ if (ids.length > 0) {
950
+ this.gesture = {
951
+ kind: "translate",
952
+ ids,
953
+ anchor_doc: this.pending.anchor_doc,
954
+ last_doc: point_doc
955
+ };
956
+ this.setCursor("move", response);
957
+ } else this.gesture = {
958
+ kind: "marquee",
959
+ anchor_doc: this.pending.anchor_doc,
960
+ current_doc: point_doc
961
+ };
962
+ }
963
+ }
964
+ switch (this.gesture.kind) {
965
+ case "idle": {
966
+ const hit = deps.pick(point_doc);
967
+ this.setHover(hit, response);
968
+ const ui_action = this.hit_regions.hitTest([sx, sy]);
969
+ this.setCursor(decideIdleCursor({
970
+ ui_action,
971
+ hovered_id: hit,
972
+ selection_ids: this.selection_ids
973
+ }), response);
974
+ return response;
975
+ }
976
+ case "translate": {
977
+ const g = this.gesture;
978
+ const dx = point_doc[0] - g.anchor_doc[0];
979
+ const dy = point_doc[1] - g.anchor_doc[1];
980
+ this.gesture = {
981
+ ...g,
982
+ last_doc: point_doc
983
+ };
984
+ deps.emitIntent({
985
+ kind: "translate",
986
+ ids: g.ids,
987
+ dx,
988
+ dy,
989
+ phase: "preview"
990
+ });
991
+ response.needsRedraw = true;
992
+ return response;
993
+ }
994
+ case "marquee": {
995
+ const g = this.gesture;
996
+ this.gesture = {
997
+ ...g,
998
+ current_doc: point_doc
999
+ };
1000
+ response.needsRedraw = true;
1001
+ return response;
1002
+ }
1003
+ case "resize": {
1004
+ const g = this.gesture;
1005
+ const dx = point_doc[0] - g.anchor_doc[0];
1006
+ const dy = point_doc[1] - g.anchor_doc[1];
1007
+ const next_rect = applyResize(g.initial_rect, g.direction, dx, dy);
1008
+ this.gesture = {
1009
+ ...g,
1010
+ current_rect: next_rect
1011
+ };
1012
+ deps.emitIntent({
1013
+ kind: "resize",
1014
+ ids: g.ids,
1015
+ anchor: g.direction,
1016
+ rect: next_rect,
1017
+ phase: "preview"
1018
+ });
1019
+ response.needsRedraw = true;
1020
+ return response;
1021
+ }
1022
+ case "rotate": {
1023
+ const g = this.gesture;
1024
+ const angle = Math.atan2(point_doc[1] - g.center_doc[1], point_doc[0] - g.center_doc[0]);
1025
+ this.gesture = {
1026
+ ...g,
1027
+ current_angle: angle
1028
+ };
1029
+ deps.emitIntent({
1030
+ kind: "rotate",
1031
+ ids: g.ids,
1032
+ angle: angle - g.anchor_angle,
1033
+ phase: "preview"
1034
+ });
1035
+ response.needsRedraw = true;
1036
+ return response;
1037
+ }
1038
+ case "endpoint": {
1039
+ const g = this.gesture;
1040
+ this.gesture = {
1041
+ ...g,
1042
+ pos_doc: point_doc
1043
+ };
1044
+ deps.emitIntent({
1045
+ kind: "set_endpoint",
1046
+ id: g.id,
1047
+ endpoint: g.endpoint,
1048
+ pos: point_doc,
1049
+ phase: "preview"
1050
+ });
1051
+ response.needsRedraw = true;
1052
+ return response;
1053
+ }
1054
+ case "pan":
1055
+ this.gesture = {
1056
+ kind: "pan",
1057
+ prev_screen: [sx, sy]
1058
+ };
1059
+ return response;
1060
+ }
1061
+ return response;
1062
+ }
1063
+ onPointerDown(sx, sy, button, deps) {
1064
+ const response = emptyResponse();
1065
+ if (button !== "primary") return response;
1066
+ const point_doc = screenToDoc(this.transform, sx, sy);
1067
+ const screen = [sx, sy];
1068
+ const ui_action = this.hit_regions.hitTest(screen);
1069
+ const hovered_id = deps.pick(point_doc);
1070
+ const click_count = this.click_tracker.register(sx, sy);
1071
+ const decision = decidePointerDown({
1072
+ ui_action,
1073
+ hovered_id,
1074
+ selection_ids: this.selection_ids,
1075
+ modifiers: this.modifiers,
1076
+ click_count,
1077
+ readonly: this.readonly
1078
+ });
1079
+ switch (decision.kind) {
1080
+ case "noop": return response;
1081
+ case "start_resize":
1082
+ this.gesture = {
1083
+ kind: "resize",
1084
+ ids: [...decision.ids],
1085
+ direction: decision.direction,
1086
+ initial_rect: decision.initial_rect,
1087
+ anchor_doc: point_doc,
1088
+ current_rect: decision.initial_rect
1089
+ };
1090
+ response.needsRedraw = true;
1091
+ return response;
1092
+ case "start_rotate": {
1093
+ const [cx, cy] = _grida_cmath.default.rect.getCenter(decision.initial_rect);
1094
+ const angle = Math.atan2(point_doc[1] - cy, point_doc[0] - cx);
1095
+ this.gesture = {
1096
+ kind: "rotate",
1097
+ ids: [...decision.ids],
1098
+ corner: decision.corner,
1099
+ center_doc: [cx, cy],
1100
+ anchor_angle: angle,
1101
+ current_angle: angle
1102
+ };
1103
+ response.needsRedraw = true;
1104
+ return response;
1105
+ }
1106
+ case "start_endpoint": {
1107
+ const start = decision.endpoint === "p1" ? decision.p1 : decision.p2;
1108
+ this.gesture = {
1109
+ kind: "endpoint",
1110
+ id: decision.id,
1111
+ endpoint: decision.endpoint,
1112
+ pos_doc: [start[0], start[1]]
1113
+ };
1114
+ response.needsRedraw = true;
1115
+ return response;
1116
+ }
1117
+ case "enter_edit":
1118
+ deps.emitIntent({
1119
+ kind: "enter_content_edit",
1120
+ id: decision.id
1121
+ });
1122
+ this.pending = null;
1123
+ return response;
1124
+ case "immediate_select":
1125
+ deps.emitIntent({
1126
+ kind: "select",
1127
+ ids: [...decision.select_ids],
1128
+ mode: decision.mode
1129
+ });
1130
+ response.needsRedraw = true;
1131
+ this.pending = {
1132
+ anchor_doc: point_doc,
1133
+ anchor_screen: screen,
1134
+ ids_at_down: decision.pending.ids_at_down,
1135
+ deferred: decision.pending.deferred
1136
+ };
1137
+ return response;
1138
+ case "pend":
1139
+ this.pending = {
1140
+ anchor_doc: point_doc,
1141
+ anchor_screen: screen,
1142
+ ids_at_down: decision.pending.ids_at_down,
1143
+ deferred: decision.pending.deferred
1144
+ };
1145
+ return response;
1146
+ case "start_marquee_pend":
1147
+ if (decision.emit_deselect_all) {
1148
+ deps.emitIntent({ kind: "deselect_all" });
1149
+ response.needsRedraw = true;
1150
+ }
1151
+ this.pending = {
1152
+ anchor_doc: point_doc,
1153
+ anchor_screen: screen,
1154
+ ids_at_down: []
1155
+ };
1156
+ return response;
1157
+ }
1158
+ }
1159
+ onPointerUp(_sx, _sy, button, deps) {
1160
+ const response = emptyResponse();
1161
+ if (button !== "primary") return response;
1162
+ if (this.pending && this.pending.deferred) {
1163
+ const d = this.pending.deferred;
1164
+ deps.emitIntent({
1165
+ kind: "select",
1166
+ ids: [d.node_id],
1167
+ mode: d.shift ? "toggle" : "replace"
1168
+ });
1169
+ response.needsRedraw = true;
1170
+ }
1171
+ this.pending = null;
1172
+ switch (this.gesture.kind) {
1173
+ case "translate": {
1174
+ const g = this.gesture;
1175
+ const dx = g.last_doc[0] - g.anchor_doc[0];
1176
+ const dy = g.last_doc[1] - g.anchor_doc[1];
1177
+ deps.emitIntent({
1178
+ kind: "translate",
1179
+ ids: g.ids,
1180
+ dx,
1181
+ dy,
1182
+ phase: "commit"
1183
+ });
1184
+ this.gesture = IDLE;
1185
+ response.needsRedraw = true;
1186
+ if (cursorEquals(this.cursor, "move")) this.setCursor("default", response);
1187
+ break;
1188
+ }
1189
+ case "marquee": {
1190
+ const g = this.gesture;
1191
+ const rect = rectFromPoints(g.anchor_doc, g.current_doc);
1192
+ deps.emitIntent({
1193
+ kind: "marquee_select",
1194
+ rect,
1195
+ additive: this.modifiers.shift,
1196
+ phase: "commit"
1197
+ });
1198
+ this.gesture = IDLE;
1199
+ response.needsRedraw = true;
1200
+ break;
1201
+ }
1202
+ case "resize": {
1203
+ const g = this.gesture;
1204
+ deps.emitIntent({
1205
+ kind: "resize",
1206
+ ids: g.ids,
1207
+ anchor: g.direction,
1208
+ rect: g.current_rect,
1209
+ phase: "commit"
1210
+ });
1211
+ this.gesture = IDLE;
1212
+ response.needsRedraw = true;
1213
+ break;
1214
+ }
1215
+ case "rotate": {
1216
+ const g = this.gesture;
1217
+ deps.emitIntent({
1218
+ kind: "rotate",
1219
+ ids: g.ids,
1220
+ angle: g.current_angle - g.anchor_angle,
1221
+ phase: "commit"
1222
+ });
1223
+ this.gesture = IDLE;
1224
+ response.needsRedraw = true;
1225
+ break;
1226
+ }
1227
+ case "endpoint": {
1228
+ const g = this.gesture;
1229
+ deps.emitIntent({
1230
+ kind: "set_endpoint",
1231
+ id: g.id,
1232
+ endpoint: g.endpoint,
1233
+ pos: g.pos_doc,
1234
+ phase: "commit"
1235
+ });
1236
+ this.gesture = IDLE;
1237
+ response.needsRedraw = true;
1238
+ break;
1239
+ }
1240
+ }
1241
+ return response;
1242
+ }
1243
+ onBlur(deps) {
1244
+ const response = emptyResponse();
1245
+ this.pending = null;
1246
+ if (this.gesture.kind !== "idle") {
1247
+ deps.emitIntent({ kind: "cancel_gesture" });
1248
+ this.gesture = IDLE;
1249
+ response.needsRedraw = true;
1250
+ }
1251
+ return response;
1252
+ }
1253
+ setHover(id, response) {
1254
+ if (this.hover === id) return;
1255
+ const prev_eff = this.getEffectiveHover();
1256
+ this.hover = id;
1257
+ if (prev_eff !== this.getEffectiveHover()) {
1258
+ response.hoverChanged = true;
1259
+ response.needsRedraw = true;
1260
+ }
1261
+ }
1262
+ /**
1263
+ * Set or clear the host-driven hover override. Pass `null` to clear.
1264
+ * Returns a response indicating whether the *effective* hover changed —
1265
+ * the caller should redraw and notify subscribers if `hoverChanged`.
1266
+ */
1267
+ setHoverOverride(id) {
1268
+ const response = emptyResponse();
1269
+ if (this.hover_override === id) return response;
1270
+ const prev_eff = this.getEffectiveHover();
1271
+ this.hover_override = id;
1272
+ if (prev_eff !== this.getEffectiveHover()) {
1273
+ response.hoverChanged = true;
1274
+ response.needsRedraw = true;
1275
+ }
1276
+ return response;
1277
+ }
1278
+ setCursor(next, response) {
1279
+ if (!cursorEquals(this.cursor, next)) {
1280
+ this.cursor = next;
1281
+ response.cursorChanged = true;
1282
+ }
1283
+ }
1284
+ };
1285
+ //#endregion
1286
+ //#region surface/style.ts
1287
+ const DEFAULT_STYLE = {
1288
+ chromeColor: "#2563eb",
1289
+ hoverColor: "#60a5fa",
1290
+ handleSize: 8,
1291
+ handleFill: "#ffffff",
1292
+ handleStroke: "#2563eb",
1293
+ selectionOutlineWidth: 1,
1294
+ hoverOutlineWidth: 2,
1295
+ showRotationHandles: false
1296
+ };
1297
+ function mergeStyle(base, partial) {
1298
+ if (!partial) return base;
1299
+ return {
1300
+ ...base,
1301
+ ...partial
1302
+ };
1303
+ }
1304
+ //#endregion
1305
+ //#region event/shape.ts
1306
+ /**
1307
+ * Compute the axis-aligned bounding box of a `SelectionShape` in doc-space.
1308
+ * Used for layout math the chrome builder needs (e.g. handle positions).
1309
+ *
1310
+ * Throws on `kind: "unresolved"` — the chrome builder must resolve those
1311
+ * via `shapeOf` first.
1312
+ */
1313
+ function shapeBounds(shape) {
1314
+ if (shape.kind === "rect") return shape.rect;
1315
+ if (shape.kind === "line") return _grida_cmath.default.rect.fromPoints([shape.p1, shape.p2]);
1316
+ throw new Error(`shapeBounds: cannot bound unresolved shape (id=${shape.id})`);
1317
+ }
1318
+ //#endregion
1319
+ //#region event/overlay.ts
1320
+ /**
1321
+ * Minimum hit-target size in screen-px.
1322
+ *
1323
+ * Visual knobs are typically 8px, but the hit region is 16px so users don't
1324
+ * need pixel-perfect aim. Matches `MIN_HIT_SIZE` in the Rust overlay.
1325
+ */
1326
+ const MIN_HIT_SIZE = 16;
1327
+ /**
1328
+ * Below this selection size (in screen-px on either axis), chrome is
1329
+ * suppressed — both the visual handles AND the hit regions. Matches
1330
+ * `MIN_HANDLES_VISIBLE_SIZE` in the Rust overlay.
1331
+ */
1332
+ const MIN_CHROME_VISIBLE_SIZE = 12;
1333
+ //#endregion
1334
+ //#region surface/chrome.ts
1335
+ const CORNER_RESIZE_DIRECTIONS = [
1336
+ "nw",
1337
+ "ne",
1338
+ "se",
1339
+ "sw"
1340
+ ];
1341
+ const EDGE_RESIZE_DIRECTIONS = [
1342
+ "n",
1343
+ "e",
1344
+ "s",
1345
+ "w"
1346
+ ];
1347
+ const ROTATION_CORNERS = [
1348
+ "nw",
1349
+ "ne",
1350
+ "se",
1351
+ "sw"
1352
+ ];
1353
+ /**
1354
+ * Build the per-frame surface chrome.
1355
+ *
1356
+ * Returns a pair:
1357
+ * - `overlays` — interactable elements (handles, endpoint knobs, rotation
1358
+ * regions). Each pairs a hit shape with an optional render shape.
1359
+ * - `decoration` — pure visual `HUDDraw` (selection outlines, hover outline,
1360
+ * marquee, line outlines). Not interactable.
1361
+ *
1362
+ * The Surface fans `overlays` into `HitRegions` (for events) and merges
1363
+ * their render shapes into `decoration` (for the canvas draw call).
1364
+ */
1365
+ function buildChrome(input) {
1366
+ const { state, shapeOf, style } = input;
1367
+ const transform = state.getTransform();
1368
+ const overlays = [];
1369
+ const decoration_rects = [];
1370
+ const decoration_lines = [];
1371
+ const in_gesture = state.gesture.kind !== "idle";
1372
+ const hover_id = in_gesture ? null : state.getEffectiveHover();
1373
+ if (hover_id) {
1374
+ const shape = shapeOf(hover_id);
1375
+ if (shape) pushShapeOutline(shape, decoration_rects, decoration_lines, {
1376
+ dashed: false,
1377
+ strokeWidth: style.hoverOutlineWidth
1378
+ });
1379
+ }
1380
+ if (!in_gesture) for (const group of state.getSelectionGroups()) {
1381
+ const shape = resolveGroupShape(group, shapeOf);
1382
+ if (!shape) continue;
1383
+ pushShapeOutline(shape, decoration_rects, decoration_lines, {
1384
+ dashed: false,
1385
+ strokeWidth: style.selectionOutlineWidth
1386
+ });
1387
+ pushBodyHandle(shape, group.ids, transform, overlays);
1388
+ if (shape.kind === "rect") pushRectGroupHandles(shape.rect, group.ids, transform, style, overlays);
1389
+ else if (shape.kind === "line") pushLineEndpoints(group.ids[0], shape.p1, shape.p2, style, overlays);
1390
+ }
1391
+ if (state.gesture.kind === "marquee") {
1392
+ const g = state.gesture;
1393
+ const mr = rectFromPoints(g.anchor_doc, g.current_doc);
1394
+ decoration_rects.push({
1395
+ ...mr,
1396
+ stroke: true,
1397
+ fill: true,
1398
+ fillOpacity: .15
1399
+ });
1400
+ }
1401
+ if (state.gesture.kind === "resize") decoration_rects.push({
1402
+ ...state.gesture.current_rect,
1403
+ stroke: true,
1404
+ fill: false,
1405
+ dashed: true
1406
+ });
1407
+ return {
1408
+ overlays,
1409
+ decoration: {
1410
+ rects: decoration_rects.length > 0 ? decoration_rects : void 0,
1411
+ lines: decoration_lines.length > 0 ? decoration_lines : void 0
1412
+ }
1413
+ };
1414
+ }
1415
+ function resolveGroupShape(group, shapeOf) {
1416
+ if (group.shape.kind === "unresolved") return shapeOf(group.shape.id);
1417
+ return group.shape;
1418
+ }
1419
+ function pushBodyHandle(shape, ids, transform, out) {
1420
+ const bounds_doc = shapeBounds(shape);
1421
+ const rect_screen = _grida_cmath.default.rect.transform(bounds_doc, transform);
1422
+ if (rect_screen.width < 1 && rect_screen.height < 1) return;
1423
+ out.push({
1424
+ action: {
1425
+ kind: "translate_handle",
1426
+ ids
1427
+ },
1428
+ hit: {
1429
+ kind: "screen_aabb",
1430
+ rect: rect_screen
1431
+ },
1432
+ cursor: "move"
1433
+ });
1434
+ }
1435
+ function pushRectGroupHandles(rect_doc, ids, transform, style, out) {
1436
+ const rect_screen = _grida_cmath.default.rect.transform(rect_doc, transform);
1437
+ if (rect_screen.width < 12 && rect_screen.height < 12) return;
1438
+ const size = style.handleSize;
1439
+ const hit_size = Math.max(size + 4, 16);
1440
+ const anchors_doc = cornerAnchors(rect_doc);
1441
+ for (const dir of CORNER_RESIZE_DIRECTIONS) {
1442
+ const anchor_doc = anchors_doc[dir];
1443
+ out.push({
1444
+ action: {
1445
+ kind: "resize_handle",
1446
+ direction: dir,
1447
+ ids,
1448
+ initial_rect: rect_doc
1449
+ },
1450
+ hit: {
1451
+ kind: "screen_rect_at_doc",
1452
+ anchor_doc,
1453
+ width: hit_size,
1454
+ height: hit_size,
1455
+ placement: "center"
1456
+ },
1457
+ render: {
1458
+ kind: "screen_rect",
1459
+ anchor_doc,
1460
+ width: size,
1461
+ height: size,
1462
+ placement: "center",
1463
+ fill: true,
1464
+ stroke: true,
1465
+ fillColor: style.handleFill,
1466
+ strokeColor: style.handleStroke
1467
+ },
1468
+ cursor: {
1469
+ kind: "resize",
1470
+ direction: dir
1471
+ }
1472
+ });
1473
+ }
1474
+ const edge_strips = edgeStripsScreen(rect_screen, hit_size);
1475
+ for (const dir of EDGE_RESIZE_DIRECTIONS) {
1476
+ const strip = edge_strips[dir];
1477
+ out.push({
1478
+ action: {
1479
+ kind: "resize_handle",
1480
+ direction: dir,
1481
+ ids,
1482
+ initial_rect: rect_doc
1483
+ },
1484
+ hit: {
1485
+ kind: "screen_aabb",
1486
+ rect: strip
1487
+ },
1488
+ cursor: {
1489
+ kind: "resize",
1490
+ direction: dir
1491
+ }
1492
+ });
1493
+ }
1494
+ if (style.showRotationHandles && ids.length === 1) {
1495
+ const rot_size = 16;
1496
+ for (const corner of ROTATION_CORNERS) {
1497
+ const anchor_doc = anchors_doc[corner];
1498
+ const offset_screen = rotationOffsetScreen(corner);
1499
+ const [ax, ay] = docToScreen(transform, anchor_doc[0], anchor_doc[1]);
1500
+ out.push({
1501
+ action: {
1502
+ kind: "rotate_handle",
1503
+ corner,
1504
+ ids,
1505
+ initial_rect: rect_doc
1506
+ },
1507
+ hit: {
1508
+ kind: "screen_aabb",
1509
+ rect: {
1510
+ x: ax + offset_screen[0] - rot_size / 2,
1511
+ y: ay + offset_screen[1] - rot_size / 2,
1512
+ width: rot_size,
1513
+ height: rot_size
1514
+ }
1515
+ },
1516
+ cursor: {
1517
+ kind: "rotate",
1518
+ corner
1519
+ }
1520
+ });
1521
+ }
1522
+ }
1523
+ }
1524
+ function cornerAnchors(r) {
1525
+ return {
1526
+ nw: [r.x, r.y],
1527
+ n: [r.x + r.width / 2, r.y],
1528
+ ne: [r.x + r.width, r.y],
1529
+ e: [r.x + r.width, r.y + r.height / 2],
1530
+ se: [r.x + r.width, r.y + r.height],
1531
+ s: [r.x + r.width / 2, r.y + r.height],
1532
+ sw: [r.x, r.y + r.height],
1533
+ w: [r.x, r.y + r.height / 2]
1534
+ };
1535
+ }
1536
+ /**
1537
+ * Compute the 4 screen-space AABB strips between corner knobs for virtual
1538
+ * edge resize regions. Each strip is `thickness` thick, inset from the
1539
+ * corners by half the strip thickness so it doesn't overlap corner hits.
1540
+ */
1541
+ function edgeStripsScreen(rect_screen, thickness) {
1542
+ const { x, y, width, height } = rect_screen;
1543
+ const inset = thickness / 2;
1544
+ const half = thickness / 2;
1545
+ return {
1546
+ n: {
1547
+ x: x + inset,
1548
+ y: y - half,
1549
+ width: Math.max(0, width - inset * 2),
1550
+ height: thickness
1551
+ },
1552
+ s: {
1553
+ x: x + inset,
1554
+ y: y + height - half,
1555
+ width: Math.max(0, width - inset * 2),
1556
+ height: thickness
1557
+ },
1558
+ e: {
1559
+ x: x + width - half,
1560
+ y: y + inset,
1561
+ width: thickness,
1562
+ height: Math.max(0, height - inset * 2)
1563
+ },
1564
+ w: {
1565
+ x: x - half,
1566
+ y: y + inset,
1567
+ width: thickness,
1568
+ height: Math.max(0, height - inset * 2)
1569
+ }
1570
+ };
1571
+ }
1572
+ function rotationOffsetScreen(corner) {
1573
+ switch (corner) {
1574
+ case "nw": return [-12, -12];
1575
+ case "ne": return [12, -12];
1576
+ case "se": return [12, 12];
1577
+ case "sw": return [-12, 12];
1578
+ }
1579
+ }
1580
+ function pushLineEndpoints(id, p1, p2, style, out) {
1581
+ const size = style.handleSize;
1582
+ const hit_size = Math.max(size + 4, 16);
1583
+ const endpoints = [{
1584
+ which: "p1",
1585
+ pos: p1
1586
+ }, {
1587
+ which: "p2",
1588
+ pos: p2
1589
+ }];
1590
+ for (const ep of endpoints) out.push({
1591
+ action: {
1592
+ kind: "endpoint_handle",
1593
+ endpoint: ep.which,
1594
+ id,
1595
+ p1: [p1[0], p1[1]],
1596
+ p2: [p2[0], p2[1]]
1597
+ },
1598
+ hit: {
1599
+ kind: "screen_rect_at_doc",
1600
+ anchor_doc: ep.pos,
1601
+ width: hit_size,
1602
+ height: hit_size,
1603
+ placement: "center"
1604
+ },
1605
+ render: {
1606
+ kind: "screen_rect",
1607
+ anchor_doc: ep.pos,
1608
+ width: size,
1609
+ height: size,
1610
+ placement: "center",
1611
+ fill: true,
1612
+ stroke: true,
1613
+ fillColor: style.handleFill,
1614
+ strokeColor: style.handleStroke
1615
+ },
1616
+ cursor: "pointer"
1617
+ });
1618
+ }
1619
+ function pushShapeOutline(shape, rects, lines, opts) {
1620
+ if (shape.kind === "rect") rects.push({
1621
+ ...shape.rect,
1622
+ stroke: true,
1623
+ fill: false,
1624
+ dashed: opts.dashed,
1625
+ strokeWidth: opts.strokeWidth
1626
+ });
1627
+ else if (shape.kind === "line") lines.push({
1628
+ x1: shape.p1[0],
1629
+ y1: shape.p1[1],
1630
+ x2: shape.p2[0],
1631
+ y2: shape.p2[1],
1632
+ dashed: opts.dashed,
1633
+ strokeWidth: opts.strokeWidth
1634
+ });
1635
+ }
1636
+ /**
1637
+ * Fan a list of `OverlayElement`s into per-primitive render arrays and into
1638
+ * the hit-region registry. Returns the additional render primitives that
1639
+ * should be merged with the decoration `HUDDraw`.
1640
+ */
1641
+ function fanOverlays(overlays, transform, regions) {
1642
+ regions.clear();
1643
+ const screenRects = [];
1644
+ for (const el of overlays) {
1645
+ regions.push({
1646
+ rect: projectHitAABB(el.hit, transform),
1647
+ action: el.action
1648
+ });
1649
+ if (!el.render) continue;
1650
+ if (el.render.kind === "screen_rect") screenRects.push({
1651
+ x: el.render.anchor_doc[0],
1652
+ y: el.render.anchor_doc[1],
1653
+ width: el.render.width,
1654
+ height: el.render.height,
1655
+ anchor: el.render.placement,
1656
+ fill: el.render.fill,
1657
+ stroke: el.render.stroke,
1658
+ fillColor: el.render.fillColor,
1659
+ strokeColor: el.render.strokeColor
1660
+ });
1661
+ }
1662
+ return { screenRects };
1663
+ }
1664
+ function projectHitAABB(hit, transform) {
1665
+ if (hit.kind === "screen_aabb") return hit.rect;
1666
+ const [sx, sy] = docToScreen(transform, hit.anchor_doc[0], hit.anchor_doc[1]);
1667
+ const placement = hit.placement ?? "center";
1668
+ let x = sx;
1669
+ let y = sy;
1670
+ switch (placement) {
1671
+ case "center":
1672
+ x = sx - hit.width / 2;
1673
+ y = sy - hit.height / 2;
1674
+ break;
1675
+ case "tl":
1676
+ x = sx;
1677
+ y = sy;
1678
+ break;
1679
+ case "tr":
1680
+ x = sx - hit.width;
1681
+ y = sy;
1682
+ break;
1683
+ case "bl":
1684
+ x = sx;
1685
+ y = sy - hit.height;
1686
+ break;
1687
+ case "br":
1688
+ x = sx - hit.width;
1689
+ y = sy - hit.height;
1690
+ break;
1691
+ }
1692
+ return {
1693
+ x,
1694
+ y,
1695
+ width: hit.width,
1696
+ height: hit.height
1697
+ };
1698
+ }
1699
+ /**
1700
+ * Merge a base `HUDDraw` (chrome decoration) with optional host-fed extras
1701
+ * and surface-owned interactable screenRects into a single command list.
1702
+ */
1703
+ function mergeDraws(base, extra, screenRects) {
1704
+ return {
1705
+ lines: cat(base.lines, extra?.lines),
1706
+ rules: cat(base.rules, extra?.rules),
1707
+ points: cat(base.points, extra?.points),
1708
+ rects: cat(base.rects, extra?.rects),
1709
+ polylines: cat(base.polylines, extra?.polylines),
1710
+ screenRects: cat(cat(base.screenRects, extra?.screenRects), screenRects)
1711
+ };
1712
+ }
1713
+ /**
1714
+ * Concatenate two optional arrays. Returns the non-empty side directly when
1715
+ * the other is empty — no defensive copy on the hot draw path.
1716
+ */
1717
+ function cat(a, b) {
1718
+ if (!b || b.length === 0) return a;
1719
+ if (!a || a.length === 0) return b;
1720
+ return [...a, ...b];
1721
+ }
1722
+ //#endregion
1723
+ //#region surface/surface.ts
1724
+ /**
1725
+ * Top-level wired surface.
1726
+ *
1727
+ * Owns an internal `HUDCanvas`, a `SurfaceState` (gesture/hover/...) and
1728
+ * the host providers. On every `dispatch`, the state machine runs;
1729
+ * `draw` composes surface chrome + host-fed extras into a single canvas
1730
+ * paint.
1731
+ */
1732
+ var Surface = class {
1733
+ constructor(canvas, options) {
1734
+ this.width = 0;
1735
+ this.height = 0;
1736
+ this.opts = options;
1737
+ this.style = mergeStyle(DEFAULT_STYLE, options.style);
1738
+ this.hudCanvas = new HUDCanvas(canvas, { color: options.color ?? this.style.chromeColor });
1739
+ this.state = new SurfaceState();
1740
+ if (options.readonly !== void 0) this.state.setReadonly(options.readonly);
1741
+ }
1742
+ setSize(w, h) {
1743
+ this.width = w;
1744
+ this.height = h;
1745
+ this.hudCanvas.setSize(w, h);
1746
+ }
1747
+ setTransform(t) {
1748
+ this.state.setTransform(t);
1749
+ this.hudCanvas.setTransform(t);
1750
+ }
1751
+ /**
1752
+ * Push a new selection from the host.
1753
+ *
1754
+ * Accepts either:
1755
+ * - `NodeId[]` — each id becomes its own single-member group, shape
1756
+ * resolved via `shapeOf(id)` by the chrome builder.
1757
+ * - `SelectionGroup[]` — pre-computed groups with their union shape.
1758
+ *
1759
+ * See `SurfaceState.setSelection` for details.
1760
+ */
1761
+ setSelection(input) {
1762
+ this.state.setSelection(input);
1763
+ }
1764
+ setStyle(partial) {
1765
+ this.style = mergeStyle(this.style, partial);
1766
+ this.hudCanvas.setColor(this.style.chromeColor);
1767
+ }
1768
+ setReadonly(v) {
1769
+ this.state.setReadonly(v);
1770
+ }
1771
+ /**
1772
+ * Set or clear a host-driven hover override.
1773
+ *
1774
+ * The surface tracks two hover sources:
1775
+ * - **Pointer pick** — what scene content is under the cursor (updated
1776
+ * automatically on `pointer_move`).
1777
+ * - **Host override** — what the host wants to show as hovered, e.g.
1778
+ * from a layers panel row mouseenter.
1779
+ *
1780
+ * The override (when non-null) wins. `hover()` returns the effective
1781
+ * value; chrome renders the effective value. Pass `null` to clear and
1782
+ * fall back to pointer pick.
1783
+ *
1784
+ * Returns the same response shape as `dispatch` so the host can react
1785
+ * to whether anything actually changed.
1786
+ */
1787
+ setHoverOverride(id) {
1788
+ return this.state.setHoverOverride(id);
1789
+ }
1790
+ dispose() {}
1791
+ dispatch(event) {
1792
+ return this.state.dispatch(event, {
1793
+ pick: this.opts.pick,
1794
+ shapeOf: this.opts.shapeOf,
1795
+ emitIntent: this.opts.onIntent
1796
+ });
1797
+ }
1798
+ draw(extra) {
1799
+ const { overlays, decoration } = buildChrome({
1800
+ state: this.state,
1801
+ shapeOf: this.opts.shapeOf,
1802
+ style: this.style,
1803
+ width: this.width,
1804
+ height: this.height
1805
+ });
1806
+ const { screenRects } = fanOverlays(overlays, this.state.getTransform(), this.state.hitRegions());
1807
+ this.hudCanvas.draw(mergeDraws(decoration, extra, screenRects));
1808
+ }
1809
+ /** Convenience: clear the canvas (e.g. when the host stops the surface). */
1810
+ clear() {
1811
+ this.hudCanvas.draw(void 0);
1812
+ }
1813
+ gesture() {
1814
+ return this.state.gesture;
1815
+ }
1816
+ /**
1817
+ * The effective hover: host override (when set) wins over pointer pick.
1818
+ * Use this for chrome decisions and host-side reads.
1819
+ */
1820
+ hover() {
1821
+ return this.state.getEffectiveHover();
1822
+ }
1823
+ cursor() {
1824
+ return this.state.cursor;
1825
+ }
1826
+ modifiers() {
1827
+ return this.state.modifiers;
1828
+ }
1829
+ };
1830
+ //#endregion
1831
+ Object.defineProperty(exports, "HUDCanvas", {
1832
+ enumerable: true,
1833
+ get: function() {
1834
+ return HUDCanvas;
1835
+ }
1836
+ });
1837
+ Object.defineProperty(exports, "MIN_CHROME_VISIBLE_SIZE", {
1838
+ enumerable: true,
1839
+ get: function() {
1840
+ return MIN_CHROME_VISIBLE_SIZE;
1841
+ }
1842
+ });
1843
+ Object.defineProperty(exports, "MIN_HIT_SIZE", {
1844
+ enumerable: true,
1845
+ get: function() {
1846
+ return MIN_HIT_SIZE;
1847
+ }
1848
+ });
1849
+ Object.defineProperty(exports, "NO_MODS", {
1850
+ enumerable: true,
1851
+ get: function() {
1852
+ return NO_MODS;
1853
+ }
1854
+ });
1855
+ Object.defineProperty(exports, "Surface", {
1856
+ enumerable: true,
1857
+ get: function() {
1858
+ return Surface;
1859
+ }
1860
+ });
1861
+ Object.defineProperty(exports, "__toESM", {
1862
+ enumerable: true,
1863
+ get: function() {
1864
+ return __toESM;
1865
+ }
1866
+ });
1867
+ Object.defineProperty(exports, "lassoToHUDDraw", {
1868
+ enumerable: true,
1869
+ get: function() {
1870
+ return lassoToHUDDraw;
1871
+ }
1872
+ });
1873
+ Object.defineProperty(exports, "marqueeToHUDDraw", {
1874
+ enumerable: true,
1875
+ get: function() {
1876
+ return marqueeToHUDDraw;
1877
+ }
1878
+ });
1879
+ Object.defineProperty(exports, "measurementToHUDDraw", {
1880
+ enumerable: true,
1881
+ get: function() {
1882
+ return measurementToHUDDraw;
1883
+ }
1884
+ });
1885
+ Object.defineProperty(exports, "snapGuideToHUDDraw", {
1886
+ enumerable: true,
1887
+ get: function() {
1888
+ return snapGuideToHUDDraw;
1889
+ }
1890
+ });