@grida/hud 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,9 +20,42 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
20
20
  enumerable: true
21
21
  }) : target, mod));
22
22
  //#endregion
23
+ const require_cursor = require("./cursor-DsP9qtN2.js");
23
24
  let _grida_cmath__measurement = require("@grida/cmath/_measurement");
24
25
  let _grida_cmath = require("@grida/cmath");
25
26
  _grida_cmath = __toESM(_grida_cmath);
27
+ //#region primitives/pixel-grid.ts
28
+ const DEFAULT_PIXEL_GRID_COLOR = "rgba(150, 150, 150, 0.15)";
29
+ const DEFAULT_PIXEL_GRID_STEPS = [1, 1];
30
+ function drawPixelGrid(p) {
31
+ const { ctx, transform, width, height, dpr, color = DEFAULT_PIXEL_GRID_COLOR, steps = DEFAULT_PIXEL_GRID_STEPS } = p;
32
+ ctx.save();
33
+ const [[sx, , tx], [, sy, ty]] = transform;
34
+ ctx.setTransform(sx * dpr, 0, 0, sy * dpr, tx * dpr, ty * dpr);
35
+ ctx.strokeStyle = color;
36
+ ctx.lineWidth = 1 / Math.max(Math.abs(sx * dpr), Math.abs(sy * dpr));
37
+ const minUserX = (0 - tx * dpr) / (sx * dpr);
38
+ const maxUserX = (width * dpr - tx * dpr) / (sx * dpr);
39
+ const minUserY = (0 - ty * dpr) / (sy * dpr);
40
+ const maxUserY = (height * dpr - ty * dpr) / (sy * dpr);
41
+ const [stepX, stepY] = steps;
42
+ const startX = Math.floor(minUserX / stepX) * stepX - 2 * stepX;
43
+ const endX = Math.ceil(maxUserX / stepX) * stepX + 2 * stepX;
44
+ const startY = Math.floor(minUserY / stepY) * stepY - 2 * stepY;
45
+ const endY = Math.ceil(maxUserY / stepY) * stepY + 2 * stepY;
46
+ ctx.beginPath();
47
+ for (let x = startX; x <= endX; x += stepX) {
48
+ ctx.moveTo(x, startY);
49
+ ctx.lineTo(x, endY);
50
+ }
51
+ for (let y = startY; y <= endY; y += stepY) {
52
+ ctx.moveTo(startX, y);
53
+ ctx.lineTo(endX, y);
54
+ }
55
+ ctx.stroke();
56
+ ctx.restore();
57
+ }
58
+ //#endregion
26
59
  //#region primitives/canvas.ts
27
60
  const DEFAULT_COLOR = "#f44336";
28
61
  const DEFAULT_LABEL_FG = "#ffffff";
@@ -60,6 +93,7 @@ var HUDCanvas = class {
60
93
  ]];
61
94
  this.width = 0;
62
95
  this.height = 0;
96
+ this.pixelGrid = null;
63
97
  this.ctx = canvas.getContext("2d");
64
98
  this.dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
65
99
  this.color = options?.color ?? DEFAULT_COLOR;
@@ -82,6 +116,31 @@ var HUDCanvas = class {
82
116
  this.transform = transform;
83
117
  }
84
118
  /**
119
+ * Configure the back-most pixel-grid layer. Pass `null` to disable.
120
+ * Drawn before any HUD primitive, gated by `zoomThreshold`. See
121
+ * `PixelGridConfig.transform` for the two-transform contract.
122
+ */
123
+ setPixelGrid(config) {
124
+ if (config === null) {
125
+ this.pixelGrid = null;
126
+ return;
127
+ }
128
+ this.pixelGrid = {
129
+ ...config,
130
+ transform: config.transform ?? this.pixelGrid?.transform
131
+ };
132
+ }
133
+ /**
134
+ * Update only the pixel grid's transform, without replacing the rest of
135
+ * the config. Cheap to call per camera tick.
136
+ */
137
+ setPixelGridTransform(transform) {
138
+ if (this.pixelGrid) this.pixelGrid = {
139
+ ...this.pixelGrid,
140
+ transform
141
+ };
142
+ }
143
+ /**
85
144
  * Clear the canvas and draw all primitives in `commands`.
86
145
  * Pass `undefined` to clear without drawing (e.g. when no overlay is active).
87
146
  */
@@ -89,6 +148,17 @@ var HUDCanvas = class {
89
148
  const ctx = this.ctx;
90
149
  ctx.setTransform(1, 0, 0, 1, 0, 0);
91
150
  ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
151
+ const pg = this.pixelGrid;
152
+ const pgTransform = pg?.transform ?? this.transform;
153
+ if (pg?.enabled && pgTransform[0][0] > pg.zoomThreshold) drawPixelGrid({
154
+ ctx,
155
+ transform: pgTransform,
156
+ width: this.width,
157
+ height: this.height,
158
+ dpr: this.dpr,
159
+ color: pg.color,
160
+ steps: pg.steps
161
+ });
92
162
  if (!commands) return;
93
163
  const { lines, rules, points, rects, polylines, screenRects } = commands;
94
164
  if (rules && rules.length > 0) this.drawRules(rules);
@@ -114,12 +184,12 @@ var HUDCanvas = class {
114
184
  drawRules(rules) {
115
185
  const ctx = this.ctx;
116
186
  this.applyScreenTransform();
117
- ctx.strokeStyle = this.color;
118
187
  ctx.lineWidth = DEFAULT_LINE_WIDTH;
119
- for (const { axis, offset } of rules) {
120
- const screenOffset = this.deltaToScreen(offset, axis);
188
+ for (const rule of rules) {
189
+ const screenOffset = this.deltaToScreen(rule.offset, rule.axis);
190
+ ctx.strokeStyle = rule.color ?? this.color;
121
191
  ctx.beginPath();
122
- if (axis === "x") {
192
+ if (rule.axis === "x") {
123
193
  ctx.moveTo(screenOffset, 0);
124
194
  ctx.lineTo(screenOffset, this.height);
125
195
  } else {
@@ -173,17 +243,29 @@ var HUDCanvas = class {
173
243
  const midY = (line.y1 + line.y2) / 2;
174
244
  const lx = sx * midX + tx;
175
245
  const ly = sy * midY + ty;
246
+ const angle = line.labelAngle ?? 0;
176
247
  const isVertical = Math.abs(line.x2 - line.x1) < Math.abs(line.y2 - line.y1);
177
- const labelX = isVertical ? lx + LABEL_OFFSET : lx;
178
- const labelY = isVertical ? ly : ly + LABEL_OFFSET;
248
+ const baseOffsetX = isVertical ? LABEL_OFFSET : 0;
249
+ const baseOffsetY = isVertical ? 0 : LABEL_OFFSET;
250
+ const cos = Math.cos(angle);
251
+ const sin = Math.sin(angle);
252
+ const labelX = lx + baseOffsetX * cos - baseOffsetY * sin;
253
+ const labelY = ly + baseOffsetX * sin + baseOffsetY * cos;
179
254
  const tw = ctx.measureText(line.label).width + LABEL_PADDING_X * 2;
180
255
  const th = LABEL_FONT_HEIGHT + LABEL_PADDING_Y * 2;
256
+ if (angle !== 0) {
257
+ ctx.save();
258
+ ctx.translate(labelX, labelY);
259
+ ctx.rotate(angle);
260
+ ctx.translate(-labelX, -labelY);
261
+ }
181
262
  ctx.fillStyle = line.color ?? this.color;
182
263
  ctx.beginPath();
183
264
  ctx.roundRect(labelX - tw / 2, labelY - th / 2, tw, th, LABEL_BORDER_RADIUS);
184
265
  ctx.fill();
185
266
  ctx.fillStyle = DEFAULT_LABEL_FG;
186
267
  ctx.fillText(line.label, labelX, labelY);
268
+ if (angle !== 0) ctx.restore();
187
269
  }
188
270
  }
189
271
  drawRects(rects) {
@@ -246,20 +328,29 @@ var HUDCanvas = class {
246
328
  drawPoints(points) {
247
329
  const ctx = this.ctx;
248
330
  this.applyScreenTransform();
249
- ctx.strokeStyle = this.color;
250
331
  ctx.lineWidth = DEFAULT_LINE_WIDTH;
251
332
  const half = CROSSHAIR_SIZE / 2;
252
333
  const [[sx, , tx], [, sy, ty]] = this.transform;
253
- ctx.beginPath();
254
- for (const [px, py] of points) {
255
- const scrX = sx * px + tx;
256
- const scrY = sy * py + ty;
257
- ctx.moveTo(scrX - half, scrY - half);
258
- ctx.lineTo(scrX + half, scrY + half);
259
- ctx.moveTo(scrX + half, scrY - half);
260
- ctx.lineTo(scrX - half, scrY + half);
334
+ const buckets = /* @__PURE__ */ new Map();
335
+ for (const p of points) {
336
+ const c = p.color ?? this.color;
337
+ const arr = buckets.get(c);
338
+ if (arr) arr.push(p);
339
+ else buckets.set(c, [p]);
340
+ }
341
+ for (const [color, group] of buckets) {
342
+ ctx.strokeStyle = color;
343
+ ctx.beginPath();
344
+ for (const p of group) {
345
+ const scrX = sx * p.x + tx;
346
+ const scrY = sy * p.y + ty;
347
+ ctx.moveTo(scrX - half, scrY - half);
348
+ ctx.lineTo(scrX + half, scrY + half);
349
+ ctx.moveTo(scrX + half, scrY - half);
350
+ ctx.lineTo(scrX - half, scrY + half);
351
+ }
352
+ ctx.stroke();
261
353
  }
262
- ctx.stroke();
263
354
  }
264
355
  /**
265
356
  * Draw rects whose **size is in screen-space** but whose **anchor is in
@@ -306,6 +397,15 @@ var HUDCanvas = class {
306
397
  }
307
398
  const doFill = r.fill !== false;
308
399
  const doStroke = r.stroke !== false;
400
+ const angle = r.angle ?? 0;
401
+ if (angle !== 0) {
402
+ const cx = x + w / 2;
403
+ const cy = y + h / 2;
404
+ ctx.save();
405
+ ctx.translate(cx, cy);
406
+ ctx.rotate(angle);
407
+ ctx.translate(-cx, -cy);
408
+ }
309
409
  if (doFill) {
310
410
  ctx.fillStyle = r.fillColor ?? this.color;
311
411
  ctx.fillRect(x, y, w, h);
@@ -314,28 +414,67 @@ var HUDCanvas = class {
314
414
  ctx.strokeStyle = r.strokeColor ?? this.color;
315
415
  ctx.strokeRect(x, y, w, h);
316
416
  }
417
+ if (angle !== 0) ctx.restore();
317
418
  }
318
419
  }
319
420
  };
320
421
  //#endregion
422
+ //#region primitives/draw.ts
423
+ /**
424
+ * Filter a draw command list by semantic group.
425
+ *
426
+ * Ungrouped primitives are always kept. The function is intentionally shallow:
427
+ * primitives are immutable command objects on the hot draw path, so preserving
428
+ * object identity keeps this as a visibility pass rather than a rewrite.
429
+ */
430
+ function filterHUDDrawByGroup(draw, filter) {
431
+ if (!draw) return void 0;
432
+ const hidden = new Set(filter.hidden ?? []);
433
+ if (hidden.size === 0) return draw;
434
+ const out = {};
435
+ out.lines = keepVisible(draw.lines, hidden);
436
+ out.rules = keepVisible(draw.rules, hidden);
437
+ out.points = keepVisible(draw.points, hidden);
438
+ out.rects = keepVisible(draw.rects, hidden);
439
+ out.polylines = keepVisible(draw.polylines, hidden);
440
+ out.screenRects = keepVisible(draw.screenRects, hidden);
441
+ return hasAny(out) ? out : void 0;
442
+ }
443
+ function keepVisible(items, hidden) {
444
+ if (!items || items.length === 0) return void 0;
445
+ const kept = items.filter((item) => !item.group || !hidden.has(item.group));
446
+ return kept.length > 0 ? kept : void 0;
447
+ }
448
+ function hasAny(draw) {
449
+ 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;
450
+ }
451
+ //#endregion
321
452
  //#region primitives/snap-guide.ts
322
453
  /**
323
454
  * Convert a `guide.SnapGuide` (the output of `guide.plot()`) into a
324
455
  * generic {@link HUDDraw} command list.
325
456
  *
326
- * Lines pass through directly (HUDLine extends cmath.ui.Line).
327
- * Points pass through directly (both are cmath.Vector2).
328
- * Rules are destructured from tuples to objects.
457
+ * `color`, when supplied, is applied as the per-item stroke override
458
+ * for every emitted line, rule, and point. When absent, the HUD
459
+ * canvas's current color is used.
329
460
  */
330
- function snapGuideToHUDDraw(sg) {
461
+ function snapGuideToHUDDraw(sg, color) {
331
462
  if (!sg) return void 0;
332
463
  return {
333
- lines: sg.lines,
464
+ lines: sg.lines.map((l) => ({
465
+ ...l,
466
+ color
467
+ })),
334
468
  rules: sg.rules.map(([axis, offset]) => ({
335
469
  axis,
336
- offset
470
+ offset,
471
+ color
337
472
  })),
338
- points: sg.points
473
+ points: sg.points.map(([x, y]) => ({
474
+ x,
475
+ y,
476
+ color
477
+ }))
339
478
  };
340
479
  }
341
480
  //#endregion
@@ -472,15 +611,53 @@ function rectFromPoints(a, b) {
472
611
  return _grida_cmath.default.rect.fromPoints([a, b]);
473
612
  }
474
613
  /**
475
- * Apply a resize handle drag to an initial rect and return the new rect.
614
+ * Apply a resize-handle drag to a `SelectionShape` and return the new shape.
476
615
  *
477
- * `dx, dy` is the total drag delta in document-space, measured from
616
+ * `dx, dy` is the total drag delta in **document-space**, measured from
478
617
  * `anchor_doc` (the pointer-down point) to the current pointer.
479
618
  *
619
+ * - For `kind: "rect"` shapes, the delta applies directly to the doc-space
620
+ * bbox. Same math as the legacy `Rect`-only `applyResize`; no behavior
621
+ * change for axis-aligned hosts.
622
+ * - For `kind: "transformed"` shapes, the doc-space delta is rotated into
623
+ * the shape's **local** frame via the matrix's linear part (the rotation
624
+ * component, translation dropped), then applied to `local`. The matrix
625
+ * itself is preserved — only `local.x/y/width/height` change. Net effect:
626
+ * dragging a corner of a rotated rect extends the artwork along its
627
+ * rotation axis, not along world axes.
628
+ * - For `kind: "line"` and `kind: "unresolved"` the shape is returned
629
+ * unchanged (lines have endpoint-knob gestures, not corner-resize).
630
+ *
480
631
  * No constraints: width/height can go negative — host is responsible for
481
632
  * normalizing if it cares (most callers clamp to a min-size).
482
633
  */
483
634
  function applyResize(initial, direction, dx, dy) {
635
+ if (initial.kind === "rect") return {
636
+ kind: "rect",
637
+ rect: applyResizeRect(initial.rect, direction, dx, dy)
638
+ };
639
+ if (initial.kind === "transformed") {
640
+ const m = initial.matrix;
641
+ const linear = [[
642
+ m[0][0],
643
+ m[0][1],
644
+ 0
645
+ ], [
646
+ m[1][0],
647
+ m[1][1],
648
+ 0
649
+ ]];
650
+ const inv_linear = _grida_cmath.default.transform.invert(linear);
651
+ const [ldx, ldy] = _grida_cmath.default.vector2.transform([dx, dy], inv_linear);
652
+ return {
653
+ kind: "transformed",
654
+ local: applyResizeRect(initial.local, direction, ldx, ldy),
655
+ matrix: m
656
+ };
657
+ }
658
+ return initial;
659
+ }
660
+ function applyResizeRect(initial, direction, dx, dy) {
484
661
  let { x, y, width, height } = initial;
485
662
  switch (direction) {
486
663
  case "n":
@@ -526,16 +703,19 @@ function applyResize(initial, direction, dx, dy) {
526
703
  };
527
704
  }
528
705
  //#endregion
529
- //#region event/cursor.ts
530
- /** Cursor-equality used to detect changes without object allocations. */
531
- function cursorEquals(a, b) {
532
- if (typeof a === "string" && typeof b === "string") return a === b;
533
- if (typeof a !== "string" && typeof b !== "string") {
534
- if (a.kind === "resize" && b.kind === "resize") return a.direction === b.direction;
535
- if (a.kind === "rotate" && b.kind === "rotate") return a.corner === b.corner;
536
- return false;
537
- }
538
- return false;
706
+ //#region event/shape.ts
707
+ /**
708
+ * Compute the axis-aligned bounding box of a `SelectionShape` in doc-space.
709
+ * Used for layout math the chrome builder needs (e.g. handle positions).
710
+ *
711
+ * Throws on `kind: "unresolved"` the chrome builder must resolve those
712
+ * via `shapeOf` first.
713
+ */
714
+ function shapeBounds(shape) {
715
+ if (shape.kind === "rect") return shape.rect;
716
+ if (shape.kind === "transformed") return _grida_cmath.default.rect.transform(shape.local, shape.matrix);
717
+ if (shape.kind === "line") return _grida_cmath.default.rect.fromPoints([shape.p1, shape.p2]);
718
+ throw new Error(`shapeBounds: cannot bound unresolved shape (id=${shape.id})`);
539
719
  }
540
720
  //#endregion
541
721
  //#region event/click-tracker.ts
@@ -579,9 +759,10 @@ function nowMs() {
579
759
  /**
580
760
  * Registry of overlay UI hit regions.
581
761
  *
582
- * Regions are appended in draw order (back-to-front). `hitTest` iterates
583
- * in reverse so the topmost region wins — matching how the chrome is
584
- * visually layered.
762
+ * Regions are appended in declaration order. `hitTest` resolves by
763
+ * **lowest priority value** at the hit point; declaration order
764
+ * serves as tie-break only (later push wins on equal priorities,
765
+ * preserving the prior "topmost wins on overlap" feel).
585
766
  */
586
767
  var HitRegions = class {
587
768
  constructor() {
@@ -594,11 +775,18 @@ var HitRegions = class {
594
775
  this.regions.push(region);
595
776
  }
596
777
  hitTest(point) {
597
- for (let i = this.regions.length - 1; i >= 0; i--) {
598
- const r = this.regions[i];
599
- if (_grida_cmath.default.rect.containsPoint(r.rect, point)) return r.action;
778
+ return this.hitTestRegion(point)?.action ?? null;
779
+ }
780
+ /** Returns the full region (label + priority) used by tests and
781
+ * debug tooling that want to assert on `label`. `hitTest` delegates here. */
782
+ hitTestRegion(point) {
783
+ let best = null;
784
+ for (const r of this.regions) {
785
+ const test_point = r.inverse_transform ? _grida_cmath.default.vector2.transform(point, r.inverse_transform) : point;
786
+ if (!_grida_cmath.default.rect.containsPoint(r.rect, test_point)) continue;
787
+ if (best === null || r.priority <= best.priority) best = r;
600
788
  }
601
- return null;
789
+ return best;
602
790
  }
603
791
  isEmpty() {
604
792
  return this.regions.length === 0;
@@ -611,6 +799,44 @@ var HitRegions = class {
611
799
  //#endregion
612
800
  //#region event/decision.ts
613
801
  /**
802
+ * Selection-intent decision module — pure functions, ZERO side effects.
803
+ *
804
+ * Mental model:
805
+ *
806
+ * The HUD is an event router over real overlay layers. The host creates
807
+ * overlays (resize knob, rotate region, endpoint knob, translate body,
808
+ * etc.); the router decides what each one does on pointer-down by overlay
809
+ * type, falling back to a scene-content pick when no overlay claims.
810
+ * Pointer events are synthesized on top of raw pointer input — no native
811
+ * `click`, no DOM ordering.
812
+ *
813
+ * Architecture:
814
+ *
815
+ * PointerDownInput ─► classifyScenario() ─► Scenario ─► dispatch() ─► PointerDownDecision
816
+ * (recognizer) (declarative table)
817
+ *
818
+ * The `Scenario` enum is a **descriptive label** for each `(overlay hit,
819
+ * pick result, selection, modifiers)` combination — useful for tests and
820
+ * readable dispatch — NOT a contract the HUD imposes on hosts. The
821
+ * contract is per-overlay routing semantics (e.g. resize knob always
822
+ * starts a gesture; translate body always defers) and the Tier-1 → Tier-2
823
+ * fallback when no overlay claims the point.
824
+ *
825
+ * Adding a new UX rule:
826
+ *
827
+ * 1. Add a new `Scenario` constant (or reuse one).
828
+ * 2. Add/update the recognizer branch in {@link classifyScenario}.
829
+ * 3. Add/update the dispatch branch in {@link decidePointerDown}.
830
+ * 4. Add tests pinning the classification and the dispatch.
831
+ * 5. Update the working-group doc.
832
+ *
833
+ * Working-group spec (implementation-agnostic):
834
+ * https://grida.co/docs/wg/feat-editor/ux-surface/selection-intent
835
+ *
836
+ * UX-narrative sibling:
837
+ * https://grida.co/docs/wg/feat-editor/ux-surface/selection
838
+ */
839
+ /**
614
840
  * Recognize which scenario a pointer-down belongs to. Total over inputs.
615
841
  * Pure, no I/O. The single source of truth for "which atomic intent did the
616
842
  * user just express?"
@@ -677,7 +903,7 @@ function dispatch(scenario, input) {
677
903
  kind: "start_resize",
678
904
  ids: a.ids,
679
905
  direction: a.direction,
680
- initial_rect: a.initial_rect
906
+ initial_shape: a.initial_shape
681
907
  };
682
908
  }
683
909
  case "HandleRotate": {
@@ -686,7 +912,7 @@ function dispatch(scenario, input) {
686
912
  kind: "start_rotate",
687
913
  ids: a.ids,
688
914
  corner: a.corner,
689
- initial_rect: a.initial_rect
915
+ initial_shape: a.initial_shape
690
916
  };
691
917
  }
692
918
  case "HandleEndpoint": {
@@ -787,11 +1013,13 @@ function decideIdleCursor(input) {
787
1013
  if (ui_action) switch (ui_action.kind) {
788
1014
  case "resize_handle": return {
789
1015
  kind: "resize",
790
- direction: ui_action.direction
1016
+ direction: ui_action.direction,
1017
+ baseAngle: shape_screen_angle_rad(ui_action.initial_shape)
791
1018
  };
792
1019
  case "rotate_handle": return {
793
1020
  kind: "rotate",
794
- corner: ui_action.corner
1021
+ corner: ui_action.corner,
1022
+ baseAngle: shape_screen_angle_rad(ui_action.initial_shape)
795
1023
  };
796
1024
  case "translate_handle": return "move";
797
1025
  case "select_node":
@@ -800,6 +1028,24 @@ function decideIdleCursor(input) {
800
1028
  if (hovered_id && selection_ids.includes(hovered_id)) return "move";
801
1029
  return "default";
802
1030
  }
1031
+ /**
1032
+ * Doc-space rotation of a `SelectionShape` in radians. For `transformed`
1033
+ * shapes this is the angle baked into `matrix`; for `rect`/`line` it's 0.
1034
+ *
1035
+ * Used by `decideIdleCursor` and by the rotate-gesture cursor compositor
1036
+ * so the resize / rotate cursor always tilts to match the selection's
1037
+ * orientation — without requiring the HUD's camera to be axis-aligned
1038
+ * for the doc-space angle to be the right thing to render (the renderer
1039
+ * draws the cursor in screen px, and in svg-editor the camera contributes
1040
+ * scale + translate only, so doc-space == screen-space rotation).
1041
+ *
1042
+ * For hosts that ROTATE the HUD camera, this should compose with the
1043
+ * camera's angle at consume time. Not relevant for svg-editor.
1044
+ */
1045
+ function shape_screen_angle_rad(shape) {
1046
+ if (shape.kind !== "transformed") return 0;
1047
+ return _grida_cmath.default.transform.angle(shape.matrix) * Math.PI / 180;
1048
+ }
803
1049
  //#endregion
804
1050
  //#region event/transform.ts
805
1051
  const IDENTITY = [[
@@ -1004,16 +1250,17 @@ var SurfaceState = class {
1004
1250
  const g = this.gesture;
1005
1251
  const dx = point_doc[0] - g.anchor_doc[0];
1006
1252
  const dy = point_doc[1] - g.anchor_doc[1];
1007
- const next_rect = applyResize(g.initial_rect, g.direction, dx, dy);
1253
+ const next_shape = applyResize(g.initial_shape, g.direction, dx, dy);
1008
1254
  this.gesture = {
1009
1255
  ...g,
1010
- current_rect: next_rect
1256
+ current_shape: next_shape
1011
1257
  };
1012
1258
  deps.emitIntent({
1013
1259
  kind: "resize",
1014
1260
  ids: g.ids,
1015
1261
  anchor: g.direction,
1016
- rect: next_rect,
1262
+ rect: shapeBounds(next_shape),
1263
+ shape: next_shape,
1017
1264
  phase: "preview"
1018
1265
  });
1019
1266
  response.needsRedraw = true;
@@ -1026,12 +1273,18 @@ var SurfaceState = class {
1026
1273
  ...g,
1027
1274
  current_angle: angle
1028
1275
  };
1276
+ const delta = angle - g.anchor_angle;
1029
1277
  deps.emitIntent({
1030
1278
  kind: "rotate",
1031
1279
  ids: g.ids,
1032
- angle: angle - g.anchor_angle,
1280
+ angle: delta,
1033
1281
  phase: "preview"
1034
1282
  });
1283
+ this.setCursor({
1284
+ kind: "rotate",
1285
+ corner: g.corner,
1286
+ baseAngle: g.initial_cursor_angle + delta
1287
+ }, response);
1035
1288
  response.needsRedraw = true;
1036
1289
  return response;
1037
1290
  }
@@ -1083,23 +1336,30 @@ var SurfaceState = class {
1083
1336
  kind: "resize",
1084
1337
  ids: [...decision.ids],
1085
1338
  direction: decision.direction,
1086
- initial_rect: decision.initial_rect,
1339
+ initial_shape: decision.initial_shape,
1087
1340
  anchor_doc: point_doc,
1088
- current_rect: decision.initial_rect
1341
+ current_shape: decision.initial_shape
1089
1342
  };
1090
1343
  response.needsRedraw = true;
1091
1344
  return response;
1092
1345
  case "start_rotate": {
1093
- const [cx, cy] = _grida_cmath.default.rect.getCenter(decision.initial_rect);
1346
+ const [cx, cy] = _grida_cmath.default.rect.getCenter(shapeBounds(decision.initial_shape));
1094
1347
  const angle = Math.atan2(point_doc[1] - cy, point_doc[0] - cx);
1348
+ const initial_cursor_angle = decision.initial_shape.kind === "transformed" ? _grida_cmath.default.transform.angle(decision.initial_shape.matrix) * Math.PI / 180 : 0;
1095
1349
  this.gesture = {
1096
1350
  kind: "rotate",
1097
1351
  ids: [...decision.ids],
1098
1352
  corner: decision.corner,
1099
1353
  center_doc: [cx, cy],
1100
1354
  anchor_angle: angle,
1101
- current_angle: angle
1355
+ current_angle: angle,
1356
+ initial_cursor_angle
1102
1357
  };
1358
+ this.setCursor({
1359
+ kind: "rotate",
1360
+ corner: decision.corner,
1361
+ baseAngle: initial_cursor_angle
1362
+ }, response);
1103
1363
  response.needsRedraw = true;
1104
1364
  return response;
1105
1365
  }
@@ -1183,7 +1443,7 @@ var SurfaceState = class {
1183
1443
  });
1184
1444
  this.gesture = IDLE;
1185
1445
  response.needsRedraw = true;
1186
- if (cursorEquals(this.cursor, "move")) this.setCursor("default", response);
1446
+ if (require_cursor.cursorEquals(this.cursor, "move")) this.setCursor("default", response);
1187
1447
  break;
1188
1448
  }
1189
1449
  case "marquee": {
@@ -1205,7 +1465,8 @@ var SurfaceState = class {
1205
1465
  kind: "resize",
1206
1466
  ids: g.ids,
1207
1467
  anchor: g.direction,
1208
- rect: g.current_rect,
1468
+ rect: shapeBounds(g.current_shape),
1469
+ shape: g.current_shape,
1209
1470
  phase: "commit"
1210
1471
  });
1211
1472
  this.gesture = IDLE;
@@ -1276,7 +1537,7 @@ var SurfaceState = class {
1276
1537
  return response;
1277
1538
  }
1278
1539
  setCursor(next, response) {
1279
- if (!cursorEquals(this.cursor, next)) {
1540
+ if (!require_cursor.cursorEquals(this.cursor, next)) {
1280
1541
  this.cursor = next;
1281
1542
  response.cursorChanged = true;
1282
1543
  }
@@ -1302,20 +1563,6 @@ function mergeStyle(base, partial) {
1302
1563
  };
1303
1564
  }
1304
1565
  //#endregion
1305
- //#region event/shape.ts
1306
- /**
1307
- * Compute the axis-aligned bounding box of a `SelectionShape` in doc-space.
1308
- * Used for layout math the chrome builder needs (e.g. handle positions).
1309
- *
1310
- * Throws on `kind: "unresolved"` — the chrome builder must resolve those
1311
- * via `shapeOf` first.
1312
- */
1313
- function shapeBounds(shape) {
1314
- if (shape.kind === "rect") return shape.rect;
1315
- if (shape.kind === "line") return _grida_cmath.default.rect.fromPoints([shape.p1, shape.p2]);
1316
- throw new Error(`shapeBounds: cannot bound unresolved shape (id=${shape.id})`);
1317
- }
1318
- //#endregion
1319
1566
  //#region event/overlay.ts
1320
1567
  /**
1321
1568
  * Minimum hit-target size in screen-px.
@@ -1331,25 +1578,237 @@ const MIN_HIT_SIZE = 16;
1331
1578
  */
1332
1579
  const MIN_CHROME_VISIBLE_SIZE = 12;
1333
1580
  //#endregion
1334
- //#region surface/chrome.ts
1335
- const CORNER_RESIZE_DIRECTIONS = [
1581
+ //#region event/selection-controls.ts
1582
+ const CORNER_DIRS = [
1336
1583
  "nw",
1337
1584
  "ne",
1338
1585
  "se",
1339
1586
  "sw"
1340
1587
  ];
1341
- const EDGE_RESIZE_DIRECTIONS = [
1588
+ const EDGE_DIRS = [
1342
1589
  "n",
1343
1590
  "e",
1344
1591
  "s",
1345
1592
  "w"
1346
1593
  ];
1347
- const ROTATION_CORNERS = [
1348
- "nw",
1349
- "ne",
1350
- "se",
1351
- "sw"
1352
- ];
1594
+ const CORNER_LABEL = {
1595
+ nw: "resize_handle:nw",
1596
+ ne: "resize_handle:ne",
1597
+ se: "resize_handle:se",
1598
+ sw: "resize_handle:sw"
1599
+ };
1600
+ const EDGE_LABEL = {
1601
+ n: "resize_edge:n",
1602
+ e: "resize_edge:e",
1603
+ s: "resize_edge:s",
1604
+ w: "resize_edge:w"
1605
+ };
1606
+ const ROTATE_LABEL = {
1607
+ nw: "rotate:nw",
1608
+ ne: "rotate:ne",
1609
+ se: "rotate:se",
1610
+ sw: "rotate:sw"
1611
+ };
1612
+ const HUDHitPriority = {
1613
+ ENDPOINT_HANDLE: 10,
1614
+ RESIZE_HANDLE_EDGE_SMALL: 22,
1615
+ TRANSLATE_BODY_SMALL: 25,
1616
+ RESIZE_HANDLE_EDGE: 30,
1617
+ RESIZE_HANDLE_CORNER: 31,
1618
+ TRANSLATE_BODY: 40,
1619
+ ROTATE_HANDLE: 50
1620
+ };
1621
+ /** The principle constant — the minimum guaranteed length for the body
1622
+ * interior on each axis AND for each side strip along its parallel
1623
+ * axis. Tunable; everything else derives. */
1624
+ const MIN_GUARANTEED_INTERACTIVE_DIM = 20;
1625
+ /** Below this axis dim, body promotes above corner. Derived from the
1626
+ * principle. */
1627
+ const BODY_FLIP_THRESHOLD = 36;
1628
+ /**
1629
+ * @returns { corner, edge } — lengths in screen-px summing to `total`
1630
+ * (corner * 2 + edge === total). Each is `>= 0`.
1631
+ *
1632
+ * Three phases:
1633
+ * - **comfortable** (`total >= 2 * corner_preferred + edge_min`):
1634
+ * corners at preferred, edge takes the surplus.
1635
+ * - **squeezed** (`total >= edge_min`): edge at its min, corners share
1636
+ * the remainder.
1637
+ * - **tiny** (`total < edge_min`): edge takes everything, corners 0.
1638
+ */
1639
+ function negotiateAxis(total, corner_preferred, edge_min) {
1640
+ if (total <= 0) return {
1641
+ corner: 0,
1642
+ edge: 0
1643
+ };
1644
+ if (total >= corner_preferred * 2 + edge_min) return {
1645
+ corner: corner_preferred,
1646
+ edge: total - corner_preferred * 2
1647
+ };
1648
+ if (total >= edge_min) return {
1649
+ corner: (total - edge_min) / 2,
1650
+ edge: edge_min
1651
+ };
1652
+ return {
1653
+ corner: 0,
1654
+ edge: total
1655
+ };
1656
+ }
1657
+ /**
1658
+ * Compute the selection control layout for a screen-space rect.
1659
+ *
1660
+ * Pure: no DOM, no global state. Same inputs → same zones.
1661
+ *
1662
+ * The perimeter ring straddles the bbox edge with `extension =
1663
+ * hit_size / 2` overhang outside. Along each axis the run of length
1664
+ * `axis_dim + 2 * extension` is split via {@link negotiateAxis} into
1665
+ * `[corner | edge | corner]`. The 4 corners and 4 edges in 2D then tile
1666
+ * the ring as a strict 3×3 grid of cells — **non-overlapping**. Body
1667
+ * sits at rect_screen and may overlap with the ring's inside-bbox half
1668
+ * in comfortable mode; priority resolves those overlaps.
1669
+ *
1670
+ * See the comment block in this file for the full principle.
1671
+ */
1672
+ function computeSelectionControlLayout(rect_screen, opts) {
1673
+ const zones = [];
1674
+ const w_violated = rect_screen.width < BODY_FLIP_THRESHOLD;
1675
+ const h_violated = rect_screen.height < BODY_FLIP_THRESHOLD;
1676
+ const small_mode = w_violated || h_violated;
1677
+ const controls_visible = rect_screen.width >= 12 && rect_screen.height >= 12;
1678
+ if (rect_screen.width >= 1 || rect_screen.height >= 1) zones.push({
1679
+ rect: rect_screen,
1680
+ priority: small_mode ? HUDHitPriority.TRANSLATE_BODY_SMALL : HUDHitPriority.TRANSLATE_BODY,
1681
+ role: { kind: "translate" },
1682
+ label: "translate"
1683
+ });
1684
+ if (!controls_visible) return {
1685
+ zones,
1686
+ controls_visible,
1687
+ small_mode
1688
+ };
1689
+ const hit_size = Math.max(opts.handle_size + 4, 16);
1690
+ const extension = hit_size / 2;
1691
+ const total_x = rect_screen.width + extension * 2;
1692
+ const total_y = rect_screen.height + extension * 2;
1693
+ const { corner: cx, edge: ex } = negotiateAxis(total_x, hit_size, 20);
1694
+ const { corner: cy, edge: ey } = negotiateAxis(total_y, hit_size, 20);
1695
+ const left = rect_screen.x - extension;
1696
+ const top = rect_screen.y - extension;
1697
+ const mid_x = left + cx;
1698
+ const mid_y = top + cy;
1699
+ const right_x = mid_x + ex;
1700
+ const right_y = mid_y + ey;
1701
+ const cornerRects = {
1702
+ nw: {
1703
+ x: left,
1704
+ y: top,
1705
+ width: cx,
1706
+ height: cy
1707
+ },
1708
+ ne: {
1709
+ x: right_x,
1710
+ y: top,
1711
+ width: cx,
1712
+ height: cy
1713
+ },
1714
+ sw: {
1715
+ x: left,
1716
+ y: right_y,
1717
+ width: cx,
1718
+ height: cy
1719
+ },
1720
+ se: {
1721
+ x: right_x,
1722
+ y: right_y,
1723
+ width: cx,
1724
+ height: cy
1725
+ }
1726
+ };
1727
+ const edgeRects = {
1728
+ n: {
1729
+ x: mid_x,
1730
+ y: top,
1731
+ width: ex,
1732
+ height: cy
1733
+ },
1734
+ s: {
1735
+ x: mid_x,
1736
+ y: right_y,
1737
+ width: ex,
1738
+ height: cy
1739
+ },
1740
+ w: {
1741
+ x: left,
1742
+ y: mid_y,
1743
+ width: cx,
1744
+ height: ey
1745
+ },
1746
+ e: {
1747
+ x: right_x,
1748
+ y: mid_y,
1749
+ width: cx,
1750
+ height: ey
1751
+ }
1752
+ };
1753
+ for (const dir of CORNER_DIRS) {
1754
+ const rect = cornerRects[dir];
1755
+ if (rect.width <= 0 || rect.height <= 0) continue;
1756
+ zones.push({
1757
+ rect,
1758
+ priority: HUDHitPriority.RESIZE_HANDLE_CORNER,
1759
+ role: {
1760
+ kind: "resize_corner",
1761
+ direction: dir
1762
+ },
1763
+ label: CORNER_LABEL[dir]
1764
+ });
1765
+ }
1766
+ const edge_priority = {
1767
+ n: w_violated ? HUDHitPriority.RESIZE_HANDLE_EDGE_SMALL : HUDHitPriority.RESIZE_HANDLE_EDGE,
1768
+ s: w_violated ? HUDHitPriority.RESIZE_HANDLE_EDGE_SMALL : HUDHitPriority.RESIZE_HANDLE_EDGE,
1769
+ e: h_violated ? HUDHitPriority.RESIZE_HANDLE_EDGE_SMALL : HUDHitPriority.RESIZE_HANDLE_EDGE,
1770
+ w: h_violated ? HUDHitPriority.RESIZE_HANDLE_EDGE_SMALL : HUDHitPriority.RESIZE_HANDLE_EDGE
1771
+ };
1772
+ for (const dir of EDGE_DIRS) {
1773
+ const rect = edgeRects[dir];
1774
+ if (rect.width <= 0 || rect.height <= 0) continue;
1775
+ zones.push({
1776
+ rect,
1777
+ priority: edge_priority[dir],
1778
+ role: {
1779
+ kind: "resize_edge",
1780
+ direction: dir
1781
+ },
1782
+ label: EDGE_LABEL[dir]
1783
+ });
1784
+ }
1785
+ if (opts.show_rotation) for (const dir of CORNER_DIRS) {
1786
+ const resize = cornerRects[dir];
1787
+ if (resize.width <= 0 || resize.height <= 0) continue;
1788
+ const [dx, dy] = _grida_cmath.default.compass.cardinal_direction_vector[dir];
1789
+ zones.push({
1790
+ rect: {
1791
+ x: resize.x + (dx > 0 ? 0 : -16),
1792
+ y: resize.y + (dy > 0 ? 0 : -16),
1793
+ width: resize.width + 16,
1794
+ height: resize.height + 16
1795
+ },
1796
+ priority: HUDHitPriority.ROTATE_HANDLE,
1797
+ role: {
1798
+ kind: "rotate",
1799
+ corner: dir
1800
+ },
1801
+ label: ROTATE_LABEL[dir]
1802
+ });
1803
+ }
1804
+ return {
1805
+ zones,
1806
+ controls_visible,
1807
+ small_mode
1808
+ };
1809
+ }
1810
+ //#endregion
1811
+ //#region surface/chrome.ts
1353
1812
  /**
1354
1813
  * Build the per-frame surface chrome.
1355
1814
  *
@@ -1359,34 +1818,44 @@ const ROTATION_CORNERS = [
1359
1818
  * - `decoration` — pure visual `HUDDraw` (selection outlines, hover outline,
1360
1819
  * marquee, line outlines). Not interactable.
1361
1820
  *
1821
+ * Priority is data, not iteration order. Each `OverlayElement` carries its
1822
+ * own `priority` (lower wins) and a stable `label`. The `HitRegions`
1823
+ * registry resolves overlapping regions by priority, not push order. See
1824
+ * `event/selection-controls.ts` for the canonical priority ladder.
1825
+ *
1362
1826
  * The Surface fans `overlays` into `HitRegions` (for events) and merges
1363
1827
  * their render shapes into `decoration` (for the canvas draw call).
1364
1828
  */
1365
1829
  function buildChrome(input) {
1366
- const { state, shapeOf, style } = input;
1830
+ const { state, shapeOf, style, groups } = input;
1367
1831
  const transform = state.getTransform();
1368
1832
  const overlays = [];
1369
1833
  const decoration_rects = [];
1370
1834
  const decoration_lines = [];
1371
- const in_gesture = state.gesture.kind !== "idle";
1372
- const hover_id = in_gesture ? null : state.getEffectiveHover();
1835
+ const decoration_polylines = [];
1836
+ const hover_id = state.gesture.kind === "idle" ? state.getEffectiveHover() : null;
1373
1837
  if (hover_id) {
1374
1838
  const shape = shapeOf(hover_id);
1375
- if (shape) pushShapeOutline(shape, decoration_rects, decoration_lines, {
1839
+ if (shape) pushShapeOutline(shape, decoration_rects, decoration_lines, decoration_polylines, {
1376
1840
  dashed: false,
1377
- strokeWidth: style.hoverOutlineWidth
1841
+ strokeWidth: style.hoverOutlineWidth,
1842
+ group: groups?.hover
1378
1843
  });
1379
1844
  }
1380
- if (!in_gesture) for (const group of state.getSelectionGroups()) {
1845
+ for (const group of state.getSelectionGroups()) {
1381
1846
  const shape = resolveGroupShape(group, shapeOf);
1382
1847
  if (!shape) continue;
1383
- pushShapeOutline(shape, decoration_rects, decoration_lines, {
1848
+ pushShapeOutline(shape, decoration_rects, decoration_lines, decoration_polylines, {
1384
1849
  dashed: false,
1385
- strokeWidth: style.selectionOutlineWidth
1850
+ strokeWidth: style.selectionOutlineWidth,
1851
+ group: groups?.selection
1386
1852
  });
1387
- pushBodyHandle(shape, group.ids, transform, overlays);
1388
- if (shape.kind === "rect") pushRectGroupHandles(shape.rect, group.ids, transform, style, overlays);
1389
- else if (shape.kind === "line") pushLineEndpoints(group.ids[0], shape.p1, shape.p2, style, overlays);
1853
+ if (shape.kind === "rect") pushRectChrome(shape.rect, group.ids, transform, style, groups?.selectionControls, overlays);
1854
+ else if (shape.kind === "transformed") pushTransformedChrome(shape.local, shape.matrix, group.ids, transform, style, groups?.selectionControls, overlays);
1855
+ else if (shape.kind === "line") {
1856
+ pushLineBody(shape, group.ids, transform, groups?.selectionControls, overlays);
1857
+ pushLineEndpoints(group.ids[0], shape.p1, shape.p2, style, groups?.selectionControls, overlays);
1858
+ }
1390
1859
  }
1391
1860
  if (state.gesture.kind === "marquee") {
1392
1861
  const g = state.gesture;
@@ -1395,20 +1864,35 @@ function buildChrome(input) {
1395
1864
  ...mr,
1396
1865
  stroke: true,
1397
1866
  fill: true,
1398
- fillOpacity: .15
1867
+ fillOpacity: .15,
1868
+ group: groups?.marquee
1869
+ });
1870
+ }
1871
+ if (state.gesture.kind === "resize") {
1872
+ const shape = state.gesture.current_shape;
1873
+ if (shape.kind === "transformed") {
1874
+ const corners = _grida_cmath.default.rect.toCorners(shape.local).map((p) => _grida_cmath.default.vector2.transform(p, shape.matrix));
1875
+ decoration_polylines.push({
1876
+ points: [...corners, corners[0]],
1877
+ stroke: true,
1878
+ fill: false,
1879
+ dashed: true,
1880
+ group: groups?.transformPreview
1881
+ });
1882
+ } else decoration_rects.push({
1883
+ ...shapeBounds(shape),
1884
+ stroke: true,
1885
+ fill: false,
1886
+ dashed: true,
1887
+ group: groups?.transformPreview
1399
1888
  });
1400
1889
  }
1401
- if (state.gesture.kind === "resize") decoration_rects.push({
1402
- ...state.gesture.current_rect,
1403
- stroke: true,
1404
- fill: false,
1405
- dashed: true
1406
- });
1407
1890
  return {
1408
1891
  overlays,
1409
1892
  decoration: {
1410
1893
  rects: decoration_rects.length > 0 ? decoration_rects : void 0,
1411
- lines: decoration_lines.length > 0 ? decoration_lines : void 0
1894
+ lines: decoration_lines.length > 0 ? decoration_lines : void 0,
1895
+ polylines: decoration_polylines.length > 0 ? decoration_polylines : void 0
1412
1896
  }
1413
1897
  };
1414
1898
  }
@@ -1416,168 +1900,262 @@ function resolveGroupShape(group, shapeOf) {
1416
1900
  if (group.shape.kind === "unresolved") return shapeOf(group.shape.id);
1417
1901
  return group.shape;
1418
1902
  }
1419
- function pushBodyHandle(shape, ids, transform, out) {
1420
- const bounds_doc = shapeBounds(shape);
1421
- const rect_screen = _grida_cmath.default.rect.transform(bounds_doc, transform);
1422
- if (rect_screen.width < 1 && rect_screen.height < 1) return;
1423
- out.push({
1424
- action: {
1425
- kind: "translate_handle",
1426
- ids
1427
- },
1428
- hit: {
1429
- kind: "screen_aabb",
1430
- rect: rect_screen
1431
- },
1432
- cursor: "move"
1903
+ function pushRectChrome(rect_doc, ids, transform, style, group, out) {
1904
+ const layout = computeSelectionControlLayout(_grida_cmath.default.rect.transform(rect_doc, transform), {
1905
+ handle_size: style.handleSize,
1906
+ show_rotation: style.showRotationHandles && ids.length >= 1
1433
1907
  });
1908
+ for (const zone of layout.zones) {
1909
+ const el = zoneToOverlay(zone, rect_doc, ids, style, group, layout.controls_visible);
1910
+ if (el) out.push(el);
1911
+ }
1434
1912
  }
1435
- function pushRectGroupHandles(rect_doc, ids, transform, style, out) {
1436
- const rect_screen = _grida_cmath.default.rect.transform(rect_doc, transform);
1437
- if (rect_screen.width < 12 && rect_screen.height < 12) return;
1438
- const size = style.handleSize;
1439
- const hit_size = Math.max(size + 4, 16);
1440
- const anchors_doc = cornerAnchors(rect_doc);
1441
- for (const dir of CORNER_RESIZE_DIRECTIONS) {
1442
- const anchor_doc = anchors_doc[dir];
1443
- out.push({
1913
+ function zoneToOverlay(zone, rect_doc, ids, style, group, controls_visible) {
1914
+ switch (zone.role.kind) {
1915
+ case "translate": return {
1916
+ label: zone.label,
1917
+ group,
1918
+ action: {
1919
+ kind: "translate_handle",
1920
+ ids
1921
+ },
1922
+ hit: {
1923
+ kind: "screen_aabb",
1924
+ rect: zone.rect
1925
+ },
1926
+ priority: zone.priority,
1927
+ cursor: "move"
1928
+ };
1929
+ case "resize_corner": {
1930
+ const dir = zone.role.direction;
1931
+ const size = style.handleSize;
1932
+ const anchor_doc = _grida_cmath.default.rect.getCardinalPoint(rect_doc, dir);
1933
+ return {
1934
+ label: zone.label,
1935
+ group,
1936
+ action: {
1937
+ kind: "resize_handle",
1938
+ direction: dir,
1939
+ ids,
1940
+ initial_shape: {
1941
+ kind: "rect",
1942
+ rect: rect_doc
1943
+ }
1944
+ },
1945
+ hit: {
1946
+ kind: "screen_aabb",
1947
+ rect: zone.rect
1948
+ },
1949
+ render: controls_visible ? {
1950
+ kind: "screen_rect",
1951
+ anchor_doc,
1952
+ width: size,
1953
+ height: size,
1954
+ placement: "center",
1955
+ fill: true,
1956
+ stroke: true,
1957
+ fillColor: style.handleFill,
1958
+ strokeColor: style.handleStroke
1959
+ } : void 0,
1960
+ priority: zone.priority,
1961
+ cursor: {
1962
+ kind: "resize",
1963
+ direction: dir
1964
+ }
1965
+ };
1966
+ }
1967
+ case "resize_edge": return {
1968
+ label: zone.label,
1969
+ group,
1444
1970
  action: {
1445
1971
  kind: "resize_handle",
1446
- direction: dir,
1972
+ direction: zone.role.direction,
1447
1973
  ids,
1448
- initial_rect: rect_doc
1974
+ initial_shape: {
1975
+ kind: "rect",
1976
+ rect: rect_doc
1977
+ }
1449
1978
  },
1450
1979
  hit: {
1451
- kind: "screen_rect_at_doc",
1452
- anchor_doc,
1453
- width: hit_size,
1454
- height: hit_size,
1455
- placement: "center"
1456
- },
1457
- render: {
1458
- kind: "screen_rect",
1459
- anchor_doc,
1460
- width: size,
1461
- height: size,
1462
- placement: "center",
1463
- fill: true,
1464
- stroke: true,
1465
- fillColor: style.handleFill,
1466
- strokeColor: style.handleStroke
1980
+ kind: "screen_aabb",
1981
+ rect: zone.rect
1467
1982
  },
1983
+ priority: zone.priority,
1468
1984
  cursor: {
1469
1985
  kind: "resize",
1470
- direction: dir
1986
+ direction: zone.role.direction
1471
1987
  }
1472
- });
1473
- }
1474
- const edge_strips = edgeStripsScreen(rect_screen, hit_size);
1475
- for (const dir of EDGE_RESIZE_DIRECTIONS) {
1476
- const strip = edge_strips[dir];
1477
- out.push({
1988
+ };
1989
+ case "rotate": return {
1990
+ label: zone.label,
1991
+ group,
1478
1992
  action: {
1479
- kind: "resize_handle",
1480
- direction: dir,
1993
+ kind: "rotate_handle",
1994
+ corner: zone.role.corner,
1481
1995
  ids,
1482
- initial_rect: rect_doc
1996
+ initial_shape: {
1997
+ kind: "rect",
1998
+ rect: rect_doc
1999
+ }
1483
2000
  },
1484
2001
  hit: {
1485
2002
  kind: "screen_aabb",
1486
- rect: strip
2003
+ rect: zone.rect
1487
2004
  },
2005
+ priority: zone.priority,
1488
2006
  cursor: {
1489
- kind: "resize",
1490
- direction: dir
2007
+ kind: "rotate",
2008
+ corner: zone.role.corner
1491
2009
  }
1492
- });
2010
+ };
1493
2011
  }
1494
- if (style.showRotationHandles && ids.length === 1) {
1495
- const rot_size = 16;
1496
- for (const corner of ROTATION_CORNERS) {
1497
- const anchor_doc = anchors_doc[corner];
1498
- const offset_screen = rotationOffsetScreen(corner);
1499
- const [ax, ay] = docToScreen(transform, anchor_doc[0], anchor_doc[1]);
1500
- out.push({
1501
- action: {
1502
- kind: "rotate_handle",
1503
- corner,
1504
- ids,
1505
- initial_rect: rect_doc
1506
- },
1507
- hit: {
1508
- kind: "screen_aabb",
1509
- rect: {
1510
- x: ax + offset_screen[0] - rot_size / 2,
1511
- y: ay + offset_screen[1] - rot_size / 2,
1512
- width: rot_size,
1513
- height: rot_size
2012
+ }
2013
+ function pushTransformedChrome(local, matrix, ids, camera, style, group, out) {
2014
+ const local_to_screen = _grida_cmath.default.transform.multiply(camera, matrix);
2015
+ const scale_xy = _grida_cmath.default.transform.getScale(local_to_screen);
2016
+ const angle_deg = _grida_cmath.default.transform.angle(local_to_screen);
2017
+ const angle_rad = angle_deg * Math.PI / 180;
2018
+ const screen_w = local.width * scale_xy[0];
2019
+ const screen_h = local.height * scale_xy[1];
2020
+ const local_center = [local.x + local.width / 2, local.y + local.height / 2];
2021
+ const screen_center = _grida_cmath.default.vector2.transform(local_center, local_to_screen);
2022
+ const layout = computeSelectionControlLayout({
2023
+ x: screen_center[0] - screen_w / 2,
2024
+ y: screen_center[1] - screen_h / 2,
2025
+ width: screen_w,
2026
+ height: screen_h
2027
+ }, {
2028
+ handle_size: style.handleSize,
2029
+ show_rotation: style.showRotationHandles && ids.length >= 1
2030
+ });
2031
+ const inverse_transform = _grida_cmath.default.transform.rotate(_grida_cmath.default.transform.identity, -angle_deg, screen_center);
2032
+ const initial_shape = {
2033
+ kind: "transformed",
2034
+ local,
2035
+ matrix
2036
+ };
2037
+ for (const zone of layout.zones) {
2038
+ const hit = {
2039
+ kind: "screen_obb",
2040
+ rect: zone.rect,
2041
+ inverse_transform
2042
+ };
2043
+ switch (zone.role.kind) {
2044
+ case "translate":
2045
+ out.push({
2046
+ label: zone.label,
2047
+ group,
2048
+ action: {
2049
+ kind: "translate_handle",
2050
+ ids
2051
+ },
2052
+ hit,
2053
+ priority: zone.priority,
2054
+ cursor: "move"
2055
+ });
2056
+ break;
2057
+ case "resize_corner": {
2058
+ const dir = zone.role.direction;
2059
+ const size = style.handleSize;
2060
+ const cardinal_local = _grida_cmath.default.rect.getCardinalPoint(local, dir);
2061
+ const anchor_doc = _grida_cmath.default.vector2.transform(cardinal_local, matrix);
2062
+ out.push({
2063
+ label: zone.label,
2064
+ group,
2065
+ action: {
2066
+ kind: "resize_handle",
2067
+ direction: dir,
2068
+ ids,
2069
+ initial_shape
2070
+ },
2071
+ hit,
2072
+ render: layout.controls_visible ? {
2073
+ kind: "screen_rect",
2074
+ anchor_doc,
2075
+ width: size,
2076
+ height: size,
2077
+ placement: "center",
2078
+ fill: true,
2079
+ stroke: true,
2080
+ fillColor: style.handleFill,
2081
+ strokeColor: style.handleStroke,
2082
+ angle: angle_rad
2083
+ } : void 0,
2084
+ priority: zone.priority,
2085
+ cursor: {
2086
+ kind: "resize",
2087
+ direction: dir
1514
2088
  }
1515
- },
1516
- cursor: {
1517
- kind: "rotate",
1518
- corner
1519
- }
1520
- });
2089
+ });
2090
+ break;
2091
+ }
2092
+ case "resize_edge":
2093
+ out.push({
2094
+ label: zone.label,
2095
+ group,
2096
+ action: {
2097
+ kind: "resize_handle",
2098
+ direction: zone.role.direction,
2099
+ ids,
2100
+ initial_shape
2101
+ },
2102
+ hit,
2103
+ priority: zone.priority,
2104
+ cursor: {
2105
+ kind: "resize",
2106
+ direction: zone.role.direction
2107
+ }
2108
+ });
2109
+ break;
2110
+ case "rotate":
2111
+ out.push({
2112
+ label: zone.label,
2113
+ group,
2114
+ action: {
2115
+ kind: "rotate_handle",
2116
+ corner: zone.role.corner,
2117
+ ids,
2118
+ initial_shape
2119
+ },
2120
+ hit,
2121
+ priority: zone.priority,
2122
+ cursor: {
2123
+ kind: "rotate",
2124
+ corner: zone.role.corner
2125
+ }
2126
+ });
2127
+ break;
1521
2128
  }
1522
2129
  }
1523
2130
  }
1524
- function cornerAnchors(r) {
1525
- return {
1526
- nw: [r.x, r.y],
1527
- n: [r.x + r.width / 2, r.y],
1528
- ne: [r.x + r.width, r.y],
1529
- e: [r.x + r.width, r.y + r.height / 2],
1530
- se: [r.x + r.width, r.y + r.height],
1531
- s: [r.x + r.width / 2, r.y + r.height],
1532
- sw: [r.x, r.y + r.height],
1533
- w: [r.x, r.y + r.height / 2]
2131
+ function pushLineBody(shape, ids, transform, group, out) {
2132
+ const bounds_doc = shapeBounds(shape);
2133
+ const rect_screen = _grida_cmath.default.rect.transform(bounds_doc, transform);
2134
+ if (rect_screen.width < 1 && rect_screen.height < 1) return;
2135
+ const hitW = Math.max(rect_screen.width, 16);
2136
+ const hitH = Math.max(rect_screen.height, 16);
2137
+ const hitRect = {
2138
+ x: rect_screen.x - (hitW - rect_screen.width) / 2,
2139
+ y: rect_screen.y - (hitH - rect_screen.height) / 2,
2140
+ width: hitW,
2141
+ height: hitH
1534
2142
  };
1535
- }
1536
- /**
1537
- * Compute the 4 screen-space AABB strips between corner knobs for virtual
1538
- * edge resize regions. Each strip is `thickness` thick, inset from the
1539
- * corners by half the strip thickness so it doesn't overlap corner hits.
1540
- */
1541
- function edgeStripsScreen(rect_screen, thickness) {
1542
- const { x, y, width, height } = rect_screen;
1543
- const inset = thickness / 2;
1544
- const half = thickness / 2;
1545
- return {
1546
- n: {
1547
- x: x + inset,
1548
- y: y - half,
1549
- width: Math.max(0, width - inset * 2),
1550
- height: thickness
1551
- },
1552
- s: {
1553
- x: x + inset,
1554
- y: y + height - half,
1555
- width: Math.max(0, width - inset * 2),
1556
- height: thickness
2143
+ out.push({
2144
+ label: "translate",
2145
+ group,
2146
+ action: {
2147
+ kind: "translate_handle",
2148
+ ids
1557
2149
  },
1558
- e: {
1559
- x: x + width - half,
1560
- y: y + inset,
1561
- width: thickness,
1562
- height: Math.max(0, height - inset * 2)
2150
+ hit: {
2151
+ kind: "screen_aabb",
2152
+ rect: hitRect
1563
2153
  },
1564
- w: {
1565
- x: x - half,
1566
- y: y + inset,
1567
- width: thickness,
1568
- height: Math.max(0, height - inset * 2)
1569
- }
1570
- };
1571
- }
1572
- function rotationOffsetScreen(corner) {
1573
- switch (corner) {
1574
- case "nw": return [-12, -12];
1575
- case "ne": return [12, -12];
1576
- case "se": return [12, 12];
1577
- case "sw": return [-12, 12];
1578
- }
2154
+ priority: HUDHitPriority.TRANSLATE_BODY,
2155
+ cursor: "move"
2156
+ });
1579
2157
  }
1580
- function pushLineEndpoints(id, p1, p2, style, out) {
2158
+ function pushLineEndpoints(id, p1, p2, style, group, out) {
1581
2159
  const size = style.handleSize;
1582
2160
  const hit_size = Math.max(size + 4, 16);
1583
2161
  const endpoints = [{
@@ -1588,6 +2166,8 @@ function pushLineEndpoints(id, p1, p2, style, out) {
1588
2166
  pos: p2
1589
2167
  }];
1590
2168
  for (const ep of endpoints) out.push({
2169
+ label: `endpoint:${ep.which}`,
2170
+ group,
1591
2171
  action: {
1592
2172
  kind: "endpoint_handle",
1593
2173
  endpoint: ep.which,
@@ -1613,38 +2193,57 @@ function pushLineEndpoints(id, p1, p2, style, out) {
1613
2193
  fillColor: style.handleFill,
1614
2194
  strokeColor: style.handleStroke
1615
2195
  },
2196
+ priority: HUDHitPriority.ENDPOINT_HANDLE,
1616
2197
  cursor: "pointer"
1617
2198
  });
1618
2199
  }
1619
- function pushShapeOutline(shape, rects, lines, opts) {
2200
+ function pushShapeOutline(shape, rects, lines, polylines, opts) {
1620
2201
  if (shape.kind === "rect") rects.push({
1621
2202
  ...shape.rect,
1622
2203
  stroke: true,
1623
2204
  fill: false,
1624
2205
  dashed: opts.dashed,
1625
- strokeWidth: opts.strokeWidth
2206
+ strokeWidth: opts.strokeWidth,
2207
+ group: opts.group
1626
2208
  });
1627
- else if (shape.kind === "line") lines.push({
2209
+ else if (shape.kind === "transformed") {
2210
+ const corners_doc = _grida_cmath.default.rect.toCorners(shape.local).map((p) => _grida_cmath.default.vector2.transform(p, shape.matrix));
2211
+ polylines.push({
2212
+ points: [...corners_doc, corners_doc[0]],
2213
+ stroke: true,
2214
+ fill: false,
2215
+ dashed: opts.dashed,
2216
+ group: opts.group
2217
+ });
2218
+ } else if (shape.kind === "line") lines.push({
1628
2219
  x1: shape.p1[0],
1629
2220
  y1: shape.p1[1],
1630
2221
  x2: shape.p2[0],
1631
2222
  y2: shape.p2[1],
1632
2223
  dashed: opts.dashed,
1633
- strokeWidth: opts.strokeWidth
2224
+ strokeWidth: opts.strokeWidth,
2225
+ group: opts.group
1634
2226
  });
1635
2227
  }
1636
2228
  /**
1637
2229
  * Fan a list of `OverlayElement`s into per-primitive render arrays and into
1638
2230
  * the hit-region registry. Returns the additional render primitives that
1639
2231
  * should be merged with the decoration `HUDDraw`.
2232
+ *
2233
+ * Priority and label are forwarded verbatim from each overlay element to
2234
+ * the registered HitRegion — the registry resolves overlaps by priority.
1640
2235
  */
1641
2236
  function fanOverlays(overlays, transform, regions) {
1642
2237
  regions.clear();
1643
2238
  const screenRects = [];
1644
2239
  for (const el of overlays) {
2240
+ const projected = projectHit(el.hit, transform);
1645
2241
  regions.push({
1646
- rect: projectHitAABB(el.hit, transform),
1647
- action: el.action
2242
+ rect: projected.rect,
2243
+ inverse_transform: projected.inverse_transform,
2244
+ action: el.action,
2245
+ priority: el.priority,
2246
+ label: el.label
1648
2247
  });
1649
2248
  if (!el.render) continue;
1650
2249
  if (el.render.kind === "screen_rect") screenRects.push({
@@ -1656,13 +2255,19 @@ function fanOverlays(overlays, transform, regions) {
1656
2255
  fill: el.render.fill,
1657
2256
  stroke: el.render.stroke,
1658
2257
  fillColor: el.render.fillColor,
1659
- strokeColor: el.render.strokeColor
2258
+ strokeColor: el.render.strokeColor,
2259
+ angle: el.render.angle,
2260
+ group: el.group
1660
2261
  });
1661
2262
  }
1662
2263
  return { screenRects };
1663
2264
  }
1664
- function projectHitAABB(hit, transform) {
1665
- if (hit.kind === "screen_aabb") return hit.rect;
2265
+ function projectHit(hit, transform) {
2266
+ if (hit.kind === "screen_aabb") return { rect: hit.rect };
2267
+ if (hit.kind === "screen_obb") return {
2268
+ rect: hit.rect,
2269
+ inverse_transform: hit.inverse_transform
2270
+ };
1666
2271
  const [sx, sy] = docToScreen(transform, hit.anchor_doc[0], hit.anchor_doc[1]);
1667
2272
  const placement = hit.placement ?? "center";
1668
2273
  let x = sx;
@@ -1689,12 +2294,12 @@ function projectHitAABB(hit, transform) {
1689
2294
  y = sy - hit.height;
1690
2295
  break;
1691
2296
  }
1692
- return {
2297
+ return { rect: {
1693
2298
  x,
1694
2299
  y,
1695
2300
  width: hit.width,
1696
2301
  height: hit.height
1697
- };
2302
+ } };
1698
2303
  }
1699
2304
  /**
1700
2305
  * Merge a base `HUDDraw` (chrome decoration) with optional host-fed extras
@@ -1733,11 +2338,25 @@ var Surface = class {
1733
2338
  constructor(canvas, options) {
1734
2339
  this.width = 0;
1735
2340
  this.height = 0;
2341
+ this.cursor_renderer = null;
1736
2342
  this.opts = options;
1737
2343
  this.style = mergeStyle(DEFAULT_STYLE, options.style);
1738
- this.hudCanvas = new HUDCanvas(canvas, { color: options.color ?? this.style.chromeColor });
2344
+ this.colorOverride = options.color;
2345
+ this.hudCanvas = new HUDCanvas(canvas, { color: this.colorOverride ?? this.style.chromeColor });
1739
2346
  this.state = new SurfaceState();
1740
2347
  if (options.readonly !== void 0) this.state.setReadonly(options.readonly);
2348
+ if (options.pixelGrid) this.hudCanvas.setPixelGrid(options.pixelGrid);
2349
+ }
2350
+ /** Configure / disable the back-most pixel-grid layer. */
2351
+ setPixelGrid(config) {
2352
+ this.hudCanvas.setPixelGrid(config);
2353
+ }
2354
+ /**
2355
+ * Update just the pixel grid's transform. Cheap to call per camera tick.
2356
+ * No-op when no pixel-grid config is set.
2357
+ */
2358
+ setPixelGridTransform(transform) {
2359
+ this.hudCanvas.setPixelGridTransform(transform);
1741
2360
  }
1742
2361
  setSize(w, h) {
1743
2362
  this.width = w;
@@ -1763,7 +2382,15 @@ var Surface = class {
1763
2382
  }
1764
2383
  setStyle(partial) {
1765
2384
  this.style = mergeStyle(this.style, partial);
1766
- this.hudCanvas.setColor(this.style.chromeColor);
2385
+ this.hudCanvas.setColor(this.colorOverride ?? this.style.chromeColor);
2386
+ }
2387
+ /**
2388
+ * Set or clear the host color override. `null` clears the override and
2389
+ * lets `style.chromeColor` win on the next paint.
2390
+ */
2391
+ setColor(color) {
2392
+ this.colorOverride = color ?? void 0;
2393
+ this.hudCanvas.setColor(this.colorOverride ?? this.style.chromeColor);
1767
2394
  }
1768
2395
  setReadonly(v) {
1769
2396
  this.state.setReadonly(v);
@@ -1800,11 +2427,13 @@ var Surface = class {
1800
2427
  state: this.state,
1801
2428
  shapeOf: this.opts.shapeOf,
1802
2429
  style: this.style,
2430
+ groups: this.opts.groups,
1803
2431
  width: this.width,
1804
2432
  height: this.height
1805
2433
  });
1806
- const { screenRects } = fanOverlays(overlays, this.state.getTransform(), this.state.hitRegions());
1807
- this.hudCanvas.draw(mergeDraws(decoration, extra, screenRects));
2434
+ const hidden = this.opts.visibility?.({ gesture: this.state.gesture })?.hidden;
2435
+ const { screenRects } = fanOverlays(filterOverlaysByGroup(overlays, hidden), this.state.getTransform(), this.state.hitRegions());
2436
+ this.hudCanvas.draw(filterHUDDrawByGroup(mergeDraws(decoration, extra, screenRects), { hidden }));
1808
2437
  }
1809
2438
  /** Convenience: clear the canvas (e.g. when the host stops the surface). */
1810
2439
  clear() {
@@ -1823,23 +2452,87 @@ var Surface = class {
1823
2452
  cursor() {
1824
2453
  return this.state.cursor;
1825
2454
  }
2455
+ /**
2456
+ * Resolve the current cursor to a CSS `cursor:` value. Runs the
2457
+ * installed renderer (or the built-in `cursorToCss` if none installed).
2458
+ *
2459
+ * Host wires it like:
2460
+ *
2461
+ * const r = surface.dispatch(event);
2462
+ * if (r.cursorChanged) el.style.cursor = surface.cursorCss();
2463
+ *
2464
+ * Saves the host from re-importing `cursorToCss` after every dispatch
2465
+ * and gives one place to change behavior when a renderer is swapped in.
2466
+ */
2467
+ cursorCss() {
2468
+ return (this.cursor_renderer ?? require_cursor.cursorToCss)(this.state.cursor);
2469
+ }
2470
+ /**
2471
+ * Install (or clear) a custom cursor renderer.
2472
+ *
2473
+ * `null` restores the built-in `cursorToCss` behavior (native CSS
2474
+ * keywords for every variant). Pass `cursors.defaultRenderer()` from
2475
+ * `@grida/hud/cursors` for the bundled SVG cursor set.
2476
+ *
2477
+ * Re-callable mid-session; the next `cursorCss()` reads the new value.
2478
+ */
2479
+ setCursorRenderer(fn) {
2480
+ this.cursor_renderer = fn;
2481
+ }
1826
2482
  modifiers() {
1827
2483
  return this.state.modifiers;
1828
2484
  }
1829
2485
  };
2486
+ function filterOverlaysByGroup(overlays, hidden) {
2487
+ const hidden_set = new Set(hidden ?? []);
2488
+ if (hidden_set.size === 0) return overlays;
2489
+ return overlays.filter((overlay) => {
2490
+ return !overlay.group || !hidden_set.has(overlay.group);
2491
+ });
2492
+ }
1830
2493
  //#endregion
2494
+ Object.defineProperty(exports, "BODY_FLIP_THRESHOLD", {
2495
+ enumerable: true,
2496
+ get: function() {
2497
+ return BODY_FLIP_THRESHOLD;
2498
+ }
2499
+ });
2500
+ Object.defineProperty(exports, "DEFAULT_PIXEL_GRID_COLOR", {
2501
+ enumerable: true,
2502
+ get: function() {
2503
+ return DEFAULT_PIXEL_GRID_COLOR;
2504
+ }
2505
+ });
2506
+ Object.defineProperty(exports, "DEFAULT_PIXEL_GRID_STEPS", {
2507
+ enumerable: true,
2508
+ get: function() {
2509
+ return DEFAULT_PIXEL_GRID_STEPS;
2510
+ }
2511
+ });
1831
2512
  Object.defineProperty(exports, "HUDCanvas", {
1832
2513
  enumerable: true,
1833
2514
  get: function() {
1834
2515
  return HUDCanvas;
1835
2516
  }
1836
2517
  });
2518
+ Object.defineProperty(exports, "HUDHitPriority", {
2519
+ enumerable: true,
2520
+ get: function() {
2521
+ return HUDHitPriority;
2522
+ }
2523
+ });
1837
2524
  Object.defineProperty(exports, "MIN_CHROME_VISIBLE_SIZE", {
1838
2525
  enumerable: true,
1839
2526
  get: function() {
1840
2527
  return MIN_CHROME_VISIBLE_SIZE;
1841
2528
  }
1842
2529
  });
2530
+ Object.defineProperty(exports, "MIN_GUARANTEED_INTERACTIVE_DIM", {
2531
+ enumerable: true,
2532
+ get: function() {
2533
+ return MIN_GUARANTEED_INTERACTIVE_DIM;
2534
+ }
2535
+ });
1843
2536
  Object.defineProperty(exports, "MIN_HIT_SIZE", {
1844
2537
  enumerable: true,
1845
2538
  get: function() {
@@ -1864,6 +2557,24 @@ Object.defineProperty(exports, "__toESM", {
1864
2557
  return __toESM;
1865
2558
  }
1866
2559
  });
2560
+ Object.defineProperty(exports, "computeSelectionControlLayout", {
2561
+ enumerable: true,
2562
+ get: function() {
2563
+ return computeSelectionControlLayout;
2564
+ }
2565
+ });
2566
+ Object.defineProperty(exports, "drawPixelGrid", {
2567
+ enumerable: true,
2568
+ get: function() {
2569
+ return drawPixelGrid;
2570
+ }
2571
+ });
2572
+ Object.defineProperty(exports, "filterHUDDrawByGroup", {
2573
+ enumerable: true,
2574
+ get: function() {
2575
+ return filterHUDDrawByGroup;
2576
+ }
2577
+ });
1867
2578
  Object.defineProperty(exports, "lassoToHUDDraw", {
1868
2579
  enumerable: true,
1869
2580
  get: function() {
@@ -1882,6 +2593,12 @@ Object.defineProperty(exports, "measurementToHUDDraw", {
1882
2593
  return measurementToHUDDraw;
1883
2594
  }
1884
2595
  });
2596
+ Object.defineProperty(exports, "negotiateAxis", {
2597
+ enumerable: true,
2598
+ get: function() {
2599
+ return negotiateAxis;
2600
+ }
2601
+ });
1885
2602
  Object.defineProperty(exports, "snapGuideToHUDDraw", {
1886
2603
  enumerable: true,
1887
2604
  get: function() {