@grida/hud 0.2.0 → 0.2.2

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.
Files changed (38) hide show
  1. package/README.md +562 -37
  2. package/dist/core/index.d.mts +181 -0
  3. package/dist/core/index.d.ts +181 -0
  4. package/dist/core/index.js +301 -0
  5. package/dist/core/index.mjs +291 -0
  6. package/dist/cursors/index.d.mts +1 -1
  7. package/dist/cursors/index.d.ts +1 -1
  8. package/dist/cursors/index.js +1 -1
  9. package/dist/cursors/index.mjs +1 -1
  10. package/dist/index-BQtDtpHM.d.mts +3215 -0
  11. package/dist/index-BlfZbeEJ.d.ts +3215 -0
  12. package/dist/index.d.mts +4 -3
  13. package/dist/index.d.ts +4 -3
  14. package/dist/index.js +55 -2
  15. package/dist/index.mjs +3 -3
  16. package/dist/overlay-CVV4s3IL.d.ts +241 -0
  17. package/dist/overlay-dsG32baA.d.mts +241 -0
  18. package/dist/primitives/bedrock.d.mts +47 -0
  19. package/dist/primitives/bedrock.d.ts +47 -0
  20. package/dist/primitives/bedrock.js +71 -0
  21. package/dist/primitives/bedrock.mjs +65 -0
  22. package/dist/react.d.mts +3 -2
  23. package/dist/react.d.ts +2 -1
  24. package/dist/react.js +4 -1
  25. package/dist/react.mjs +4 -1
  26. package/dist/surface-BHDH6P6p.js +7383 -0
  27. package/dist/surface-B_8w6VWG.mjs +6929 -0
  28. package/dist/types-3wwFisZs.d.mts +296 -0
  29. package/dist/types-3wwFisZs.d.ts +296 -0
  30. package/package.json +16 -3
  31. package/dist/index-Cp0X4SV7.d.ts +0 -947
  32. package/dist/index-DhGdcuQz.d.mts +0 -947
  33. package/dist/surface-BvMmXoEl.mjs +0 -2471
  34. package/dist/surface-ofSNTJ8H.js +0 -2607
  35. /package/dist/{cursor-BFGUuD2M.d.mts → cursor-CxS8EMvm.d.mts} +0 -0
  36. /package/dist/{cursor-CIYvFshz.d.ts → cursor-CxS8EMvm.d.ts} +0 -0
  37. /package/dist/{cursor-BieMVb71.mjs → cursor-DW-uAPVE.mjs} +0 -0
  38. /package/dist/{cursor-DsP9qtN2.js → cursor-FGiJBdU-.js} +0 -0
@@ -1,2471 +0,0 @@
1
- import { i as cursorToCss, r as cursorEquals } from "./cursor-BieMVb71.mjs";
2
- import { auxiliary_line_xylr, guide_line_xylr } from "@grida/cmath/_measurement";
3
- import cmath from "@grida/cmath";
4
- //#region primitives/pixel-grid.ts
5
- const DEFAULT_PIXEL_GRID_COLOR = "rgba(150, 150, 150, 0.15)";
6
- const DEFAULT_PIXEL_GRID_STEPS = [1, 1];
7
- function drawPixelGrid(p) {
8
- const { ctx, transform, width, height, dpr, color = DEFAULT_PIXEL_GRID_COLOR, steps = DEFAULT_PIXEL_GRID_STEPS } = p;
9
- ctx.save();
10
- const [[sx, , tx], [, sy, ty]] = transform;
11
- ctx.setTransform(sx * dpr, 0, 0, sy * dpr, tx * dpr, ty * dpr);
12
- ctx.strokeStyle = color;
13
- ctx.lineWidth = 1 / Math.max(Math.abs(sx * dpr), Math.abs(sy * dpr));
14
- const minUserX = (0 - tx * dpr) / (sx * dpr);
15
- const maxUserX = (width * dpr - tx * dpr) / (sx * dpr);
16
- const minUserY = (0 - ty * dpr) / (sy * dpr);
17
- const maxUserY = (height * dpr - ty * dpr) / (sy * dpr);
18
- const [stepX, stepY] = steps;
19
- const startX = Math.floor(minUserX / stepX) * stepX - 2 * stepX;
20
- const endX = Math.ceil(maxUserX / stepX) * stepX + 2 * stepX;
21
- const startY = Math.floor(minUserY / stepY) * stepY - 2 * stepY;
22
- const endY = Math.ceil(maxUserY / stepY) * stepY + 2 * stepY;
23
- ctx.beginPath();
24
- for (let x = startX; x <= endX; x += stepX) {
25
- ctx.moveTo(x, startY);
26
- ctx.lineTo(x, endY);
27
- }
28
- for (let y = startY; y <= endY; y += stepY) {
29
- ctx.moveTo(startX, y);
30
- ctx.lineTo(endX, y);
31
- }
32
- ctx.stroke();
33
- ctx.restore();
34
- }
35
- //#endregion
36
- //#region primitives/canvas.ts
37
- const DEFAULT_COLOR = "#f44336";
38
- const DEFAULT_LABEL_FG = "#ffffff";
39
- const DEFAULT_LINE_WIDTH = .5;
40
- const CROSSHAIR_SIZE = 4;
41
- const LABEL_FONT = "10px sans-serif";
42
- const LABEL_FONT_HEIGHT = 14;
43
- const LABEL_PADDING_X = 4;
44
- const LABEL_PADDING_Y = 2;
45
- const LABEL_BORDER_RADIUS = 4;
46
- const LABEL_OFFSET = 16;
47
- const SCREEN_RECT_LINE_WIDTH = 1;
48
- /**
49
- * Imperative Canvas 2D renderer for the HUD overlay.
50
- *
51
- * Owns a single `<canvas>` element and draws {@link HUDDraw} command lists
52
- * each frame. All drawing is immediate-mode: the canvas is cleared and
53
- * fully redrawn on every `draw()` call.
54
- *
55
- * The viewport transform is assumed to be axis-aligned (scale + translate only,
56
- * no rotation/shear). The off-diagonal components of the transform matrix are
57
- * ignored.
58
- */
59
- var HUDCanvas = class {
60
- constructor(canvas, options) {
61
- this.canvas = canvas;
62
- this.transform = [[
63
- 1,
64
- 0,
65
- 0
66
- ], [
67
- 0,
68
- 1,
69
- 0
70
- ]];
71
- this.width = 0;
72
- this.height = 0;
73
- this.pixelGrid = null;
74
- this.ctx = canvas.getContext("2d");
75
- this.dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
76
- this.color = options?.color ?? DEFAULT_COLOR;
77
- }
78
- setColor(color) {
79
- this.color = color ?? DEFAULT_COLOR;
80
- }
81
- setSize(w, h) {
82
- const dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
83
- if (this.width === w && this.height === h && this.dpr === dpr) return;
84
- this.dpr = dpr;
85
- this.width = w;
86
- this.height = h;
87
- this.canvas.width = w * this.dpr;
88
- this.canvas.height = h * this.dpr;
89
- this.canvas.style.width = `${w}px`;
90
- this.canvas.style.height = `${h}px`;
91
- }
92
- setTransform(transform) {
93
- this.transform = transform;
94
- }
95
- /**
96
- * Configure the back-most pixel-grid layer. Pass `null` to disable.
97
- * Drawn before any HUD primitive, gated by `zoomThreshold`. See
98
- * `PixelGridConfig.transform` for the two-transform contract.
99
- */
100
- setPixelGrid(config) {
101
- if (config === null) {
102
- this.pixelGrid = null;
103
- return;
104
- }
105
- this.pixelGrid = {
106
- ...config,
107
- transform: config.transform ?? this.pixelGrid?.transform
108
- };
109
- }
110
- /**
111
- * Update only the pixel grid's transform, without replacing the rest of
112
- * the config. Cheap to call per camera tick.
113
- */
114
- setPixelGridTransform(transform) {
115
- if (this.pixelGrid) this.pixelGrid = {
116
- ...this.pixelGrid,
117
- transform
118
- };
119
- }
120
- /**
121
- * Clear the canvas and draw all primitives in `commands`.
122
- * Pass `undefined` to clear without drawing (e.g. when no overlay is active).
123
- */
124
- draw(commands) {
125
- const ctx = this.ctx;
126
- ctx.setTransform(1, 0, 0, 1, 0, 0);
127
- ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
128
- const pg = this.pixelGrid;
129
- const pgTransform = pg?.transform ?? this.transform;
130
- if (pg?.enabled && pgTransform[0][0] > pg.zoomThreshold) drawPixelGrid({
131
- ctx,
132
- transform: pgTransform,
133
- width: this.width,
134
- height: this.height,
135
- dpr: this.dpr,
136
- color: pg.color,
137
- steps: pg.steps
138
- });
139
- if (!commands) return;
140
- const { lines, rules, points, rects, polylines, screenRects } = commands;
141
- if (rules && rules.length > 0) this.drawRules(rules);
142
- if (rects && rects.length > 0) this.drawRects(rects);
143
- if (polylines && polylines.length > 0) this.drawPolylines(polylines);
144
- if (lines && lines.length > 0) this.drawLines(lines);
145
- if (points && points.length > 0) this.drawPoints(points);
146
- if (screenRects && screenRects.length > 0) this.drawScreenRects(screenRects);
147
- }
148
- applyViewTransform() {
149
- const [[sx, , tx], [, sy, ty]] = this.transform;
150
- this.ctx.setTransform(sx * this.dpr, 0, 0, sy * this.dpr, tx * this.dpr, ty * this.dpr);
151
- }
152
- applyScreenTransform() {
153
- this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
154
- }
155
- /** Project a scalar offset on `axis` to screen-space. */
156
- deltaToScreen(offset, axis) {
157
- const i = axis === "x" ? 0 : 1;
158
- const row = this.transform[i];
159
- return row[i] * offset + row[2];
160
- }
161
- drawRules(rules) {
162
- const ctx = this.ctx;
163
- this.applyScreenTransform();
164
- ctx.lineWidth = DEFAULT_LINE_WIDTH;
165
- for (const rule of rules) {
166
- const screenOffset = this.deltaToScreen(rule.offset, rule.axis);
167
- ctx.strokeStyle = rule.color ?? this.color;
168
- ctx.beginPath();
169
- if (rule.axis === "x") {
170
- ctx.moveTo(screenOffset, 0);
171
- ctx.lineTo(screenOffset, this.height);
172
- } else {
173
- ctx.moveTo(0, screenOffset);
174
- ctx.lineTo(this.width, screenOffset);
175
- }
176
- ctx.stroke();
177
- }
178
- }
179
- drawLines(lines) {
180
- const ctx = this.ctx;
181
- const zoom = this.transform[0][0];
182
- const [[sx, , tx], [, sy, ty]] = this.transform;
183
- this.applyViewTransform();
184
- ctx.strokeStyle = this.color;
185
- ctx.lineWidth = DEFAULT_LINE_WIDTH / zoom;
186
- let dashed = false;
187
- let currentWidth = DEFAULT_LINE_WIDTH;
188
- let currentColor = this.color;
189
- for (const line of lines) {
190
- if (line.dashed && !dashed) {
191
- ctx.setLineDash([4 / zoom, 3 / zoom]);
192
- dashed = true;
193
- } else if (!line.dashed && dashed) {
194
- ctx.setLineDash([]);
195
- dashed = false;
196
- }
197
- const w = line.strokeWidth ?? DEFAULT_LINE_WIDTH;
198
- if (w !== currentWidth) {
199
- ctx.lineWidth = w / zoom;
200
- currentWidth = w;
201
- }
202
- const c = line.color ?? this.color;
203
- if (c !== currentColor) {
204
- ctx.strokeStyle = c;
205
- currentColor = c;
206
- }
207
- ctx.beginPath();
208
- ctx.moveTo(line.x1, line.y1);
209
- ctx.lineTo(line.x2, line.y2);
210
- ctx.stroke();
211
- }
212
- if (dashed) ctx.setLineDash([]);
213
- this.applyScreenTransform();
214
- ctx.font = LABEL_FONT;
215
- ctx.textAlign = "center";
216
- ctx.textBaseline = "middle";
217
- for (const line of lines) {
218
- if (!line.label) continue;
219
- const midX = (line.x1 + line.x2) / 2;
220
- const midY = (line.y1 + line.y2) / 2;
221
- const lx = sx * midX + tx;
222
- const ly = sy * midY + ty;
223
- const angle = line.labelAngle ?? 0;
224
- const isVertical = Math.abs(line.x2 - line.x1) < Math.abs(line.y2 - line.y1);
225
- const baseOffsetX = isVertical ? LABEL_OFFSET : 0;
226
- const baseOffsetY = isVertical ? 0 : LABEL_OFFSET;
227
- const cos = Math.cos(angle);
228
- const sin = Math.sin(angle);
229
- const labelX = lx + baseOffsetX * cos - baseOffsetY * sin;
230
- const labelY = ly + baseOffsetX * sin + baseOffsetY * cos;
231
- const tw = ctx.measureText(line.label).width + LABEL_PADDING_X * 2;
232
- const th = LABEL_FONT_HEIGHT + LABEL_PADDING_Y * 2;
233
- if (angle !== 0) {
234
- ctx.save();
235
- ctx.translate(labelX, labelY);
236
- ctx.rotate(angle);
237
- ctx.translate(-labelX, -labelY);
238
- }
239
- ctx.fillStyle = line.color ?? this.color;
240
- ctx.beginPath();
241
- ctx.roundRect(labelX - tw / 2, labelY - th / 2, tw, th, LABEL_BORDER_RADIUS);
242
- ctx.fill();
243
- ctx.fillStyle = DEFAULT_LABEL_FG;
244
- ctx.fillText(line.label, labelX, labelY);
245
- if (angle !== 0) ctx.restore();
246
- }
247
- }
248
- drawRects(rects) {
249
- const ctx = this.ctx;
250
- const zoom = this.transform[0][0];
251
- this.applyViewTransform();
252
- ctx.lineWidth = DEFAULT_LINE_WIDTH / zoom;
253
- let currentWidth = DEFAULT_LINE_WIDTH;
254
- for (const rect of rects) {
255
- const doStroke = rect.stroke !== false;
256
- const doFill = rect.fill === true;
257
- const color = rect.color ?? this.color;
258
- if (rect.dashed) ctx.setLineDash([4 / zoom, 3 / zoom]);
259
- if (doFill) {
260
- ctx.globalAlpha = rect.fillOpacity ?? 1;
261
- ctx.fillStyle = color;
262
- ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
263
- ctx.globalAlpha = 1;
264
- }
265
- if (doStroke) {
266
- const w = rect.strokeWidth ?? DEFAULT_LINE_WIDTH;
267
- if (w !== currentWidth) {
268
- ctx.lineWidth = w / zoom;
269
- currentWidth = w;
270
- }
271
- ctx.strokeStyle = color;
272
- ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
273
- }
274
- if (rect.dashed) ctx.setLineDash([]);
275
- }
276
- }
277
- drawPolylines(polylines) {
278
- const ctx = this.ctx;
279
- const zoom = this.transform[0][0];
280
- this.applyViewTransform();
281
- ctx.lineWidth = DEFAULT_LINE_WIDTH / zoom;
282
- for (const poly of polylines) {
283
- if (poly.points.length < 2) continue;
284
- ctx.beginPath();
285
- ctx.moveTo(poly.points[0][0], poly.points[0][1]);
286
- for (let i = 1; i < poly.points.length; i++) ctx.lineTo(poly.points[i][0], poly.points[i][1]);
287
- const doFill = poly.fill === true;
288
- const doStroke = poly.stroke !== false;
289
- const color = poly.color ?? this.color;
290
- if (doFill) {
291
- ctx.closePath();
292
- ctx.globalAlpha = poly.fillOpacity ?? 1;
293
- ctx.fillStyle = color;
294
- ctx.fill("evenodd");
295
- ctx.globalAlpha = 1;
296
- }
297
- if (doStroke) {
298
- if (poly.dashed) ctx.setLineDash([4 / zoom, 3 / zoom]);
299
- ctx.strokeStyle = color;
300
- ctx.stroke();
301
- if (poly.dashed) ctx.setLineDash([]);
302
- }
303
- }
304
- }
305
- drawPoints(points) {
306
- const ctx = this.ctx;
307
- this.applyScreenTransform();
308
- ctx.lineWidth = DEFAULT_LINE_WIDTH;
309
- const half = CROSSHAIR_SIZE / 2;
310
- const [[sx, , tx], [, sy, ty]] = this.transform;
311
- const buckets = /* @__PURE__ */ new Map();
312
- for (const p of points) {
313
- const c = p.color ?? this.color;
314
- const arr = buckets.get(c);
315
- if (arr) arr.push(p);
316
- else buckets.set(c, [p]);
317
- }
318
- for (const [color, group] of buckets) {
319
- ctx.strokeStyle = color;
320
- ctx.beginPath();
321
- for (const p of group) {
322
- const scrX = sx * p.x + tx;
323
- const scrY = sy * p.y + ty;
324
- ctx.moveTo(scrX - half, scrY - half);
325
- ctx.lineTo(scrX + half, scrY + half);
326
- ctx.moveTo(scrX + half, scrY - half);
327
- ctx.lineTo(scrX - half, scrY + half);
328
- }
329
- ctx.stroke();
330
- }
331
- }
332
- /**
333
- * Draw rects whose **size is in screen-space** but whose **anchor is in
334
- * document-space**. The doc-space point is projected via the current
335
- * transform; the rect is then drawn at fixed CSS-pixel dimensions.
336
- *
337
- * This is the primitive used to draw resize / rotate handles — they must
338
- * remain a constant visual size regardless of viewport zoom.
339
- */
340
- drawScreenRects(rects) {
341
- const ctx = this.ctx;
342
- const [[sx, , tx], [, sy, ty]] = this.transform;
343
- this.applyScreenTransform();
344
- ctx.lineWidth = SCREEN_RECT_LINE_WIDTH;
345
- for (const r of rects) {
346
- const scrX = sx * r.x + tx;
347
- const scrY = sy * r.y + ty;
348
- const w = r.width;
349
- const h = r.height;
350
- const anchor = r.anchor ?? "center";
351
- let x = scrX;
352
- let y = scrY;
353
- switch (anchor) {
354
- case "center":
355
- x = scrX - w / 2;
356
- y = scrY - h / 2;
357
- break;
358
- case "tl":
359
- x = scrX;
360
- y = scrY;
361
- break;
362
- case "tr":
363
- x = scrX - w;
364
- y = scrY;
365
- break;
366
- case "bl":
367
- x = scrX;
368
- y = scrY - h;
369
- break;
370
- case "br":
371
- x = scrX - w;
372
- y = scrY - h;
373
- break;
374
- }
375
- const doFill = r.fill !== false;
376
- const doStroke = r.stroke !== false;
377
- const angle = r.angle ?? 0;
378
- if (angle !== 0) {
379
- const cx = x + w / 2;
380
- const cy = y + h / 2;
381
- ctx.save();
382
- ctx.translate(cx, cy);
383
- ctx.rotate(angle);
384
- ctx.translate(-cx, -cy);
385
- }
386
- if (doFill) {
387
- ctx.fillStyle = r.fillColor ?? this.color;
388
- ctx.fillRect(x, y, w, h);
389
- }
390
- if (doStroke) {
391
- ctx.strokeStyle = r.strokeColor ?? this.color;
392
- ctx.strokeRect(x, y, w, h);
393
- }
394
- if (angle !== 0) ctx.restore();
395
- }
396
- }
397
- };
398
- //#endregion
399
- //#region primitives/draw.ts
400
- /**
401
- * Filter a draw command list by semantic group.
402
- *
403
- * Ungrouped primitives are always kept. The function is intentionally shallow:
404
- * primitives are immutable command objects on the hot draw path, so preserving
405
- * object identity keeps this as a visibility pass rather than a rewrite.
406
- */
407
- function filterHUDDrawByGroup(draw, filter) {
408
- if (!draw) return void 0;
409
- const hidden = new Set(filter.hidden ?? []);
410
- if (hidden.size === 0) return draw;
411
- const out = {};
412
- out.lines = keepVisible(draw.lines, hidden);
413
- out.rules = keepVisible(draw.rules, hidden);
414
- out.points = keepVisible(draw.points, hidden);
415
- out.rects = keepVisible(draw.rects, hidden);
416
- out.polylines = keepVisible(draw.polylines, hidden);
417
- out.screenRects = keepVisible(draw.screenRects, hidden);
418
- return hasAny(out) ? out : void 0;
419
- }
420
- function keepVisible(items, hidden) {
421
- if (!items || items.length === 0) return void 0;
422
- const kept = items.filter((item) => !item.group || !hidden.has(item.group));
423
- return kept.length > 0 ? kept : void 0;
424
- }
425
- function hasAny(draw) {
426
- return (draw.lines?.length ?? 0) > 0 || (draw.rules?.length ?? 0) > 0 || (draw.points?.length ?? 0) > 0 || (draw.rects?.length ?? 0) > 0 || (draw.polylines?.length ?? 0) > 0 || (draw.screenRects?.length ?? 0) > 0;
427
- }
428
- //#endregion
429
- //#region primitives/snap-guide.ts
430
- /**
431
- * Convert a `guide.SnapGuide` (the output of `guide.plot()`) into a
432
- * generic {@link HUDDraw} command list.
433
- *
434
- * `color`, when supplied, is applied as the per-item stroke override
435
- * for every emitted line, rule, and point. When absent, the HUD
436
- * canvas's current color is used.
437
- */
438
- function snapGuideToHUDDraw(sg, color) {
439
- if (!sg) return void 0;
440
- return {
441
- lines: sg.lines.map((l) => ({
442
- ...l,
443
- color
444
- })),
445
- rules: sg.rules.map(([axis, offset]) => ({
446
- axis,
447
- offset,
448
- color
449
- })),
450
- points: sg.points.map(([x, y]) => ({
451
- x,
452
- y,
453
- color
454
- }))
455
- };
456
- }
457
- //#endregion
458
- //#region primitives/measurement-guide.ts
459
- const SIDES = [
460
- "top",
461
- "right",
462
- "bottom",
463
- "left"
464
- ];
465
- /**
466
- * Convert a {@link Measurement} (the output of `measure()`) into a
467
- * generic {@link HUDDraw} command list.
468
- *
469
- * All coordinates are in **document space** — the HUD canvas applies
470
- * the viewport transform.
471
- *
472
- * Produces:
473
- * - Two stroke-only rects for the A and B bounding boxes
474
- * - One labelled guide line per non-zero distance (solid)
475
- * - One auxiliary line per non-zero side connecting the guide to B (dashed)
476
- *
477
- * If `color` is provided, every emitted line and rect carries that color so
478
- * the guides render distinctly from the canvas's chrome color. When used via
479
- * `surface.draw(extra)` (the host-fed-extras channel) this is required to
480
- * separate measurement from selection chrome on a shared canvas.
481
- */
482
- function measurementToHUDDraw(m, color) {
483
- const { a, b, box, distance } = m;
484
- const rects = [{
485
- x: a.x,
486
- y: a.y,
487
- width: a.width,
488
- height: a.height,
489
- color
490
- }, {
491
- x: b.x,
492
- y: b.y,
493
- width: b.width,
494
- height: b.height,
495
- color
496
- }];
497
- const lines = [];
498
- for (let i = 0; i < 4; i++) {
499
- const dist = distance[i];
500
- if (dist <= 0) continue;
501
- const side = SIDES[i];
502
- const label = cmath.ui.formatNumber(dist, 1);
503
- const [x1, y1, x2, y2] = guide_line_xylr(box, side, dist);
504
- lines.push({
505
- x1,
506
- y1,
507
- x2,
508
- y2,
509
- label,
510
- color
511
- });
512
- const [ax1, ay1, ax2, ay2, aLen] = auxiliary_line_xylr([x2, y2], b, side);
513
- if (aLen > 0) lines.push({
514
- x1: ax1,
515
- y1: ay1,
516
- x2: ax2,
517
- y2: ay2,
518
- dashed: true,
519
- color
520
- });
521
- }
522
- return {
523
- rects,
524
- lines
525
- };
526
- }
527
- //#endregion
528
- //#region primitives/marquee.ts
529
- /**
530
- * Convert two marquee corner points into a {@link HUDDraw} command list.
531
- *
532
- * All coordinates are in **document space**.
533
- *
534
- * Produces a single rectangle with a stroke outline and a semi-transparent fill.
535
- */
536
- function marqueeToHUDDraw(a, b) {
537
- const rect = cmath.rect.fromPoints([a, b]);
538
- return { rects: [{
539
- x: rect.x,
540
- y: rect.y,
541
- width: rect.width,
542
- height: rect.height,
543
- fill: true,
544
- fillOpacity: .2
545
- }] };
546
- }
547
- //#endregion
548
- //#region primitives/lasso.ts
549
- /**
550
- * Convert a lasso point sequence into a {@link HUDDraw} command list.
551
- *
552
- * All coordinates are in **document space**.
553
- *
554
- * Produces a single polyline with a dashed stroke and a semi-transparent fill.
555
- */
556
- function lassoToHUDDraw(points) {
557
- if (points.length < 2) return void 0;
558
- return { polylines: [{
559
- points,
560
- fill: true,
561
- fillOpacity: .2,
562
- dashed: true
563
- }] };
564
- }
565
- //#endregion
566
- //#region event/event.ts
567
- const NO_MODS = {
568
- shift: false,
569
- alt: false,
570
- meta: false,
571
- ctrl: false
572
- };
573
- function emptyResponse() {
574
- return {
575
- needsRedraw: false,
576
- cursorChanged: false,
577
- hoverChanged: false
578
- };
579
- }
580
- //#endregion
581
- //#region event/gesture.ts
582
- const IDLE = { kind: "idle" };
583
- /**
584
- * Compute a normalized rectangle from two corner points in document-space.
585
- * Thin alias over `cmath.rect.fromPoints` for ergonomic two-point use.
586
- */
587
- function rectFromPoints(a, b) {
588
- return cmath.rect.fromPoints([a, b]);
589
- }
590
- /**
591
- * Apply a resize-handle drag to a `SelectionShape` and return the new shape.
592
- *
593
- * `dx, dy` is the total drag delta in **document-space**, measured from
594
- * `anchor_doc` (the pointer-down point) to the current pointer.
595
- *
596
- * - For `kind: "rect"` shapes, the delta applies directly to the doc-space
597
- * bbox. Same math as the legacy `Rect`-only `applyResize`; no behavior
598
- * change for axis-aligned hosts.
599
- * - For `kind: "transformed"` shapes, the doc-space delta is rotated into
600
- * the shape's **local** frame via the matrix's linear part (the rotation
601
- * component, translation dropped), then applied to `local`. The matrix
602
- * itself is preserved — only `local.x/y/width/height` change. Net effect:
603
- * dragging a corner of a rotated rect extends the artwork along its
604
- * rotation axis, not along world axes.
605
- * - For `kind: "line"` and `kind: "unresolved"` the shape is returned
606
- * unchanged (lines have endpoint-knob gestures, not corner-resize).
607
- *
608
- * No constraints: width/height can go negative — host is responsible for
609
- * normalizing if it cares (most callers clamp to a min-size).
610
- */
611
- function applyResize(initial, direction, dx, dy) {
612
- if (initial.kind === "rect") return {
613
- kind: "rect",
614
- rect: applyResizeRect(initial.rect, direction, dx, dy)
615
- };
616
- if (initial.kind === "transformed") {
617
- const m = initial.matrix;
618
- const linear = [[
619
- m[0][0],
620
- m[0][1],
621
- 0
622
- ], [
623
- m[1][0],
624
- m[1][1],
625
- 0
626
- ]];
627
- const inv_linear = cmath.transform.invert(linear);
628
- const [ldx, ldy] = cmath.vector2.transform([dx, dy], inv_linear);
629
- return {
630
- kind: "transformed",
631
- local: applyResizeRect(initial.local, direction, ldx, ldy),
632
- matrix: m
633
- };
634
- }
635
- return initial;
636
- }
637
- function applyResizeRect(initial, direction, dx, dy) {
638
- let { x, y, width, height } = initial;
639
- switch (direction) {
640
- case "n":
641
- y += dy;
642
- height -= dy;
643
- break;
644
- case "s":
645
- height += dy;
646
- break;
647
- case "e":
648
- width += dx;
649
- break;
650
- case "w":
651
- x += dx;
652
- width -= dx;
653
- break;
654
- case "ne":
655
- y += dy;
656
- height -= dy;
657
- width += dx;
658
- break;
659
- case "nw":
660
- y += dy;
661
- height -= dy;
662
- x += dx;
663
- width -= dx;
664
- break;
665
- case "se":
666
- width += dx;
667
- height += dy;
668
- break;
669
- case "sw":
670
- x += dx;
671
- width -= dx;
672
- height += dy;
673
- break;
674
- }
675
- return {
676
- x,
677
- y,
678
- width,
679
- height
680
- };
681
- }
682
- //#endregion
683
- //#region event/shape.ts
684
- /**
685
- * Compute the axis-aligned bounding box of a `SelectionShape` in doc-space.
686
- * Used for layout math the chrome builder needs (e.g. handle positions).
687
- *
688
- * Throws on `kind: "unresolved"` — the chrome builder must resolve those
689
- * via `shapeOf` first.
690
- */
691
- function shapeBounds(shape) {
692
- if (shape.kind === "rect") return shape.rect;
693
- if (shape.kind === "transformed") return cmath.rect.transform(shape.local, shape.matrix);
694
- if (shape.kind === "line") return cmath.rect.fromPoints([shape.p1, shape.p2]);
695
- throw new Error(`shapeBounds: cannot bound unresolved shape (id=${shape.id})`);
696
- }
697
- //#endregion
698
- //#region event/click-tracker.ts
699
- var ClickTracker = class {
700
- constructor(opts = {}) {
701
- this.last_time = 0;
702
- this.last_x = 0;
703
- this.last_y = 0;
704
- this.count = 0;
705
- this.window_ms = opts.windowMs ?? 500;
706
- this.distance_px = opts.distancePx ?? 5;
707
- }
708
- /**
709
- * Register a click at `(x, y)` and return the current consecutive-click
710
- * count (1 for single, 2 for double, etc.). `now` is in milliseconds.
711
- */
712
- register(x, y, now = nowMs()) {
713
- const dt = now - this.last_time;
714
- const dx = x - this.last_x;
715
- const dy = y - this.last_y;
716
- const dist2 = dx * dx + dy * dy;
717
- const max2 = this.distance_px * this.distance_px;
718
- if (dt <= this.window_ms && dist2 <= max2 && this.count > 0) this.count += 1;
719
- else this.count = 1;
720
- this.last_time = now;
721
- this.last_x = x;
722
- this.last_y = y;
723
- return this.count;
724
- }
725
- reset() {
726
- this.count = 0;
727
- this.last_time = 0;
728
- }
729
- };
730
- function nowMs() {
731
- if (typeof performance !== "undefined" && performance.now) return performance.now();
732
- return Date.now();
733
- }
734
- //#endregion
735
- //#region event/hit-regions.ts
736
- /**
737
- * Registry of overlay UI hit regions.
738
- *
739
- * Regions are appended in declaration order. `hitTest` resolves by
740
- * **lowest priority value** at the hit point; declaration order
741
- * serves as tie-break only (later push wins on equal priorities,
742
- * preserving the prior "topmost wins on overlap" feel).
743
- */
744
- var HitRegions = class {
745
- constructor() {
746
- this.regions = [];
747
- }
748
- clear() {
749
- this.regions.length = 0;
750
- }
751
- push(region) {
752
- this.regions.push(region);
753
- }
754
- hitTest(point) {
755
- return this.hitTestRegion(point)?.action ?? null;
756
- }
757
- /** Returns the full region (label + priority) — used by tests and
758
- * debug tooling that want to assert on `label`. `hitTest` delegates here. */
759
- hitTestRegion(point) {
760
- let best = null;
761
- for (const r of this.regions) {
762
- const test_point = r.inverse_transform ? cmath.vector2.transform(point, r.inverse_transform) : point;
763
- if (!cmath.rect.containsPoint(r.rect, test_point)) continue;
764
- if (best === null || r.priority <= best.priority) best = r;
765
- }
766
- return best;
767
- }
768
- isEmpty() {
769
- return this.regions.length === 0;
770
- }
771
- /** Read-only access for tests. */
772
- toArray() {
773
- return this.regions;
774
- }
775
- };
776
- //#endregion
777
- //#region event/decision.ts
778
- /**
779
- * Selection-intent decision module — pure functions, ZERO side effects.
780
- *
781
- * Mental model:
782
- *
783
- * The HUD is an event router over real overlay layers. The host creates
784
- * overlays (resize knob, rotate region, endpoint knob, translate body,
785
- * etc.); the router decides what each one does on pointer-down by overlay
786
- * type, falling back to a scene-content pick when no overlay claims.
787
- * Pointer events are synthesized on top of raw pointer input — no native
788
- * `click`, no DOM ordering.
789
- *
790
- * Architecture:
791
- *
792
- * PointerDownInput ─► classifyScenario() ─► Scenario ─► dispatch() ─► PointerDownDecision
793
- * (recognizer) (declarative table)
794
- *
795
- * The `Scenario` enum is a **descriptive label** for each `(overlay hit,
796
- * pick result, selection, modifiers)` combination — useful for tests and
797
- * readable dispatch — NOT a contract the HUD imposes on hosts. The
798
- * contract is per-overlay routing semantics (e.g. resize knob always
799
- * starts a gesture; translate body always defers) and the Tier-1 → Tier-2
800
- * fallback when no overlay claims the point.
801
- *
802
- * Adding a new UX rule:
803
- *
804
- * 1. Add a new `Scenario` constant (or reuse one).
805
- * 2. Add/update the recognizer branch in {@link classifyScenario}.
806
- * 3. Add/update the dispatch branch in {@link decidePointerDown}.
807
- * 4. Add tests pinning the classification and the dispatch.
808
- * 5. Update the working-group doc.
809
- *
810
- * Working-group spec (implementation-agnostic):
811
- * https://grida.co/docs/wg/feat-editor/ux-surface/selection-intent
812
- *
813
- * UX-narrative sibling:
814
- * https://grida.co/docs/wg/feat-editor/ux-surface/selection
815
- */
816
- /**
817
- * Recognize which scenario a pointer-down belongs to. Total over inputs.
818
- * Pure, no I/O. The single source of truth for "which atomic intent did the
819
- * user just express?"
820
- */
821
- function classifyScenario(input) {
822
- const { ui_action, hovered_id, selection_ids, modifiers, click_count, readonly } = input;
823
- if (ui_action) switch (ui_action.kind) {
824
- case "resize_handle": return readonly ? "Noop" : "HandleResize";
825
- case "rotate_handle": return readonly ? "Noop" : "HandleRotate";
826
- case "endpoint_handle": return readonly ? "Noop" : "HandleEndpoint";
827
- case "select_node": {
828
- const id = ui_action.id;
829
- if (click_count >= 2) return "EnterEdit";
830
- return classifyContent(id, selection_ids, modifiers);
831
- }
832
- case "translate_handle": return classifyBodyRegion(hovered_id, selection_ids, modifiers, click_count);
833
- }
834
- if (hovered_id) {
835
- if (click_count >= 2) return "EnterEdit";
836
- return classifyContent(hovered_id, selection_ids, modifiers);
837
- }
838
- if (modifiers.shift) return "EmptyAdditiveMarquee";
839
- if (selection_ids.length > 0) return "EmptyDeselectThenMarquee";
840
- return "EmptyMarquee";
841
- }
842
- /**
843
- * Tier-2 content classification (also reused for the Tier-1 `select_node`
844
- * overlay variant). The asymmetry lives here in one place:
845
- *
846
- * would-deselect (in selection) → ambiguous (defer)
847
- * would-select (not in selection) → singleton (immediate)
848
- */
849
- function classifyContent(id, selection_ids, modifiers) {
850
- if (selection_ids.includes(id)) return modifiers.shift ? "ContentToggleOrDrag" : "ContentNarrowOrDrag";
851
- return modifiers.shift ? "ContentAdd" : "ContentReplace";
852
- }
853
- /**
854
- * Body-region classification. Every variant defers, because in the body
855
- * region "drag the existing selection" is always a candidate intent — even
856
- * with shift, even when the underlying hover would otherwise be a clear
857
- * select. The body region's whole purpose is to claim drag.
858
- */
859
- function classifyBodyRegion(hovered_id, selection_ids, modifiers, click_count) {
860
- if (click_count >= 2) return "EnterEdit";
861
- if (!hovered_id) return "BodyDragOnly";
862
- if (selection_ids.includes(hovered_id)) return modifiers.shift ? "BodyToggleOrDrag" : "BodyNarrowOrDrag";
863
- return modifiers.shift ? "BodyAddOrDrag" : "BodySwapOrDrag";
864
- }
865
- /**
866
- * Decide what a primary-button pointer-down should do. Thin wrapper:
867
- * classify the scenario, then dispatch declaratively. The dispatch table is
868
- * a flat switch — adding a new scenario shows up as exactly one new case.
869
- */
870
- function decidePointerDown(input) {
871
- return dispatch(classifyScenario(input), input);
872
- }
873
- function dispatch(scenario, input) {
874
- const { ui_action, hovered_id, selection_ids, modifiers } = input;
875
- switch (scenario) {
876
- case "Noop": return { kind: "noop" };
877
- case "HandleResize": {
878
- const a = ui_action;
879
- return {
880
- kind: "start_resize",
881
- ids: a.ids,
882
- direction: a.direction,
883
- initial_shape: a.initial_shape
884
- };
885
- }
886
- case "HandleRotate": {
887
- const a = ui_action;
888
- return {
889
- kind: "start_rotate",
890
- ids: a.ids,
891
- corner: a.corner,
892
- initial_shape: a.initial_shape
893
- };
894
- }
895
- case "HandleEndpoint": {
896
- const a = ui_action;
897
- return {
898
- kind: "start_endpoint",
899
- id: a.id,
900
- endpoint: a.endpoint,
901
- p1: a.p1,
902
- p2: a.p2
903
- };
904
- }
905
- case "EnterEdit": {
906
- const chrome_ids = ui_action && ui_action.kind === "translate_handle" ? ui_action.ids : null;
907
- const id = hovered_id ?? (chrome_ids && chrome_ids.length === 1 ? chrome_ids[0] : null);
908
- if (id === null) return { kind: "noop" };
909
- return {
910
- kind: "enter_edit",
911
- id
912
- };
913
- }
914
- case "ContentReplace": {
915
- const id = contentId(ui_action, hovered_id);
916
- return {
917
- kind: "immediate_select",
918
- select_ids: [id],
919
- mode: "replace",
920
- pending: { ids_at_down: [id] }
921
- };
922
- }
923
- case "ContentAdd": {
924
- const id = contentId(ui_action, hovered_id);
925
- return {
926
- kind: "immediate_select",
927
- select_ids: [id],
928
- mode: "toggle",
929
- pending: { ids_at_down: [id, ...selection_ids.filter((s) => s !== id)] }
930
- };
931
- }
932
- case "ContentNarrowOrDrag":
933
- case "ContentToggleOrDrag": {
934
- const id = contentId(ui_action, hovered_id);
935
- return {
936
- kind: "pend",
937
- pending: {
938
- ids_at_down: [...selection_ids],
939
- deferred: {
940
- node_id: id,
941
- shift: modifiers.shift
942
- }
943
- }
944
- };
945
- }
946
- case "BodyDragOnly": return {
947
- kind: "pend",
948
- pending: { ids_at_down: [...ui_action.ids] }
949
- };
950
- case "BodyNarrowOrDrag":
951
- case "BodyToggleOrDrag":
952
- case "BodySwapOrDrag":
953
- case "BodyAddOrDrag": return {
954
- kind: "pend",
955
- pending: {
956
- ids_at_down: [...ui_action.ids],
957
- deferred: {
958
- node_id: hovered_id,
959
- shift: modifiers.shift
960
- }
961
- }
962
- };
963
- case "EmptyDeselectThenMarquee": return {
964
- kind: "start_marquee_pend",
965
- emit_deselect_all: true
966
- };
967
- case "EmptyMarquee":
968
- case "EmptyAdditiveMarquee": return {
969
- kind: "start_marquee_pend",
970
- emit_deselect_all: false
971
- };
972
- }
973
- }
974
- /** Resolve the id that the user clicked on for content-class scenarios. */
975
- function contentId(ui_action, hovered_id) {
976
- if (ui_action && ui_action.kind === "select_node") return ui_action.id;
977
- return hovered_id;
978
- }
979
- /**
980
- * What the cursor should show while idle. Drives the cursor in lockstep
981
- * with the pointer-down decision — both read the same inputs, so cursor
982
- * and intent can't drift.
983
- *
984
- * Hover (the visual outline) is NOT decided here — it's always
985
- * `hovered_id`. Cursor is about INTENT (what the next pointer-down would
986
- * do), not what's visually under the pointer.
987
- */
988
- function decideIdleCursor(input) {
989
- const { ui_action, hovered_id, selection_ids } = input;
990
- if (ui_action) switch (ui_action.kind) {
991
- case "resize_handle": return {
992
- kind: "resize",
993
- direction: ui_action.direction,
994
- baseAngle: shape_screen_angle_rad(ui_action.initial_shape)
995
- };
996
- case "rotate_handle": return {
997
- kind: "rotate",
998
- corner: ui_action.corner,
999
- baseAngle: shape_screen_angle_rad(ui_action.initial_shape)
1000
- };
1001
- case "translate_handle": return "move";
1002
- case "select_node":
1003
- case "endpoint_handle": return "pointer";
1004
- }
1005
- if (hovered_id && selection_ids.includes(hovered_id)) return "move";
1006
- return "default";
1007
- }
1008
- /**
1009
- * Doc-space rotation of a `SelectionShape` in radians. For `transformed`
1010
- * shapes this is the angle baked into `matrix`; for `rect`/`line` it's 0.
1011
- *
1012
- * Used by `decideIdleCursor` and by the rotate-gesture cursor compositor
1013
- * so the resize / rotate cursor always tilts to match the selection's
1014
- * orientation — without requiring the HUD's camera to be axis-aligned
1015
- * for the doc-space angle to be the right thing to render (the renderer
1016
- * draws the cursor in screen px, and in svg-editor the camera contributes
1017
- * scale + translate only, so doc-space == screen-space rotation).
1018
- *
1019
- * For hosts that ROTATE the HUD camera, this should compose with the
1020
- * camera's angle at consume time. Not relevant for svg-editor.
1021
- */
1022
- function shape_screen_angle_rad(shape) {
1023
- if (shape.kind !== "transformed") return 0;
1024
- return cmath.transform.angle(shape.matrix) * Math.PI / 180;
1025
- }
1026
- //#endregion
1027
- //#region event/transform.ts
1028
- const IDENTITY = [[
1029
- 1,
1030
- 0,
1031
- 0
1032
- ], [
1033
- 0,
1034
- 1,
1035
- 0
1036
- ]];
1037
- /** Project a screen-space point into document-space. */
1038
- function screenToDoc(t, x, y) {
1039
- const [[sx, , tx], [, sy, ty]] = t;
1040
- return [(x - tx) / (sx || 1), (y - ty) / (sy || 1)];
1041
- }
1042
- /** Project a document-space point into screen-space. */
1043
- function docToScreen(t, x, y) {
1044
- const [[sx, , tx], [, sy, ty]] = t;
1045
- return [sx * x + tx, sy * y + ty];
1046
- }
1047
- //#endregion
1048
- //#region event/state.ts
1049
- const DRAG_THRESHOLD_PX = 3;
1050
- /**
1051
- * Pure-logic surface state machine.
1052
- *
1053
- * Owns gesture, hover, modifiers, cursor, click-tracker, hit-regions, and
1054
- * the current selection **mirror** pushed by the host. Does not own the
1055
- * authoritative selection — emits `select` intents the host commits.
1056
- *
1057
- * No canvas knowledge. No DOM knowledge. No React.
1058
- */
1059
- var SurfaceState = class {
1060
- constructor() {
1061
- this.gesture = IDLE;
1062
- this.hover = null;
1063
- this.hover_override = null;
1064
- this.cursor = "default";
1065
- this.modifiers = { ...NO_MODS };
1066
- this.readonly = false;
1067
- this.groups = [];
1068
- this.selection_ids = [];
1069
- this.transform = IDENTITY;
1070
- this.hit_regions = new HitRegions();
1071
- this.click_tracker = new ClickTracker();
1072
- this.pending = null;
1073
- }
1074
- /**
1075
- * The effective hover: override beats pick.
1076
- * Used by chrome rendering and by `Surface.hover()`.
1077
- */
1078
- getEffectiveHover() {
1079
- return this.hover_override ?? this.hover;
1080
- }
1081
- /**
1082
- * Push a new selection from the host. Accepts either:
1083
- *
1084
- * - **A flat `NodeId[]`** — each id becomes its own single-member group
1085
- * with shape resolved via `shapeOf(id)` at chrome build time. Simple
1086
- * hosts (e.g. svg-editor v1) use this overload.
1087
- * - **A `SelectionGroup[]`** — pre-computed groups (typically grouped by
1088
- * parent), each with its own pre-unioned shape. Hosts that already
1089
- * compute groups (e.g. the main editor) use this overload.
1090
- *
1091
- * The flat-ids form is resolved lazily — `shapeOf` is called by the chrome
1092
- * builder, not here. This keeps `setSelection` cheap and lets host shape
1093
- * changes (e.g. after a node move) be reflected without re-calling
1094
- * `setSelection`.
1095
- */
1096
- setSelection(input) {
1097
- if (input.length === 0) {
1098
- this.groups = [];
1099
- this.selection_ids = [];
1100
- return;
1101
- }
1102
- if (typeof input[0] === "string") {
1103
- const ids = input;
1104
- this.selection_ids = [...ids];
1105
- this.groups = ids.map((id) => ({
1106
- ids: [id],
1107
- shape: {
1108
- kind: "unresolved",
1109
- id
1110
- }
1111
- }));
1112
- } else {
1113
- const groups = input;
1114
- this.groups = groups.map((g) => ({
1115
- ids: [...g.ids],
1116
- shape: g.shape
1117
- }));
1118
- const flat = [];
1119
- for (const g of groups) flat.push(...g.ids);
1120
- this.selection_ids = flat;
1121
- }
1122
- }
1123
- /** Read-only access to the flat list of selected ids (for hover logic). */
1124
- getSelectionIds() {
1125
- return this.selection_ids;
1126
- }
1127
- /** Read-only access to the selection groups (for chrome rendering). */
1128
- getSelectionGroups() {
1129
- return this.groups;
1130
- }
1131
- setTransform(t) {
1132
- this.transform = t;
1133
- }
1134
- getTransform() {
1135
- return this.transform;
1136
- }
1137
- setReadonly(v) {
1138
- this.readonly = v;
1139
- }
1140
- hitRegions() {
1141
- return this.hit_regions;
1142
- }
1143
- dispatch(event, deps) {
1144
- switch (event.kind) {
1145
- case "pointer_move": return this.onPointerMove(event.x, event.y, event.mods, deps);
1146
- case "pointer_down":
1147
- this.modifiers = event.mods;
1148
- return this.onPointerDown(event.x, event.y, event.button, deps);
1149
- case "pointer_up":
1150
- this.modifiers = event.mods;
1151
- return this.onPointerUp(event.x, event.y, event.button, deps);
1152
- case "modifiers":
1153
- this.modifiers = event.mods;
1154
- return emptyResponse();
1155
- case "wheel":
1156
- case "key": return emptyResponse();
1157
- case "blur": return this.onBlur(deps);
1158
- }
1159
- }
1160
- onPointerMove(sx, sy, mods, deps) {
1161
- this.modifiers = mods;
1162
- const response = emptyResponse();
1163
- const point_doc = screenToDoc(this.transform, sx, sy);
1164
- if (this.pending && this.gesture.kind === "idle") {
1165
- const ax = this.pending.anchor_screen[0];
1166
- const ay = this.pending.anchor_screen[1];
1167
- const dx = sx - ax;
1168
- const dy = sy - ay;
1169
- if (dx * dx + dy * dy >= DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) {
1170
- const ids = this.pending.ids_at_down;
1171
- this.pending.deferred = void 0;
1172
- if (ids.length > 0) {
1173
- this.gesture = {
1174
- kind: "translate",
1175
- ids,
1176
- anchor_doc: this.pending.anchor_doc,
1177
- last_doc: point_doc
1178
- };
1179
- this.setCursor("move", response);
1180
- } else this.gesture = {
1181
- kind: "marquee",
1182
- anchor_doc: this.pending.anchor_doc,
1183
- current_doc: point_doc
1184
- };
1185
- }
1186
- }
1187
- switch (this.gesture.kind) {
1188
- case "idle": {
1189
- const hit = deps.pick(point_doc);
1190
- this.setHover(hit, response);
1191
- const ui_action = this.hit_regions.hitTest([sx, sy]);
1192
- this.setCursor(decideIdleCursor({
1193
- ui_action,
1194
- hovered_id: hit,
1195
- selection_ids: this.selection_ids
1196
- }), response);
1197
- return response;
1198
- }
1199
- case "translate": {
1200
- const g = this.gesture;
1201
- const dx = point_doc[0] - g.anchor_doc[0];
1202
- const dy = point_doc[1] - g.anchor_doc[1];
1203
- this.gesture = {
1204
- ...g,
1205
- last_doc: point_doc
1206
- };
1207
- deps.emitIntent({
1208
- kind: "translate",
1209
- ids: g.ids,
1210
- dx,
1211
- dy,
1212
- phase: "preview"
1213
- });
1214
- response.needsRedraw = true;
1215
- return response;
1216
- }
1217
- case "marquee": {
1218
- const g = this.gesture;
1219
- this.gesture = {
1220
- ...g,
1221
- current_doc: point_doc
1222
- };
1223
- response.needsRedraw = true;
1224
- return response;
1225
- }
1226
- case "resize": {
1227
- const g = this.gesture;
1228
- const dx = point_doc[0] - g.anchor_doc[0];
1229
- const dy = point_doc[1] - g.anchor_doc[1];
1230
- const next_shape = applyResize(g.initial_shape, g.direction, dx, dy);
1231
- this.gesture = {
1232
- ...g,
1233
- current_shape: next_shape
1234
- };
1235
- deps.emitIntent({
1236
- kind: "resize",
1237
- ids: g.ids,
1238
- anchor: g.direction,
1239
- rect: shapeBounds(next_shape),
1240
- shape: next_shape,
1241
- phase: "preview"
1242
- });
1243
- response.needsRedraw = true;
1244
- return response;
1245
- }
1246
- case "rotate": {
1247
- const g = this.gesture;
1248
- const angle = Math.atan2(point_doc[1] - g.center_doc[1], point_doc[0] - g.center_doc[0]);
1249
- this.gesture = {
1250
- ...g,
1251
- current_angle: angle
1252
- };
1253
- const delta = angle - g.anchor_angle;
1254
- deps.emitIntent({
1255
- kind: "rotate",
1256
- ids: g.ids,
1257
- angle: delta,
1258
- phase: "preview"
1259
- });
1260
- this.setCursor({
1261
- kind: "rotate",
1262
- corner: g.corner,
1263
- baseAngle: g.initial_cursor_angle + delta
1264
- }, response);
1265
- response.needsRedraw = true;
1266
- return response;
1267
- }
1268
- case "endpoint": {
1269
- const g = this.gesture;
1270
- this.gesture = {
1271
- ...g,
1272
- pos_doc: point_doc
1273
- };
1274
- deps.emitIntent({
1275
- kind: "set_endpoint",
1276
- id: g.id,
1277
- endpoint: g.endpoint,
1278
- pos: point_doc,
1279
- phase: "preview"
1280
- });
1281
- response.needsRedraw = true;
1282
- return response;
1283
- }
1284
- case "pan":
1285
- this.gesture = {
1286
- kind: "pan",
1287
- prev_screen: [sx, sy]
1288
- };
1289
- return response;
1290
- }
1291
- return response;
1292
- }
1293
- onPointerDown(sx, sy, button, deps) {
1294
- const response = emptyResponse();
1295
- if (button !== "primary") return response;
1296
- const point_doc = screenToDoc(this.transform, sx, sy);
1297
- const screen = [sx, sy];
1298
- const ui_action = this.hit_regions.hitTest(screen);
1299
- const hovered_id = deps.pick(point_doc);
1300
- const click_count = this.click_tracker.register(sx, sy);
1301
- const decision = decidePointerDown({
1302
- ui_action,
1303
- hovered_id,
1304
- selection_ids: this.selection_ids,
1305
- modifiers: this.modifiers,
1306
- click_count,
1307
- readonly: this.readonly
1308
- });
1309
- switch (decision.kind) {
1310
- case "noop": return response;
1311
- case "start_resize":
1312
- this.gesture = {
1313
- kind: "resize",
1314
- ids: [...decision.ids],
1315
- direction: decision.direction,
1316
- initial_shape: decision.initial_shape,
1317
- anchor_doc: point_doc,
1318
- current_shape: decision.initial_shape
1319
- };
1320
- response.needsRedraw = true;
1321
- return response;
1322
- case "start_rotate": {
1323
- const [cx, cy] = cmath.rect.getCenter(shapeBounds(decision.initial_shape));
1324
- const angle = Math.atan2(point_doc[1] - cy, point_doc[0] - cx);
1325
- const initial_cursor_angle = decision.initial_shape.kind === "transformed" ? cmath.transform.angle(decision.initial_shape.matrix) * Math.PI / 180 : 0;
1326
- this.gesture = {
1327
- kind: "rotate",
1328
- ids: [...decision.ids],
1329
- corner: decision.corner,
1330
- center_doc: [cx, cy],
1331
- anchor_angle: angle,
1332
- current_angle: angle,
1333
- initial_cursor_angle
1334
- };
1335
- this.setCursor({
1336
- kind: "rotate",
1337
- corner: decision.corner,
1338
- baseAngle: initial_cursor_angle
1339
- }, response);
1340
- response.needsRedraw = true;
1341
- return response;
1342
- }
1343
- case "start_endpoint": {
1344
- const start = decision.endpoint === "p1" ? decision.p1 : decision.p2;
1345
- this.gesture = {
1346
- kind: "endpoint",
1347
- id: decision.id,
1348
- endpoint: decision.endpoint,
1349
- pos_doc: [start[0], start[1]]
1350
- };
1351
- response.needsRedraw = true;
1352
- return response;
1353
- }
1354
- case "enter_edit":
1355
- deps.emitIntent({
1356
- kind: "enter_content_edit",
1357
- id: decision.id
1358
- });
1359
- this.pending = null;
1360
- return response;
1361
- case "immediate_select":
1362
- deps.emitIntent({
1363
- kind: "select",
1364
- ids: [...decision.select_ids],
1365
- mode: decision.mode
1366
- });
1367
- response.needsRedraw = true;
1368
- this.pending = {
1369
- anchor_doc: point_doc,
1370
- anchor_screen: screen,
1371
- ids_at_down: decision.pending.ids_at_down,
1372
- deferred: decision.pending.deferred
1373
- };
1374
- return response;
1375
- case "pend":
1376
- this.pending = {
1377
- anchor_doc: point_doc,
1378
- anchor_screen: screen,
1379
- ids_at_down: decision.pending.ids_at_down,
1380
- deferred: decision.pending.deferred
1381
- };
1382
- return response;
1383
- case "start_marquee_pend":
1384
- if (decision.emit_deselect_all) {
1385
- deps.emitIntent({ kind: "deselect_all" });
1386
- response.needsRedraw = true;
1387
- }
1388
- this.pending = {
1389
- anchor_doc: point_doc,
1390
- anchor_screen: screen,
1391
- ids_at_down: []
1392
- };
1393
- return response;
1394
- }
1395
- }
1396
- onPointerUp(_sx, _sy, button, deps) {
1397
- const response = emptyResponse();
1398
- if (button !== "primary") return response;
1399
- if (this.pending && this.pending.deferred) {
1400
- const d = this.pending.deferred;
1401
- deps.emitIntent({
1402
- kind: "select",
1403
- ids: [d.node_id],
1404
- mode: d.shift ? "toggle" : "replace"
1405
- });
1406
- response.needsRedraw = true;
1407
- }
1408
- this.pending = null;
1409
- switch (this.gesture.kind) {
1410
- case "translate": {
1411
- const g = this.gesture;
1412
- const dx = g.last_doc[0] - g.anchor_doc[0];
1413
- const dy = g.last_doc[1] - g.anchor_doc[1];
1414
- deps.emitIntent({
1415
- kind: "translate",
1416
- ids: g.ids,
1417
- dx,
1418
- dy,
1419
- phase: "commit"
1420
- });
1421
- this.gesture = IDLE;
1422
- response.needsRedraw = true;
1423
- if (cursorEquals(this.cursor, "move")) this.setCursor("default", response);
1424
- break;
1425
- }
1426
- case "marquee": {
1427
- const g = this.gesture;
1428
- const rect = rectFromPoints(g.anchor_doc, g.current_doc);
1429
- deps.emitIntent({
1430
- kind: "marquee_select",
1431
- rect,
1432
- additive: this.modifiers.shift,
1433
- phase: "commit"
1434
- });
1435
- this.gesture = IDLE;
1436
- response.needsRedraw = true;
1437
- break;
1438
- }
1439
- case "resize": {
1440
- const g = this.gesture;
1441
- deps.emitIntent({
1442
- kind: "resize",
1443
- ids: g.ids,
1444
- anchor: g.direction,
1445
- rect: shapeBounds(g.current_shape),
1446
- shape: g.current_shape,
1447
- phase: "commit"
1448
- });
1449
- this.gesture = IDLE;
1450
- response.needsRedraw = true;
1451
- break;
1452
- }
1453
- case "rotate": {
1454
- const g = this.gesture;
1455
- deps.emitIntent({
1456
- kind: "rotate",
1457
- ids: g.ids,
1458
- angle: g.current_angle - g.anchor_angle,
1459
- phase: "commit"
1460
- });
1461
- this.gesture = IDLE;
1462
- response.needsRedraw = true;
1463
- break;
1464
- }
1465
- case "endpoint": {
1466
- const g = this.gesture;
1467
- deps.emitIntent({
1468
- kind: "set_endpoint",
1469
- id: g.id,
1470
- endpoint: g.endpoint,
1471
- pos: g.pos_doc,
1472
- phase: "commit"
1473
- });
1474
- this.gesture = IDLE;
1475
- response.needsRedraw = true;
1476
- break;
1477
- }
1478
- }
1479
- return response;
1480
- }
1481
- onBlur(deps) {
1482
- const response = emptyResponse();
1483
- this.pending = null;
1484
- if (this.gesture.kind !== "idle") {
1485
- deps.emitIntent({ kind: "cancel_gesture" });
1486
- this.gesture = IDLE;
1487
- response.needsRedraw = true;
1488
- }
1489
- return response;
1490
- }
1491
- setHover(id, response) {
1492
- if (this.hover === id) return;
1493
- const prev_eff = this.getEffectiveHover();
1494
- this.hover = id;
1495
- if (prev_eff !== this.getEffectiveHover()) {
1496
- response.hoverChanged = true;
1497
- response.needsRedraw = true;
1498
- }
1499
- }
1500
- /**
1501
- * Set or clear the host-driven hover override. Pass `null` to clear.
1502
- * Returns a response indicating whether the *effective* hover changed —
1503
- * the caller should redraw and notify subscribers if `hoverChanged`.
1504
- */
1505
- setHoverOverride(id) {
1506
- const response = emptyResponse();
1507
- if (this.hover_override === id) return response;
1508
- const prev_eff = this.getEffectiveHover();
1509
- this.hover_override = id;
1510
- if (prev_eff !== this.getEffectiveHover()) {
1511
- response.hoverChanged = true;
1512
- response.needsRedraw = true;
1513
- }
1514
- return response;
1515
- }
1516
- setCursor(next, response) {
1517
- if (!cursorEquals(this.cursor, next)) {
1518
- this.cursor = next;
1519
- response.cursorChanged = true;
1520
- }
1521
- }
1522
- };
1523
- //#endregion
1524
- //#region surface/style.ts
1525
- const DEFAULT_STYLE = {
1526
- chromeColor: "#2563eb",
1527
- hoverColor: "#60a5fa",
1528
- handleSize: 8,
1529
- handleFill: "#ffffff",
1530
- handleStroke: "#2563eb",
1531
- selectionOutlineWidth: 1,
1532
- hoverOutlineWidth: 2,
1533
- showRotationHandles: false
1534
- };
1535
- function mergeStyle(base, partial) {
1536
- if (!partial) return base;
1537
- return {
1538
- ...base,
1539
- ...partial
1540
- };
1541
- }
1542
- //#endregion
1543
- //#region event/overlay.ts
1544
- /**
1545
- * Minimum hit-target size in screen-px.
1546
- *
1547
- * Visual knobs are typically 8px, but the hit region is 16px so users don't
1548
- * need pixel-perfect aim. Matches `MIN_HIT_SIZE` in the Rust overlay.
1549
- */
1550
- const MIN_HIT_SIZE = 16;
1551
- /**
1552
- * Below this selection size (in screen-px on either axis), chrome is
1553
- * suppressed — both the visual handles AND the hit regions. Matches
1554
- * `MIN_HANDLES_VISIBLE_SIZE` in the Rust overlay.
1555
- */
1556
- const MIN_CHROME_VISIBLE_SIZE = 12;
1557
- //#endregion
1558
- //#region event/selection-controls.ts
1559
- const CORNER_DIRS = [
1560
- "nw",
1561
- "ne",
1562
- "se",
1563
- "sw"
1564
- ];
1565
- const EDGE_DIRS = [
1566
- "n",
1567
- "e",
1568
- "s",
1569
- "w"
1570
- ];
1571
- const CORNER_LABEL = {
1572
- nw: "resize_handle:nw",
1573
- ne: "resize_handle:ne",
1574
- se: "resize_handle:se",
1575
- sw: "resize_handle:sw"
1576
- };
1577
- const EDGE_LABEL = {
1578
- n: "resize_edge:n",
1579
- e: "resize_edge:e",
1580
- s: "resize_edge:s",
1581
- w: "resize_edge:w"
1582
- };
1583
- const ROTATE_LABEL = {
1584
- nw: "rotate:nw",
1585
- ne: "rotate:ne",
1586
- se: "rotate:se",
1587
- sw: "rotate:sw"
1588
- };
1589
- const HUDHitPriority = {
1590
- ENDPOINT_HANDLE: 10,
1591
- RESIZE_HANDLE_EDGE_SMALL: 22,
1592
- TRANSLATE_BODY_SMALL: 25,
1593
- RESIZE_HANDLE_EDGE: 30,
1594
- RESIZE_HANDLE_CORNER: 31,
1595
- TRANSLATE_BODY: 40,
1596
- ROTATE_HANDLE: 50
1597
- };
1598
- /** The principle constant — the minimum guaranteed length for the body
1599
- * interior on each axis AND for each side strip along its parallel
1600
- * axis. Tunable; everything else derives. */
1601
- const MIN_GUARANTEED_INTERACTIVE_DIM = 20;
1602
- /** Below this axis dim, body promotes above corner. Derived from the
1603
- * principle. */
1604
- const BODY_FLIP_THRESHOLD = 36;
1605
- /**
1606
- * @returns { corner, edge } — lengths in screen-px summing to `total`
1607
- * (corner * 2 + edge === total). Each is `>= 0`.
1608
- *
1609
- * Three phases:
1610
- * - **comfortable** (`total >= 2 * corner_preferred + edge_min`):
1611
- * corners at preferred, edge takes the surplus.
1612
- * - **squeezed** (`total >= edge_min`): edge at its min, corners share
1613
- * the remainder.
1614
- * - **tiny** (`total < edge_min`): edge takes everything, corners 0.
1615
- */
1616
- function negotiateAxis(total, corner_preferred, edge_min) {
1617
- if (total <= 0) return {
1618
- corner: 0,
1619
- edge: 0
1620
- };
1621
- if (total >= corner_preferred * 2 + edge_min) return {
1622
- corner: corner_preferred,
1623
- edge: total - corner_preferred * 2
1624
- };
1625
- if (total >= edge_min) return {
1626
- corner: (total - edge_min) / 2,
1627
- edge: edge_min
1628
- };
1629
- return {
1630
- corner: 0,
1631
- edge: total
1632
- };
1633
- }
1634
- /**
1635
- * Compute the selection control layout for a screen-space rect.
1636
- *
1637
- * Pure: no DOM, no global state. Same inputs → same zones.
1638
- *
1639
- * The perimeter ring straddles the bbox edge with `extension =
1640
- * hit_size / 2` overhang outside. Along each axis the run of length
1641
- * `axis_dim + 2 * extension` is split via {@link negotiateAxis} into
1642
- * `[corner | edge | corner]`. The 4 corners and 4 edges in 2D then tile
1643
- * the ring as a strict 3×3 grid of cells — **non-overlapping**. Body
1644
- * sits at rect_screen and may overlap with the ring's inside-bbox half
1645
- * in comfortable mode; priority resolves those overlaps.
1646
- *
1647
- * See the comment block in this file for the full principle.
1648
- */
1649
- function computeSelectionControlLayout(rect_screen, opts) {
1650
- const zones = [];
1651
- const w_violated = rect_screen.width < BODY_FLIP_THRESHOLD;
1652
- const h_violated = rect_screen.height < BODY_FLIP_THRESHOLD;
1653
- const small_mode = w_violated || h_violated;
1654
- const controls_visible = rect_screen.width >= 12 && rect_screen.height >= 12;
1655
- if (rect_screen.width >= 1 || rect_screen.height >= 1) zones.push({
1656
- rect: rect_screen,
1657
- priority: small_mode ? HUDHitPriority.TRANSLATE_BODY_SMALL : HUDHitPriority.TRANSLATE_BODY,
1658
- role: { kind: "translate" },
1659
- label: "translate"
1660
- });
1661
- if (!controls_visible) return {
1662
- zones,
1663
- controls_visible,
1664
- small_mode
1665
- };
1666
- const hit_size = Math.max(opts.handle_size + 4, 16);
1667
- const extension = hit_size / 2;
1668
- const total_x = rect_screen.width + extension * 2;
1669
- const total_y = rect_screen.height + extension * 2;
1670
- const { corner: cx, edge: ex } = negotiateAxis(total_x, hit_size, 20);
1671
- const { corner: cy, edge: ey } = negotiateAxis(total_y, hit_size, 20);
1672
- const left = rect_screen.x - extension;
1673
- const top = rect_screen.y - extension;
1674
- const mid_x = left + cx;
1675
- const mid_y = top + cy;
1676
- const right_x = mid_x + ex;
1677
- const right_y = mid_y + ey;
1678
- const cornerRects = {
1679
- nw: {
1680
- x: left,
1681
- y: top,
1682
- width: cx,
1683
- height: cy
1684
- },
1685
- ne: {
1686
- x: right_x,
1687
- y: top,
1688
- width: cx,
1689
- height: cy
1690
- },
1691
- sw: {
1692
- x: left,
1693
- y: right_y,
1694
- width: cx,
1695
- height: cy
1696
- },
1697
- se: {
1698
- x: right_x,
1699
- y: right_y,
1700
- width: cx,
1701
- height: cy
1702
- }
1703
- };
1704
- const edgeRects = {
1705
- n: {
1706
- x: mid_x,
1707
- y: top,
1708
- width: ex,
1709
- height: cy
1710
- },
1711
- s: {
1712
- x: mid_x,
1713
- y: right_y,
1714
- width: ex,
1715
- height: cy
1716
- },
1717
- w: {
1718
- x: left,
1719
- y: mid_y,
1720
- width: cx,
1721
- height: ey
1722
- },
1723
- e: {
1724
- x: right_x,
1725
- y: mid_y,
1726
- width: cx,
1727
- height: ey
1728
- }
1729
- };
1730
- for (const dir of CORNER_DIRS) {
1731
- const rect = cornerRects[dir];
1732
- if (rect.width <= 0 || rect.height <= 0) continue;
1733
- zones.push({
1734
- rect,
1735
- priority: HUDHitPriority.RESIZE_HANDLE_CORNER,
1736
- role: {
1737
- kind: "resize_corner",
1738
- direction: dir
1739
- },
1740
- label: CORNER_LABEL[dir]
1741
- });
1742
- }
1743
- const edge_priority = {
1744
- n: w_violated ? HUDHitPriority.RESIZE_HANDLE_EDGE_SMALL : HUDHitPriority.RESIZE_HANDLE_EDGE,
1745
- s: w_violated ? HUDHitPriority.RESIZE_HANDLE_EDGE_SMALL : HUDHitPriority.RESIZE_HANDLE_EDGE,
1746
- e: h_violated ? HUDHitPriority.RESIZE_HANDLE_EDGE_SMALL : HUDHitPriority.RESIZE_HANDLE_EDGE,
1747
- w: h_violated ? HUDHitPriority.RESIZE_HANDLE_EDGE_SMALL : HUDHitPriority.RESIZE_HANDLE_EDGE
1748
- };
1749
- for (const dir of EDGE_DIRS) {
1750
- const rect = edgeRects[dir];
1751
- if (rect.width <= 0 || rect.height <= 0) continue;
1752
- zones.push({
1753
- rect,
1754
- priority: edge_priority[dir],
1755
- role: {
1756
- kind: "resize_edge",
1757
- direction: dir
1758
- },
1759
- label: EDGE_LABEL[dir]
1760
- });
1761
- }
1762
- if (opts.show_rotation) for (const dir of CORNER_DIRS) {
1763
- const resize = cornerRects[dir];
1764
- if (resize.width <= 0 || resize.height <= 0) continue;
1765
- const [dx, dy] = cmath.compass.cardinal_direction_vector[dir];
1766
- zones.push({
1767
- rect: {
1768
- x: resize.x + (dx > 0 ? 0 : -16),
1769
- y: resize.y + (dy > 0 ? 0 : -16),
1770
- width: resize.width + 16,
1771
- height: resize.height + 16
1772
- },
1773
- priority: HUDHitPriority.ROTATE_HANDLE,
1774
- role: {
1775
- kind: "rotate",
1776
- corner: dir
1777
- },
1778
- label: ROTATE_LABEL[dir]
1779
- });
1780
- }
1781
- return {
1782
- zones,
1783
- controls_visible,
1784
- small_mode
1785
- };
1786
- }
1787
- //#endregion
1788
- //#region surface/chrome.ts
1789
- /**
1790
- * Build the per-frame surface chrome.
1791
- *
1792
- * Returns a pair:
1793
- * - `overlays` — interactable elements (handles, endpoint knobs, rotation
1794
- * regions). Each pairs a hit shape with an optional render shape.
1795
- * - `decoration` — pure visual `HUDDraw` (selection outlines, hover outline,
1796
- * marquee, line outlines). Not interactable.
1797
- *
1798
- * Priority is data, not iteration order. Each `OverlayElement` carries its
1799
- * own `priority` (lower wins) and a stable `label`. The `HitRegions`
1800
- * registry resolves overlapping regions by priority, not push order. See
1801
- * `event/selection-controls.ts` for the canonical priority ladder.
1802
- *
1803
- * The Surface fans `overlays` into `HitRegions` (for events) and merges
1804
- * their render shapes into `decoration` (for the canvas draw call).
1805
- */
1806
- function buildChrome(input) {
1807
- const { state, shapeOf, style, groups } = input;
1808
- const transform = state.getTransform();
1809
- const overlays = [];
1810
- const decoration_rects = [];
1811
- const decoration_lines = [];
1812
- const decoration_polylines = [];
1813
- const hover_id = state.gesture.kind === "idle" ? state.getEffectiveHover() : null;
1814
- if (hover_id) {
1815
- const shape = shapeOf(hover_id);
1816
- if (shape) pushShapeOutline(shape, decoration_rects, decoration_lines, decoration_polylines, {
1817
- dashed: false,
1818
- strokeWidth: style.hoverOutlineWidth,
1819
- group: groups?.hover
1820
- });
1821
- }
1822
- for (const group of state.getSelectionGroups()) {
1823
- const shape = resolveGroupShape(group, shapeOf);
1824
- if (!shape) continue;
1825
- pushShapeOutline(shape, decoration_rects, decoration_lines, decoration_polylines, {
1826
- dashed: false,
1827
- strokeWidth: style.selectionOutlineWidth,
1828
- group: groups?.selection
1829
- });
1830
- if (shape.kind === "rect") pushRectChrome(shape.rect, group.ids, transform, style, groups?.selectionControls, overlays);
1831
- else if (shape.kind === "transformed") pushTransformedChrome(shape.local, shape.matrix, group.ids, transform, style, groups?.selectionControls, overlays);
1832
- else if (shape.kind === "line") {
1833
- pushLineBody(shape, group.ids, transform, groups?.selectionControls, overlays);
1834
- pushLineEndpoints(group.ids[0], shape.p1, shape.p2, style, groups?.selectionControls, overlays);
1835
- }
1836
- }
1837
- if (state.gesture.kind === "marquee") {
1838
- const g = state.gesture;
1839
- const mr = rectFromPoints(g.anchor_doc, g.current_doc);
1840
- decoration_rects.push({
1841
- ...mr,
1842
- stroke: true,
1843
- fill: true,
1844
- fillOpacity: .15,
1845
- group: groups?.marquee
1846
- });
1847
- }
1848
- if (state.gesture.kind === "resize") {
1849
- const shape = state.gesture.current_shape;
1850
- if (shape.kind === "transformed") {
1851
- const corners = cmath.rect.toCorners(shape.local).map((p) => cmath.vector2.transform(p, shape.matrix));
1852
- decoration_polylines.push({
1853
- points: [...corners, corners[0]],
1854
- stroke: true,
1855
- fill: false,
1856
- dashed: true,
1857
- group: groups?.transformPreview
1858
- });
1859
- } else decoration_rects.push({
1860
- ...shapeBounds(shape),
1861
- stroke: true,
1862
- fill: false,
1863
- dashed: true,
1864
- group: groups?.transformPreview
1865
- });
1866
- }
1867
- return {
1868
- overlays,
1869
- decoration: {
1870
- rects: decoration_rects.length > 0 ? decoration_rects : void 0,
1871
- lines: decoration_lines.length > 0 ? decoration_lines : void 0,
1872
- polylines: decoration_polylines.length > 0 ? decoration_polylines : void 0
1873
- }
1874
- };
1875
- }
1876
- function resolveGroupShape(group, shapeOf) {
1877
- if (group.shape.kind === "unresolved") return shapeOf(group.shape.id);
1878
- return group.shape;
1879
- }
1880
- function pushRectChrome(rect_doc, ids, transform, style, group, out) {
1881
- const layout = computeSelectionControlLayout(cmath.rect.transform(rect_doc, transform), {
1882
- handle_size: style.handleSize,
1883
- show_rotation: style.showRotationHandles && ids.length >= 1
1884
- });
1885
- for (const zone of layout.zones) {
1886
- const el = zoneToOverlay(zone, rect_doc, ids, style, group, layout.controls_visible);
1887
- if (el) out.push(el);
1888
- }
1889
- }
1890
- function zoneToOverlay(zone, rect_doc, ids, style, group, controls_visible) {
1891
- switch (zone.role.kind) {
1892
- case "translate": return {
1893
- label: zone.label,
1894
- group,
1895
- action: {
1896
- kind: "translate_handle",
1897
- ids
1898
- },
1899
- hit: {
1900
- kind: "screen_aabb",
1901
- rect: zone.rect
1902
- },
1903
- priority: zone.priority,
1904
- cursor: "move"
1905
- };
1906
- case "resize_corner": {
1907
- const dir = zone.role.direction;
1908
- const size = style.handleSize;
1909
- const anchor_doc = cmath.rect.getCardinalPoint(rect_doc, dir);
1910
- return {
1911
- label: zone.label,
1912
- group,
1913
- action: {
1914
- kind: "resize_handle",
1915
- direction: dir,
1916
- ids,
1917
- initial_shape: {
1918
- kind: "rect",
1919
- rect: rect_doc
1920
- }
1921
- },
1922
- hit: {
1923
- kind: "screen_aabb",
1924
- rect: zone.rect
1925
- },
1926
- render: controls_visible ? {
1927
- kind: "screen_rect",
1928
- anchor_doc,
1929
- width: size,
1930
- height: size,
1931
- placement: "center",
1932
- fill: true,
1933
- stroke: true,
1934
- fillColor: style.handleFill,
1935
- strokeColor: style.handleStroke
1936
- } : void 0,
1937
- priority: zone.priority,
1938
- cursor: {
1939
- kind: "resize",
1940
- direction: dir
1941
- }
1942
- };
1943
- }
1944
- case "resize_edge": return {
1945
- label: zone.label,
1946
- group,
1947
- action: {
1948
- kind: "resize_handle",
1949
- direction: zone.role.direction,
1950
- ids,
1951
- initial_shape: {
1952
- kind: "rect",
1953
- rect: rect_doc
1954
- }
1955
- },
1956
- hit: {
1957
- kind: "screen_aabb",
1958
- rect: zone.rect
1959
- },
1960
- priority: zone.priority,
1961
- cursor: {
1962
- kind: "resize",
1963
- direction: zone.role.direction
1964
- }
1965
- };
1966
- case "rotate": return {
1967
- label: zone.label,
1968
- group,
1969
- action: {
1970
- kind: "rotate_handle",
1971
- corner: zone.role.corner,
1972
- ids,
1973
- initial_shape: {
1974
- kind: "rect",
1975
- rect: rect_doc
1976
- }
1977
- },
1978
- hit: {
1979
- kind: "screen_aabb",
1980
- rect: zone.rect
1981
- },
1982
- priority: zone.priority,
1983
- cursor: {
1984
- kind: "rotate",
1985
- corner: zone.role.corner
1986
- }
1987
- };
1988
- }
1989
- }
1990
- function pushTransformedChrome(local, matrix, ids, camera, style, group, out) {
1991
- const local_to_screen = cmath.transform.multiply(camera, matrix);
1992
- const scale_xy = cmath.transform.getScale(local_to_screen);
1993
- const angle_deg = cmath.transform.angle(local_to_screen);
1994
- const angle_rad = angle_deg * Math.PI / 180;
1995
- const screen_w = local.width * scale_xy[0];
1996
- const screen_h = local.height * scale_xy[1];
1997
- const local_center = [local.x + local.width / 2, local.y + local.height / 2];
1998
- const screen_center = cmath.vector2.transform(local_center, local_to_screen);
1999
- const layout = computeSelectionControlLayout({
2000
- x: screen_center[0] - screen_w / 2,
2001
- y: screen_center[1] - screen_h / 2,
2002
- width: screen_w,
2003
- height: screen_h
2004
- }, {
2005
- handle_size: style.handleSize,
2006
- show_rotation: style.showRotationHandles && ids.length >= 1
2007
- });
2008
- const inverse_transform = cmath.transform.rotate(cmath.transform.identity, -angle_deg, screen_center);
2009
- const initial_shape = {
2010
- kind: "transformed",
2011
- local,
2012
- matrix
2013
- };
2014
- for (const zone of layout.zones) {
2015
- const hit = {
2016
- kind: "screen_obb",
2017
- rect: zone.rect,
2018
- inverse_transform
2019
- };
2020
- switch (zone.role.kind) {
2021
- case "translate":
2022
- out.push({
2023
- label: zone.label,
2024
- group,
2025
- action: {
2026
- kind: "translate_handle",
2027
- ids
2028
- },
2029
- hit,
2030
- priority: zone.priority,
2031
- cursor: "move"
2032
- });
2033
- break;
2034
- case "resize_corner": {
2035
- const dir = zone.role.direction;
2036
- const size = style.handleSize;
2037
- const cardinal_local = cmath.rect.getCardinalPoint(local, dir);
2038
- const anchor_doc = cmath.vector2.transform(cardinal_local, matrix);
2039
- out.push({
2040
- label: zone.label,
2041
- group,
2042
- action: {
2043
- kind: "resize_handle",
2044
- direction: dir,
2045
- ids,
2046
- initial_shape
2047
- },
2048
- hit,
2049
- render: layout.controls_visible ? {
2050
- kind: "screen_rect",
2051
- anchor_doc,
2052
- width: size,
2053
- height: size,
2054
- placement: "center",
2055
- fill: true,
2056
- stroke: true,
2057
- fillColor: style.handleFill,
2058
- strokeColor: style.handleStroke,
2059
- angle: angle_rad
2060
- } : void 0,
2061
- priority: zone.priority,
2062
- cursor: {
2063
- kind: "resize",
2064
- direction: dir
2065
- }
2066
- });
2067
- break;
2068
- }
2069
- case "resize_edge":
2070
- out.push({
2071
- label: zone.label,
2072
- group,
2073
- action: {
2074
- kind: "resize_handle",
2075
- direction: zone.role.direction,
2076
- ids,
2077
- initial_shape
2078
- },
2079
- hit,
2080
- priority: zone.priority,
2081
- cursor: {
2082
- kind: "resize",
2083
- direction: zone.role.direction
2084
- }
2085
- });
2086
- break;
2087
- case "rotate":
2088
- out.push({
2089
- label: zone.label,
2090
- group,
2091
- action: {
2092
- kind: "rotate_handle",
2093
- corner: zone.role.corner,
2094
- ids,
2095
- initial_shape
2096
- },
2097
- hit,
2098
- priority: zone.priority,
2099
- cursor: {
2100
- kind: "rotate",
2101
- corner: zone.role.corner
2102
- }
2103
- });
2104
- break;
2105
- }
2106
- }
2107
- }
2108
- function pushLineBody(shape, ids, transform, group, out) {
2109
- const bounds_doc = shapeBounds(shape);
2110
- const rect_screen = cmath.rect.transform(bounds_doc, transform);
2111
- if (rect_screen.width < 1 && rect_screen.height < 1) return;
2112
- const hitW = Math.max(rect_screen.width, 16);
2113
- const hitH = Math.max(rect_screen.height, 16);
2114
- const hitRect = {
2115
- x: rect_screen.x - (hitW - rect_screen.width) / 2,
2116
- y: rect_screen.y - (hitH - rect_screen.height) / 2,
2117
- width: hitW,
2118
- height: hitH
2119
- };
2120
- out.push({
2121
- label: "translate",
2122
- group,
2123
- action: {
2124
- kind: "translate_handle",
2125
- ids
2126
- },
2127
- hit: {
2128
- kind: "screen_aabb",
2129
- rect: hitRect
2130
- },
2131
- priority: HUDHitPriority.TRANSLATE_BODY,
2132
- cursor: "move"
2133
- });
2134
- }
2135
- function pushLineEndpoints(id, p1, p2, style, group, out) {
2136
- const size = style.handleSize;
2137
- const hit_size = Math.max(size + 4, 16);
2138
- const endpoints = [{
2139
- which: "p1",
2140
- pos: p1
2141
- }, {
2142
- which: "p2",
2143
- pos: p2
2144
- }];
2145
- for (const ep of endpoints) out.push({
2146
- label: `endpoint:${ep.which}`,
2147
- group,
2148
- action: {
2149
- kind: "endpoint_handle",
2150
- endpoint: ep.which,
2151
- id,
2152
- p1: [p1[0], p1[1]],
2153
- p2: [p2[0], p2[1]]
2154
- },
2155
- hit: {
2156
- kind: "screen_rect_at_doc",
2157
- anchor_doc: ep.pos,
2158
- width: hit_size,
2159
- height: hit_size,
2160
- placement: "center"
2161
- },
2162
- render: {
2163
- kind: "screen_rect",
2164
- anchor_doc: ep.pos,
2165
- width: size,
2166
- height: size,
2167
- placement: "center",
2168
- fill: true,
2169
- stroke: true,
2170
- fillColor: style.handleFill,
2171
- strokeColor: style.handleStroke
2172
- },
2173
- priority: HUDHitPriority.ENDPOINT_HANDLE,
2174
- cursor: "pointer"
2175
- });
2176
- }
2177
- function pushShapeOutline(shape, rects, lines, polylines, opts) {
2178
- if (shape.kind === "rect") rects.push({
2179
- ...shape.rect,
2180
- stroke: true,
2181
- fill: false,
2182
- dashed: opts.dashed,
2183
- strokeWidth: opts.strokeWidth,
2184
- group: opts.group
2185
- });
2186
- else if (shape.kind === "transformed") {
2187
- const corners_doc = cmath.rect.toCorners(shape.local).map((p) => cmath.vector2.transform(p, shape.matrix));
2188
- polylines.push({
2189
- points: [...corners_doc, corners_doc[0]],
2190
- stroke: true,
2191
- fill: false,
2192
- dashed: opts.dashed,
2193
- group: opts.group
2194
- });
2195
- } else if (shape.kind === "line") lines.push({
2196
- x1: shape.p1[0],
2197
- y1: shape.p1[1],
2198
- x2: shape.p2[0],
2199
- y2: shape.p2[1],
2200
- dashed: opts.dashed,
2201
- strokeWidth: opts.strokeWidth,
2202
- group: opts.group
2203
- });
2204
- }
2205
- /**
2206
- * Fan a list of `OverlayElement`s into per-primitive render arrays and into
2207
- * the hit-region registry. Returns the additional render primitives that
2208
- * should be merged with the decoration `HUDDraw`.
2209
- *
2210
- * Priority and label are forwarded verbatim from each overlay element to
2211
- * the registered HitRegion — the registry resolves overlaps by priority.
2212
- */
2213
- function fanOverlays(overlays, transform, regions) {
2214
- regions.clear();
2215
- const screenRects = [];
2216
- for (const el of overlays) {
2217
- const projected = projectHit(el.hit, transform);
2218
- regions.push({
2219
- rect: projected.rect,
2220
- inverse_transform: projected.inverse_transform,
2221
- action: el.action,
2222
- priority: el.priority,
2223
- label: el.label
2224
- });
2225
- if (!el.render) continue;
2226
- if (el.render.kind === "screen_rect") screenRects.push({
2227
- x: el.render.anchor_doc[0],
2228
- y: el.render.anchor_doc[1],
2229
- width: el.render.width,
2230
- height: el.render.height,
2231
- anchor: el.render.placement,
2232
- fill: el.render.fill,
2233
- stroke: el.render.stroke,
2234
- fillColor: el.render.fillColor,
2235
- strokeColor: el.render.strokeColor,
2236
- angle: el.render.angle,
2237
- group: el.group
2238
- });
2239
- }
2240
- return { screenRects };
2241
- }
2242
- function projectHit(hit, transform) {
2243
- if (hit.kind === "screen_aabb") return { rect: hit.rect };
2244
- if (hit.kind === "screen_obb") return {
2245
- rect: hit.rect,
2246
- inverse_transform: hit.inverse_transform
2247
- };
2248
- const [sx, sy] = docToScreen(transform, hit.anchor_doc[0], hit.anchor_doc[1]);
2249
- const placement = hit.placement ?? "center";
2250
- let x = sx;
2251
- let y = sy;
2252
- switch (placement) {
2253
- case "center":
2254
- x = sx - hit.width / 2;
2255
- y = sy - hit.height / 2;
2256
- break;
2257
- case "tl":
2258
- x = sx;
2259
- y = sy;
2260
- break;
2261
- case "tr":
2262
- x = sx - hit.width;
2263
- y = sy;
2264
- break;
2265
- case "bl":
2266
- x = sx;
2267
- y = sy - hit.height;
2268
- break;
2269
- case "br":
2270
- x = sx - hit.width;
2271
- y = sy - hit.height;
2272
- break;
2273
- }
2274
- return { rect: {
2275
- x,
2276
- y,
2277
- width: hit.width,
2278
- height: hit.height
2279
- } };
2280
- }
2281
- /**
2282
- * Merge a base `HUDDraw` (chrome decoration) with optional host-fed extras
2283
- * and surface-owned interactable screenRects into a single command list.
2284
- */
2285
- function mergeDraws(base, extra, screenRects) {
2286
- return {
2287
- lines: cat(base.lines, extra?.lines),
2288
- rules: cat(base.rules, extra?.rules),
2289
- points: cat(base.points, extra?.points),
2290
- rects: cat(base.rects, extra?.rects),
2291
- polylines: cat(base.polylines, extra?.polylines),
2292
- screenRects: cat(cat(base.screenRects, extra?.screenRects), screenRects)
2293
- };
2294
- }
2295
- /**
2296
- * Concatenate two optional arrays. Returns the non-empty side directly when
2297
- * the other is empty — no defensive copy on the hot draw path.
2298
- */
2299
- function cat(a, b) {
2300
- if (!b || b.length === 0) return a;
2301
- if (!a || a.length === 0) return b;
2302
- return [...a, ...b];
2303
- }
2304
- //#endregion
2305
- //#region surface/surface.ts
2306
- /**
2307
- * Top-level wired surface.
2308
- *
2309
- * Owns an internal `HUDCanvas`, a `SurfaceState` (gesture/hover/...) and
2310
- * the host providers. On every `dispatch`, the state machine runs;
2311
- * `draw` composes surface chrome + host-fed extras into a single canvas
2312
- * paint.
2313
- */
2314
- var Surface = class {
2315
- constructor(canvas, options) {
2316
- this.width = 0;
2317
- this.height = 0;
2318
- this.cursor_renderer = null;
2319
- this.opts = options;
2320
- this.style = mergeStyle(DEFAULT_STYLE, options.style);
2321
- this.colorOverride = options.color;
2322
- this.hudCanvas = new HUDCanvas(canvas, { color: this.colorOverride ?? this.style.chromeColor });
2323
- this.state = new SurfaceState();
2324
- if (options.readonly !== void 0) this.state.setReadonly(options.readonly);
2325
- if (options.pixelGrid) this.hudCanvas.setPixelGrid(options.pixelGrid);
2326
- }
2327
- /** Configure / disable the back-most pixel-grid layer. */
2328
- setPixelGrid(config) {
2329
- this.hudCanvas.setPixelGrid(config);
2330
- }
2331
- /**
2332
- * Update just the pixel grid's transform. Cheap to call per camera tick.
2333
- * No-op when no pixel-grid config is set.
2334
- */
2335
- setPixelGridTransform(transform) {
2336
- this.hudCanvas.setPixelGridTransform(transform);
2337
- }
2338
- setSize(w, h) {
2339
- this.width = w;
2340
- this.height = h;
2341
- this.hudCanvas.setSize(w, h);
2342
- }
2343
- setTransform(t) {
2344
- this.state.setTransform(t);
2345
- this.hudCanvas.setTransform(t);
2346
- }
2347
- /**
2348
- * Push a new selection from the host.
2349
- *
2350
- * Accepts either:
2351
- * - `NodeId[]` — each id becomes its own single-member group, shape
2352
- * resolved via `shapeOf(id)` by the chrome builder.
2353
- * - `SelectionGroup[]` — pre-computed groups with their union shape.
2354
- *
2355
- * See `SurfaceState.setSelection` for details.
2356
- */
2357
- setSelection(input) {
2358
- this.state.setSelection(input);
2359
- }
2360
- setStyle(partial) {
2361
- this.style = mergeStyle(this.style, partial);
2362
- this.hudCanvas.setColor(this.colorOverride ?? this.style.chromeColor);
2363
- }
2364
- /**
2365
- * Set or clear the host color override. `null` clears the override and
2366
- * lets `style.chromeColor` win on the next paint.
2367
- */
2368
- setColor(color) {
2369
- this.colorOverride = color ?? void 0;
2370
- this.hudCanvas.setColor(this.colorOverride ?? this.style.chromeColor);
2371
- }
2372
- setReadonly(v) {
2373
- this.state.setReadonly(v);
2374
- }
2375
- /**
2376
- * Set or clear a host-driven hover override.
2377
- *
2378
- * The surface tracks two hover sources:
2379
- * - **Pointer pick** — what scene content is under the cursor (updated
2380
- * automatically on `pointer_move`).
2381
- * - **Host override** — what the host wants to show as hovered, e.g.
2382
- * from a layers panel row mouseenter.
2383
- *
2384
- * The override (when non-null) wins. `hover()` returns the effective
2385
- * value; chrome renders the effective value. Pass `null` to clear and
2386
- * fall back to pointer pick.
2387
- *
2388
- * Returns the same response shape as `dispatch` so the host can react
2389
- * to whether anything actually changed.
2390
- */
2391
- setHoverOverride(id) {
2392
- return this.state.setHoverOverride(id);
2393
- }
2394
- dispose() {}
2395
- dispatch(event) {
2396
- return this.state.dispatch(event, {
2397
- pick: this.opts.pick,
2398
- shapeOf: this.opts.shapeOf,
2399
- emitIntent: this.opts.onIntent
2400
- });
2401
- }
2402
- draw(extra) {
2403
- const { overlays, decoration } = buildChrome({
2404
- state: this.state,
2405
- shapeOf: this.opts.shapeOf,
2406
- style: this.style,
2407
- groups: this.opts.groups,
2408
- width: this.width,
2409
- height: this.height
2410
- });
2411
- const hidden = this.opts.visibility?.({ gesture: this.state.gesture })?.hidden;
2412
- const { screenRects } = fanOverlays(filterOverlaysByGroup(overlays, hidden), this.state.getTransform(), this.state.hitRegions());
2413
- this.hudCanvas.draw(filterHUDDrawByGroup(mergeDraws(decoration, extra, screenRects), { hidden }));
2414
- }
2415
- /** Convenience: clear the canvas (e.g. when the host stops the surface). */
2416
- clear() {
2417
- this.hudCanvas.draw(void 0);
2418
- }
2419
- gesture() {
2420
- return this.state.gesture;
2421
- }
2422
- /**
2423
- * The effective hover: host override (when set) wins over pointer pick.
2424
- * Use this for chrome decisions and host-side reads.
2425
- */
2426
- hover() {
2427
- return this.state.getEffectiveHover();
2428
- }
2429
- cursor() {
2430
- return this.state.cursor;
2431
- }
2432
- /**
2433
- * Resolve the current cursor to a CSS `cursor:` value. Runs the
2434
- * installed renderer (or the built-in `cursorToCss` if none installed).
2435
- *
2436
- * Host wires it like:
2437
- *
2438
- * const r = surface.dispatch(event);
2439
- * if (r.cursorChanged) el.style.cursor = surface.cursorCss();
2440
- *
2441
- * Saves the host from re-importing `cursorToCss` after every dispatch
2442
- * and gives one place to change behavior when a renderer is swapped in.
2443
- */
2444
- cursorCss() {
2445
- return (this.cursor_renderer ?? cursorToCss)(this.state.cursor);
2446
- }
2447
- /**
2448
- * Install (or clear) a custom cursor renderer.
2449
- *
2450
- * `null` restores the built-in `cursorToCss` behavior (native CSS
2451
- * keywords for every variant). Pass `cursors.defaultRenderer()` from
2452
- * `@grida/hud/cursors` for the bundled SVG cursor set.
2453
- *
2454
- * Re-callable mid-session; the next `cursorCss()` reads the new value.
2455
- */
2456
- setCursorRenderer(fn) {
2457
- this.cursor_renderer = fn;
2458
- }
2459
- modifiers() {
2460
- return this.state.modifiers;
2461
- }
2462
- };
2463
- function filterOverlaysByGroup(overlays, hidden) {
2464
- const hidden_set = new Set(hidden ?? []);
2465
- if (hidden_set.size === 0) return overlays;
2466
- return overlays.filter((overlay) => {
2467
- return !overlay.group || !hidden_set.has(overlay.group);
2468
- });
2469
- }
2470
- //#endregion
2471
- export { DEFAULT_PIXEL_GRID_STEPS as _, computeSelectionControlLayout as a, MIN_HIT_SIZE as c, marqueeToHUDDraw as d, measurementToHUDDraw as f, DEFAULT_PIXEL_GRID_COLOR as g, HUDCanvas as h, MIN_GUARANTEED_INTERACTIVE_DIM as i, NO_MODS as l, filterHUDDrawByGroup as m, BODY_FLIP_THRESHOLD as n, negotiateAxis as o, snapGuideToHUDDraw as p, HUDHitPriority as r, MIN_CHROME_VISIBLE_SIZE as s, Surface as t, lassoToHUDDraw as u, drawPixelGrid as v };