@grida/hud 0.1.0 → 0.2.1

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