@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.
- package/README.md +185 -19
- package/dist/cursor-BFGUuD2M.d.mts +64 -0
- package/dist/cursor-BieMVb71.mjs +57 -0
- package/dist/cursor-CIYvFshz.d.ts +64 -0
- package/dist/cursor-DsP9qtN2.js +80 -0
- package/dist/cursors/index.d.mts +98 -0
- package/dist/cursors/index.d.ts +98 -0
- package/dist/cursors/index.js +188 -0
- package/dist/cursors/index.mjs +185 -0
- package/dist/{index-CBqCh-ZM.d.mts → index-Cp0X4SV7.d.ts} +385 -172
- package/dist/{index-DRBeSiI2.d.ts → index-DhGdcuQz.d.mts} +385 -172
- package/dist/index.d.mts +106 -2
- package/dist/index.d.ts +106 -2
- package/dist/index.js +13 -1
- package/dist/index.mjs +3 -2
- package/dist/react.d.mts +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +8 -3
- package/dist/react.mjs +8 -3
- package/dist/{surface-hUEeEVdL.mjs → surface-BvMmXoEl.mjs} +910 -247
- package/dist/{surface-CNlBaEXn.js → surface-ofSNTJ8H.js} +965 -248
- package/package.json +8 -2
|
@@ -1,5 +1,38 @@
|
|
|
1
|
+
import { i as cursorToCss, r as cursorEquals } from "./cursor-BieMVb71.mjs";
|
|
1
2
|
import { auxiliary_line_xylr, guide_line_xylr } from "@grida/cmath/_measurement";
|
|
2
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
|
|
3
36
|
//#region primitives/canvas.ts
|
|
4
37
|
const DEFAULT_COLOR = "#f44336";
|
|
5
38
|
const DEFAULT_LABEL_FG = "#ffffff";
|
|
@@ -37,6 +70,7 @@ var HUDCanvas = class {
|
|
|
37
70
|
]];
|
|
38
71
|
this.width = 0;
|
|
39
72
|
this.height = 0;
|
|
73
|
+
this.pixelGrid = null;
|
|
40
74
|
this.ctx = canvas.getContext("2d");
|
|
41
75
|
this.dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
|
|
42
76
|
this.color = options?.color ?? DEFAULT_COLOR;
|
|
@@ -59,6 +93,31 @@ var HUDCanvas = class {
|
|
|
59
93
|
this.transform = transform;
|
|
60
94
|
}
|
|
61
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
|
+
/**
|
|
62
121
|
* Clear the canvas and draw all primitives in `commands`.
|
|
63
122
|
* Pass `undefined` to clear without drawing (e.g. when no overlay is active).
|
|
64
123
|
*/
|
|
@@ -66,6 +125,17 @@ var HUDCanvas = class {
|
|
|
66
125
|
const ctx = this.ctx;
|
|
67
126
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
68
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
|
+
});
|
|
69
139
|
if (!commands) return;
|
|
70
140
|
const { lines, rules, points, rects, polylines, screenRects } = commands;
|
|
71
141
|
if (rules && rules.length > 0) this.drawRules(rules);
|
|
@@ -91,12 +161,12 @@ var HUDCanvas = class {
|
|
|
91
161
|
drawRules(rules) {
|
|
92
162
|
const ctx = this.ctx;
|
|
93
163
|
this.applyScreenTransform();
|
|
94
|
-
ctx.strokeStyle = this.color;
|
|
95
164
|
ctx.lineWidth = DEFAULT_LINE_WIDTH;
|
|
96
|
-
for (const
|
|
97
|
-
const screenOffset = this.deltaToScreen(offset, axis);
|
|
165
|
+
for (const rule of rules) {
|
|
166
|
+
const screenOffset = this.deltaToScreen(rule.offset, rule.axis);
|
|
167
|
+
ctx.strokeStyle = rule.color ?? this.color;
|
|
98
168
|
ctx.beginPath();
|
|
99
|
-
if (axis === "x") {
|
|
169
|
+
if (rule.axis === "x") {
|
|
100
170
|
ctx.moveTo(screenOffset, 0);
|
|
101
171
|
ctx.lineTo(screenOffset, this.height);
|
|
102
172
|
} else {
|
|
@@ -150,17 +220,29 @@ var HUDCanvas = class {
|
|
|
150
220
|
const midY = (line.y1 + line.y2) / 2;
|
|
151
221
|
const lx = sx * midX + tx;
|
|
152
222
|
const ly = sy * midY + ty;
|
|
223
|
+
const angle = line.labelAngle ?? 0;
|
|
153
224
|
const isVertical = Math.abs(line.x2 - line.x1) < Math.abs(line.y2 - line.y1);
|
|
154
|
-
const
|
|
155
|
-
const
|
|
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;
|
|
156
231
|
const tw = ctx.measureText(line.label).width + LABEL_PADDING_X * 2;
|
|
157
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
|
+
}
|
|
158
239
|
ctx.fillStyle = line.color ?? this.color;
|
|
159
240
|
ctx.beginPath();
|
|
160
241
|
ctx.roundRect(labelX - tw / 2, labelY - th / 2, tw, th, LABEL_BORDER_RADIUS);
|
|
161
242
|
ctx.fill();
|
|
162
243
|
ctx.fillStyle = DEFAULT_LABEL_FG;
|
|
163
244
|
ctx.fillText(line.label, labelX, labelY);
|
|
245
|
+
if (angle !== 0) ctx.restore();
|
|
164
246
|
}
|
|
165
247
|
}
|
|
166
248
|
drawRects(rects) {
|
|
@@ -223,20 +305,29 @@ var HUDCanvas = class {
|
|
|
223
305
|
drawPoints(points) {
|
|
224
306
|
const ctx = this.ctx;
|
|
225
307
|
this.applyScreenTransform();
|
|
226
|
-
ctx.strokeStyle = this.color;
|
|
227
308
|
ctx.lineWidth = DEFAULT_LINE_WIDTH;
|
|
228
309
|
const half = CROSSHAIR_SIZE / 2;
|
|
229
310
|
const [[sx, , tx], [, sy, ty]] = this.transform;
|
|
230
|
-
|
|
231
|
-
for (const
|
|
232
|
-
const
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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();
|
|
238
330
|
}
|
|
239
|
-
ctx.stroke();
|
|
240
331
|
}
|
|
241
332
|
/**
|
|
242
333
|
* Draw rects whose **size is in screen-space** but whose **anchor is in
|
|
@@ -283,6 +374,15 @@ var HUDCanvas = class {
|
|
|
283
374
|
}
|
|
284
375
|
const doFill = r.fill !== false;
|
|
285
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
|
+
}
|
|
286
386
|
if (doFill) {
|
|
287
387
|
ctx.fillStyle = r.fillColor ?? this.color;
|
|
288
388
|
ctx.fillRect(x, y, w, h);
|
|
@@ -291,28 +391,67 @@ var HUDCanvas = class {
|
|
|
291
391
|
ctx.strokeStyle = r.strokeColor ?? this.color;
|
|
292
392
|
ctx.strokeRect(x, y, w, h);
|
|
293
393
|
}
|
|
394
|
+
if (angle !== 0) ctx.restore();
|
|
294
395
|
}
|
|
295
396
|
}
|
|
296
397
|
};
|
|
297
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
|
|
298
429
|
//#region primitives/snap-guide.ts
|
|
299
430
|
/**
|
|
300
431
|
* Convert a `guide.SnapGuide` (the output of `guide.plot()`) into a
|
|
301
432
|
* generic {@link HUDDraw} command list.
|
|
302
433
|
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
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.
|
|
306
437
|
*/
|
|
307
|
-
function snapGuideToHUDDraw(sg) {
|
|
438
|
+
function snapGuideToHUDDraw(sg, color) {
|
|
308
439
|
if (!sg) return void 0;
|
|
309
440
|
return {
|
|
310
|
-
lines: sg.lines
|
|
441
|
+
lines: sg.lines.map((l) => ({
|
|
442
|
+
...l,
|
|
443
|
+
color
|
|
444
|
+
})),
|
|
311
445
|
rules: sg.rules.map(([axis, offset]) => ({
|
|
312
446
|
axis,
|
|
313
|
-
offset
|
|
447
|
+
offset,
|
|
448
|
+
color
|
|
314
449
|
})),
|
|
315
|
-
points: sg.points
|
|
450
|
+
points: sg.points.map(([x, y]) => ({
|
|
451
|
+
x,
|
|
452
|
+
y,
|
|
453
|
+
color
|
|
454
|
+
}))
|
|
316
455
|
};
|
|
317
456
|
}
|
|
318
457
|
//#endregion
|
|
@@ -449,15 +588,53 @@ function rectFromPoints(a, b) {
|
|
|
449
588
|
return cmath.rect.fromPoints([a, b]);
|
|
450
589
|
}
|
|
451
590
|
/**
|
|
452
|
-
* Apply a resize
|
|
591
|
+
* Apply a resize-handle drag to a `SelectionShape` and return the new shape.
|
|
453
592
|
*
|
|
454
|
-
* `dx, dy` is the total drag delta in document-space
|
|
593
|
+
* `dx, dy` is the total drag delta in **document-space**, measured from
|
|
455
594
|
* `anchor_doc` (the pointer-down point) to the current pointer.
|
|
456
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
|
+
*
|
|
457
608
|
* No constraints: width/height can go negative — host is responsible for
|
|
458
609
|
* normalizing if it cares (most callers clamp to a min-size).
|
|
459
610
|
*/
|
|
460
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) {
|
|
461
638
|
let { x, y, width, height } = initial;
|
|
462
639
|
switch (direction) {
|
|
463
640
|
case "n":
|
|
@@ -503,16 +680,19 @@ function applyResize(initial, direction, dx, dy) {
|
|
|
503
680
|
};
|
|
504
681
|
}
|
|
505
682
|
//#endregion
|
|
506
|
-
//#region event/
|
|
507
|
-
/**
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
return
|
|
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})`);
|
|
516
696
|
}
|
|
517
697
|
//#endregion
|
|
518
698
|
//#region event/click-tracker.ts
|
|
@@ -556,9 +736,10 @@ function nowMs() {
|
|
|
556
736
|
/**
|
|
557
737
|
* Registry of overlay UI hit regions.
|
|
558
738
|
*
|
|
559
|
-
* Regions are appended in
|
|
560
|
-
*
|
|
561
|
-
*
|
|
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).
|
|
562
743
|
*/
|
|
563
744
|
var HitRegions = class {
|
|
564
745
|
constructor() {
|
|
@@ -571,11 +752,18 @@ var HitRegions = class {
|
|
|
571
752
|
this.regions.push(region);
|
|
572
753
|
}
|
|
573
754
|
hitTest(point) {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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;
|
|
577
765
|
}
|
|
578
|
-
return
|
|
766
|
+
return best;
|
|
579
767
|
}
|
|
580
768
|
isEmpty() {
|
|
581
769
|
return this.regions.length === 0;
|
|
@@ -588,6 +776,44 @@ var HitRegions = class {
|
|
|
588
776
|
//#endregion
|
|
589
777
|
//#region event/decision.ts
|
|
590
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
|
+
/**
|
|
591
817
|
* Recognize which scenario a pointer-down belongs to. Total over inputs.
|
|
592
818
|
* Pure, no I/O. The single source of truth for "which atomic intent did the
|
|
593
819
|
* user just express?"
|
|
@@ -654,7 +880,7 @@ function dispatch(scenario, input) {
|
|
|
654
880
|
kind: "start_resize",
|
|
655
881
|
ids: a.ids,
|
|
656
882
|
direction: a.direction,
|
|
657
|
-
|
|
883
|
+
initial_shape: a.initial_shape
|
|
658
884
|
};
|
|
659
885
|
}
|
|
660
886
|
case "HandleRotate": {
|
|
@@ -663,7 +889,7 @@ function dispatch(scenario, input) {
|
|
|
663
889
|
kind: "start_rotate",
|
|
664
890
|
ids: a.ids,
|
|
665
891
|
corner: a.corner,
|
|
666
|
-
|
|
892
|
+
initial_shape: a.initial_shape
|
|
667
893
|
};
|
|
668
894
|
}
|
|
669
895
|
case "HandleEndpoint": {
|
|
@@ -764,11 +990,13 @@ function decideIdleCursor(input) {
|
|
|
764
990
|
if (ui_action) switch (ui_action.kind) {
|
|
765
991
|
case "resize_handle": return {
|
|
766
992
|
kind: "resize",
|
|
767
|
-
direction: ui_action.direction
|
|
993
|
+
direction: ui_action.direction,
|
|
994
|
+
baseAngle: shape_screen_angle_rad(ui_action.initial_shape)
|
|
768
995
|
};
|
|
769
996
|
case "rotate_handle": return {
|
|
770
997
|
kind: "rotate",
|
|
771
|
-
corner: ui_action.corner
|
|
998
|
+
corner: ui_action.corner,
|
|
999
|
+
baseAngle: shape_screen_angle_rad(ui_action.initial_shape)
|
|
772
1000
|
};
|
|
773
1001
|
case "translate_handle": return "move";
|
|
774
1002
|
case "select_node":
|
|
@@ -777,6 +1005,24 @@ function decideIdleCursor(input) {
|
|
|
777
1005
|
if (hovered_id && selection_ids.includes(hovered_id)) return "move";
|
|
778
1006
|
return "default";
|
|
779
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
|
+
}
|
|
780
1026
|
//#endregion
|
|
781
1027
|
//#region event/transform.ts
|
|
782
1028
|
const IDENTITY = [[
|
|
@@ -981,16 +1227,17 @@ var SurfaceState = class {
|
|
|
981
1227
|
const g = this.gesture;
|
|
982
1228
|
const dx = point_doc[0] - g.anchor_doc[0];
|
|
983
1229
|
const dy = point_doc[1] - g.anchor_doc[1];
|
|
984
|
-
const
|
|
1230
|
+
const next_shape = applyResize(g.initial_shape, g.direction, dx, dy);
|
|
985
1231
|
this.gesture = {
|
|
986
1232
|
...g,
|
|
987
|
-
|
|
1233
|
+
current_shape: next_shape
|
|
988
1234
|
};
|
|
989
1235
|
deps.emitIntent({
|
|
990
1236
|
kind: "resize",
|
|
991
1237
|
ids: g.ids,
|
|
992
1238
|
anchor: g.direction,
|
|
993
|
-
rect:
|
|
1239
|
+
rect: shapeBounds(next_shape),
|
|
1240
|
+
shape: next_shape,
|
|
994
1241
|
phase: "preview"
|
|
995
1242
|
});
|
|
996
1243
|
response.needsRedraw = true;
|
|
@@ -1003,12 +1250,18 @@ var SurfaceState = class {
|
|
|
1003
1250
|
...g,
|
|
1004
1251
|
current_angle: angle
|
|
1005
1252
|
};
|
|
1253
|
+
const delta = angle - g.anchor_angle;
|
|
1006
1254
|
deps.emitIntent({
|
|
1007
1255
|
kind: "rotate",
|
|
1008
1256
|
ids: g.ids,
|
|
1009
|
-
angle:
|
|
1257
|
+
angle: delta,
|
|
1010
1258
|
phase: "preview"
|
|
1011
1259
|
});
|
|
1260
|
+
this.setCursor({
|
|
1261
|
+
kind: "rotate",
|
|
1262
|
+
corner: g.corner,
|
|
1263
|
+
baseAngle: g.initial_cursor_angle + delta
|
|
1264
|
+
}, response);
|
|
1012
1265
|
response.needsRedraw = true;
|
|
1013
1266
|
return response;
|
|
1014
1267
|
}
|
|
@@ -1060,23 +1313,30 @@ var SurfaceState = class {
|
|
|
1060
1313
|
kind: "resize",
|
|
1061
1314
|
ids: [...decision.ids],
|
|
1062
1315
|
direction: decision.direction,
|
|
1063
|
-
|
|
1316
|
+
initial_shape: decision.initial_shape,
|
|
1064
1317
|
anchor_doc: point_doc,
|
|
1065
|
-
|
|
1318
|
+
current_shape: decision.initial_shape
|
|
1066
1319
|
};
|
|
1067
1320
|
response.needsRedraw = true;
|
|
1068
1321
|
return response;
|
|
1069
1322
|
case "start_rotate": {
|
|
1070
|
-
const [cx, cy] = cmath.rect.getCenter(decision.
|
|
1323
|
+
const [cx, cy] = cmath.rect.getCenter(shapeBounds(decision.initial_shape));
|
|
1071
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;
|
|
1072
1326
|
this.gesture = {
|
|
1073
1327
|
kind: "rotate",
|
|
1074
1328
|
ids: [...decision.ids],
|
|
1075
1329
|
corner: decision.corner,
|
|
1076
1330
|
center_doc: [cx, cy],
|
|
1077
1331
|
anchor_angle: angle,
|
|
1078
|
-
current_angle: angle
|
|
1332
|
+
current_angle: angle,
|
|
1333
|
+
initial_cursor_angle
|
|
1079
1334
|
};
|
|
1335
|
+
this.setCursor({
|
|
1336
|
+
kind: "rotate",
|
|
1337
|
+
corner: decision.corner,
|
|
1338
|
+
baseAngle: initial_cursor_angle
|
|
1339
|
+
}, response);
|
|
1080
1340
|
response.needsRedraw = true;
|
|
1081
1341
|
return response;
|
|
1082
1342
|
}
|
|
@@ -1182,7 +1442,8 @@ var SurfaceState = class {
|
|
|
1182
1442
|
kind: "resize",
|
|
1183
1443
|
ids: g.ids,
|
|
1184
1444
|
anchor: g.direction,
|
|
1185
|
-
rect: g.
|
|
1445
|
+
rect: shapeBounds(g.current_shape),
|
|
1446
|
+
shape: g.current_shape,
|
|
1186
1447
|
phase: "commit"
|
|
1187
1448
|
});
|
|
1188
1449
|
this.gesture = IDLE;
|
|
@@ -1279,20 +1540,6 @@ function mergeStyle(base, partial) {
|
|
|
1279
1540
|
};
|
|
1280
1541
|
}
|
|
1281
1542
|
//#endregion
|
|
1282
|
-
//#region event/shape.ts
|
|
1283
|
-
/**
|
|
1284
|
-
* Compute the axis-aligned bounding box of a `SelectionShape` in doc-space.
|
|
1285
|
-
* Used for layout math the chrome builder needs (e.g. handle positions).
|
|
1286
|
-
*
|
|
1287
|
-
* Throws on `kind: "unresolved"` — the chrome builder must resolve those
|
|
1288
|
-
* via `shapeOf` first.
|
|
1289
|
-
*/
|
|
1290
|
-
function shapeBounds(shape) {
|
|
1291
|
-
if (shape.kind === "rect") return shape.rect;
|
|
1292
|
-
if (shape.kind === "line") return cmath.rect.fromPoints([shape.p1, shape.p2]);
|
|
1293
|
-
throw new Error(`shapeBounds: cannot bound unresolved shape (id=${shape.id})`);
|
|
1294
|
-
}
|
|
1295
|
-
//#endregion
|
|
1296
1543
|
//#region event/overlay.ts
|
|
1297
1544
|
/**
|
|
1298
1545
|
* Minimum hit-target size in screen-px.
|
|
@@ -1308,25 +1555,237 @@ const MIN_HIT_SIZE = 16;
|
|
|
1308
1555
|
*/
|
|
1309
1556
|
const MIN_CHROME_VISIBLE_SIZE = 12;
|
|
1310
1557
|
//#endregion
|
|
1311
|
-
//#region
|
|
1312
|
-
const
|
|
1558
|
+
//#region event/selection-controls.ts
|
|
1559
|
+
const CORNER_DIRS = [
|
|
1313
1560
|
"nw",
|
|
1314
1561
|
"ne",
|
|
1315
1562
|
"se",
|
|
1316
1563
|
"sw"
|
|
1317
1564
|
];
|
|
1318
|
-
const
|
|
1565
|
+
const EDGE_DIRS = [
|
|
1319
1566
|
"n",
|
|
1320
1567
|
"e",
|
|
1321
1568
|
"s",
|
|
1322
1569
|
"w"
|
|
1323
1570
|
];
|
|
1324
|
-
const
|
|
1325
|
-
"nw",
|
|
1326
|
-
"ne",
|
|
1327
|
-
"se",
|
|
1328
|
-
"sw"
|
|
1329
|
-
|
|
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
|
|
1330
1789
|
/**
|
|
1331
1790
|
* Build the per-frame surface chrome.
|
|
1332
1791
|
*
|
|
@@ -1336,34 +1795,44 @@ const ROTATION_CORNERS = [
|
|
|
1336
1795
|
* - `decoration` — pure visual `HUDDraw` (selection outlines, hover outline,
|
|
1337
1796
|
* marquee, line outlines). Not interactable.
|
|
1338
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
|
+
*
|
|
1339
1803
|
* The Surface fans `overlays` into `HitRegions` (for events) and merges
|
|
1340
1804
|
* their render shapes into `decoration` (for the canvas draw call).
|
|
1341
1805
|
*/
|
|
1342
1806
|
function buildChrome(input) {
|
|
1343
|
-
const { state, shapeOf, style } = input;
|
|
1807
|
+
const { state, shapeOf, style, groups } = input;
|
|
1344
1808
|
const transform = state.getTransform();
|
|
1345
1809
|
const overlays = [];
|
|
1346
1810
|
const decoration_rects = [];
|
|
1347
1811
|
const decoration_lines = [];
|
|
1348
|
-
const
|
|
1349
|
-
const hover_id =
|
|
1812
|
+
const decoration_polylines = [];
|
|
1813
|
+
const hover_id = state.gesture.kind === "idle" ? state.getEffectiveHover() : null;
|
|
1350
1814
|
if (hover_id) {
|
|
1351
1815
|
const shape = shapeOf(hover_id);
|
|
1352
|
-
if (shape) pushShapeOutline(shape, decoration_rects, decoration_lines, {
|
|
1816
|
+
if (shape) pushShapeOutline(shape, decoration_rects, decoration_lines, decoration_polylines, {
|
|
1353
1817
|
dashed: false,
|
|
1354
|
-
strokeWidth: style.hoverOutlineWidth
|
|
1818
|
+
strokeWidth: style.hoverOutlineWidth,
|
|
1819
|
+
group: groups?.hover
|
|
1355
1820
|
});
|
|
1356
1821
|
}
|
|
1357
|
-
|
|
1822
|
+
for (const group of state.getSelectionGroups()) {
|
|
1358
1823
|
const shape = resolveGroupShape(group, shapeOf);
|
|
1359
1824
|
if (!shape) continue;
|
|
1360
|
-
pushShapeOutline(shape, decoration_rects, decoration_lines, {
|
|
1825
|
+
pushShapeOutline(shape, decoration_rects, decoration_lines, decoration_polylines, {
|
|
1361
1826
|
dashed: false,
|
|
1362
|
-
strokeWidth: style.selectionOutlineWidth
|
|
1827
|
+
strokeWidth: style.selectionOutlineWidth,
|
|
1828
|
+
group: groups?.selection
|
|
1363
1829
|
});
|
|
1364
|
-
|
|
1365
|
-
if (shape.kind === "
|
|
1366
|
-
else if (shape.kind === "line")
|
|
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
|
+
}
|
|
1367
1836
|
}
|
|
1368
1837
|
if (state.gesture.kind === "marquee") {
|
|
1369
1838
|
const g = state.gesture;
|
|
@@ -1372,20 +1841,35 @@ function buildChrome(input) {
|
|
|
1372
1841
|
...mr,
|
|
1373
1842
|
stroke: true,
|
|
1374
1843
|
fill: true,
|
|
1375
|
-
fillOpacity: .15
|
|
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
|
|
1376
1865
|
});
|
|
1377
1866
|
}
|
|
1378
|
-
if (state.gesture.kind === "resize") decoration_rects.push({
|
|
1379
|
-
...state.gesture.current_rect,
|
|
1380
|
-
stroke: true,
|
|
1381
|
-
fill: false,
|
|
1382
|
-
dashed: true
|
|
1383
|
-
});
|
|
1384
1867
|
return {
|
|
1385
1868
|
overlays,
|
|
1386
1869
|
decoration: {
|
|
1387
1870
|
rects: decoration_rects.length > 0 ? decoration_rects : void 0,
|
|
1388
|
-
lines: decoration_lines.length > 0 ? decoration_lines : void 0
|
|
1871
|
+
lines: decoration_lines.length > 0 ? decoration_lines : void 0,
|
|
1872
|
+
polylines: decoration_polylines.length > 0 ? decoration_polylines : void 0
|
|
1389
1873
|
}
|
|
1390
1874
|
};
|
|
1391
1875
|
}
|
|
@@ -1393,168 +1877,262 @@ function resolveGroupShape(group, shapeOf) {
|
|
|
1393
1877
|
if (group.shape.kind === "unresolved") return shapeOf(group.shape.id);
|
|
1394
1878
|
return group.shape;
|
|
1395
1879
|
}
|
|
1396
|
-
function
|
|
1397
|
-
const
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
out.push({
|
|
1401
|
-
action: {
|
|
1402
|
-
kind: "translate_handle",
|
|
1403
|
-
ids
|
|
1404
|
-
},
|
|
1405
|
-
hit: {
|
|
1406
|
-
kind: "screen_aabb",
|
|
1407
|
-
rect: rect_screen
|
|
1408
|
-
},
|
|
1409
|
-
cursor: "move"
|
|
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
|
|
1410
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
|
+
}
|
|
1411
1889
|
}
|
|
1412
|
-
function
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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,
|
|
1421
1947
|
action: {
|
|
1422
1948
|
kind: "resize_handle",
|
|
1423
|
-
direction:
|
|
1949
|
+
direction: zone.role.direction,
|
|
1424
1950
|
ids,
|
|
1425
|
-
|
|
1951
|
+
initial_shape: {
|
|
1952
|
+
kind: "rect",
|
|
1953
|
+
rect: rect_doc
|
|
1954
|
+
}
|
|
1426
1955
|
},
|
|
1427
1956
|
hit: {
|
|
1428
|
-
kind: "
|
|
1429
|
-
|
|
1430
|
-
width: hit_size,
|
|
1431
|
-
height: hit_size,
|
|
1432
|
-
placement: "center"
|
|
1433
|
-
},
|
|
1434
|
-
render: {
|
|
1435
|
-
kind: "screen_rect",
|
|
1436
|
-
anchor_doc,
|
|
1437
|
-
width: size,
|
|
1438
|
-
height: size,
|
|
1439
|
-
placement: "center",
|
|
1440
|
-
fill: true,
|
|
1441
|
-
stroke: true,
|
|
1442
|
-
fillColor: style.handleFill,
|
|
1443
|
-
strokeColor: style.handleStroke
|
|
1957
|
+
kind: "screen_aabb",
|
|
1958
|
+
rect: zone.rect
|
|
1444
1959
|
},
|
|
1960
|
+
priority: zone.priority,
|
|
1445
1961
|
cursor: {
|
|
1446
1962
|
kind: "resize",
|
|
1447
|
-
direction:
|
|
1963
|
+
direction: zone.role.direction
|
|
1448
1964
|
}
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
const strip = edge_strips[dir];
|
|
1454
|
-
out.push({
|
|
1965
|
+
};
|
|
1966
|
+
case "rotate": return {
|
|
1967
|
+
label: zone.label,
|
|
1968
|
+
group,
|
|
1455
1969
|
action: {
|
|
1456
|
-
kind: "
|
|
1457
|
-
|
|
1970
|
+
kind: "rotate_handle",
|
|
1971
|
+
corner: zone.role.corner,
|
|
1458
1972
|
ids,
|
|
1459
|
-
|
|
1973
|
+
initial_shape: {
|
|
1974
|
+
kind: "rect",
|
|
1975
|
+
rect: rect_doc
|
|
1976
|
+
}
|
|
1460
1977
|
},
|
|
1461
1978
|
hit: {
|
|
1462
1979
|
kind: "screen_aabb",
|
|
1463
|
-
rect:
|
|
1980
|
+
rect: zone.rect
|
|
1464
1981
|
},
|
|
1982
|
+
priority: zone.priority,
|
|
1465
1983
|
cursor: {
|
|
1466
|
-
kind: "
|
|
1467
|
-
|
|
1984
|
+
kind: "rotate",
|
|
1985
|
+
corner: zone.role.corner
|
|
1468
1986
|
}
|
|
1469
|
-
}
|
|
1987
|
+
};
|
|
1470
1988
|
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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
|
|
1491
2065
|
}
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
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;
|
|
1498
2105
|
}
|
|
1499
2106
|
}
|
|
1500
2107
|
}
|
|
1501
|
-
function
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
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
|
|
1511
2119
|
};
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
function edgeStripsScreen(rect_screen, thickness) {
|
|
1519
|
-
const { x, y, width, height } = rect_screen;
|
|
1520
|
-
const inset = thickness / 2;
|
|
1521
|
-
const half = thickness / 2;
|
|
1522
|
-
return {
|
|
1523
|
-
n: {
|
|
1524
|
-
x: x + inset,
|
|
1525
|
-
y: y - half,
|
|
1526
|
-
width: Math.max(0, width - inset * 2),
|
|
1527
|
-
height: thickness
|
|
1528
|
-
},
|
|
1529
|
-
s: {
|
|
1530
|
-
x: x + inset,
|
|
1531
|
-
y: y + height - half,
|
|
1532
|
-
width: Math.max(0, width - inset * 2),
|
|
1533
|
-
height: thickness
|
|
2120
|
+
out.push({
|
|
2121
|
+
label: "translate",
|
|
2122
|
+
group,
|
|
2123
|
+
action: {
|
|
2124
|
+
kind: "translate_handle",
|
|
2125
|
+
ids
|
|
1534
2126
|
},
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
width: thickness,
|
|
1539
|
-
height: Math.max(0, height - inset * 2)
|
|
2127
|
+
hit: {
|
|
2128
|
+
kind: "screen_aabb",
|
|
2129
|
+
rect: hitRect
|
|
1540
2130
|
},
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
width: thickness,
|
|
1545
|
-
height: Math.max(0, height - inset * 2)
|
|
1546
|
-
}
|
|
1547
|
-
};
|
|
1548
|
-
}
|
|
1549
|
-
function rotationOffsetScreen(corner) {
|
|
1550
|
-
switch (corner) {
|
|
1551
|
-
case "nw": return [-12, -12];
|
|
1552
|
-
case "ne": return [12, -12];
|
|
1553
|
-
case "se": return [12, 12];
|
|
1554
|
-
case "sw": return [-12, 12];
|
|
1555
|
-
}
|
|
2131
|
+
priority: HUDHitPriority.TRANSLATE_BODY,
|
|
2132
|
+
cursor: "move"
|
|
2133
|
+
});
|
|
1556
2134
|
}
|
|
1557
|
-
function pushLineEndpoints(id, p1, p2, style, out) {
|
|
2135
|
+
function pushLineEndpoints(id, p1, p2, style, group, out) {
|
|
1558
2136
|
const size = style.handleSize;
|
|
1559
2137
|
const hit_size = Math.max(size + 4, 16);
|
|
1560
2138
|
const endpoints = [{
|
|
@@ -1565,6 +2143,8 @@ function pushLineEndpoints(id, p1, p2, style, out) {
|
|
|
1565
2143
|
pos: p2
|
|
1566
2144
|
}];
|
|
1567
2145
|
for (const ep of endpoints) out.push({
|
|
2146
|
+
label: `endpoint:${ep.which}`,
|
|
2147
|
+
group,
|
|
1568
2148
|
action: {
|
|
1569
2149
|
kind: "endpoint_handle",
|
|
1570
2150
|
endpoint: ep.which,
|
|
@@ -1590,38 +2170,57 @@ function pushLineEndpoints(id, p1, p2, style, out) {
|
|
|
1590
2170
|
fillColor: style.handleFill,
|
|
1591
2171
|
strokeColor: style.handleStroke
|
|
1592
2172
|
},
|
|
2173
|
+
priority: HUDHitPriority.ENDPOINT_HANDLE,
|
|
1593
2174
|
cursor: "pointer"
|
|
1594
2175
|
});
|
|
1595
2176
|
}
|
|
1596
|
-
function pushShapeOutline(shape, rects, lines, opts) {
|
|
2177
|
+
function pushShapeOutline(shape, rects, lines, polylines, opts) {
|
|
1597
2178
|
if (shape.kind === "rect") rects.push({
|
|
1598
2179
|
...shape.rect,
|
|
1599
2180
|
stroke: true,
|
|
1600
2181
|
fill: false,
|
|
1601
2182
|
dashed: opts.dashed,
|
|
1602
|
-
strokeWidth: opts.strokeWidth
|
|
2183
|
+
strokeWidth: opts.strokeWidth,
|
|
2184
|
+
group: opts.group
|
|
1603
2185
|
});
|
|
1604
|
-
else if (shape.kind === "
|
|
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({
|
|
1605
2196
|
x1: shape.p1[0],
|
|
1606
2197
|
y1: shape.p1[1],
|
|
1607
2198
|
x2: shape.p2[0],
|
|
1608
2199
|
y2: shape.p2[1],
|
|
1609
2200
|
dashed: opts.dashed,
|
|
1610
|
-
strokeWidth: opts.strokeWidth
|
|
2201
|
+
strokeWidth: opts.strokeWidth,
|
|
2202
|
+
group: opts.group
|
|
1611
2203
|
});
|
|
1612
2204
|
}
|
|
1613
2205
|
/**
|
|
1614
2206
|
* Fan a list of `OverlayElement`s into per-primitive render arrays and into
|
|
1615
2207
|
* the hit-region registry. Returns the additional render primitives that
|
|
1616
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.
|
|
1617
2212
|
*/
|
|
1618
2213
|
function fanOverlays(overlays, transform, regions) {
|
|
1619
2214
|
regions.clear();
|
|
1620
2215
|
const screenRects = [];
|
|
1621
2216
|
for (const el of overlays) {
|
|
2217
|
+
const projected = projectHit(el.hit, transform);
|
|
1622
2218
|
regions.push({
|
|
1623
|
-
rect:
|
|
1624
|
-
|
|
2219
|
+
rect: projected.rect,
|
|
2220
|
+
inverse_transform: projected.inverse_transform,
|
|
2221
|
+
action: el.action,
|
|
2222
|
+
priority: el.priority,
|
|
2223
|
+
label: el.label
|
|
1625
2224
|
});
|
|
1626
2225
|
if (!el.render) continue;
|
|
1627
2226
|
if (el.render.kind === "screen_rect") screenRects.push({
|
|
@@ -1633,13 +2232,19 @@ function fanOverlays(overlays, transform, regions) {
|
|
|
1633
2232
|
fill: el.render.fill,
|
|
1634
2233
|
stroke: el.render.stroke,
|
|
1635
2234
|
fillColor: el.render.fillColor,
|
|
1636
|
-
strokeColor: el.render.strokeColor
|
|
2235
|
+
strokeColor: el.render.strokeColor,
|
|
2236
|
+
angle: el.render.angle,
|
|
2237
|
+
group: el.group
|
|
1637
2238
|
});
|
|
1638
2239
|
}
|
|
1639
2240
|
return { screenRects };
|
|
1640
2241
|
}
|
|
1641
|
-
function
|
|
1642
|
-
if (hit.kind === "screen_aabb") return hit.rect;
|
|
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
|
+
};
|
|
1643
2248
|
const [sx, sy] = docToScreen(transform, hit.anchor_doc[0], hit.anchor_doc[1]);
|
|
1644
2249
|
const placement = hit.placement ?? "center";
|
|
1645
2250
|
let x = sx;
|
|
@@ -1666,12 +2271,12 @@ function projectHitAABB(hit, transform) {
|
|
|
1666
2271
|
y = sy - hit.height;
|
|
1667
2272
|
break;
|
|
1668
2273
|
}
|
|
1669
|
-
return {
|
|
2274
|
+
return { rect: {
|
|
1670
2275
|
x,
|
|
1671
2276
|
y,
|
|
1672
2277
|
width: hit.width,
|
|
1673
2278
|
height: hit.height
|
|
1674
|
-
};
|
|
2279
|
+
} };
|
|
1675
2280
|
}
|
|
1676
2281
|
/**
|
|
1677
2282
|
* Merge a base `HUDDraw` (chrome decoration) with optional host-fed extras
|
|
@@ -1710,11 +2315,25 @@ var Surface = class {
|
|
|
1710
2315
|
constructor(canvas, options) {
|
|
1711
2316
|
this.width = 0;
|
|
1712
2317
|
this.height = 0;
|
|
2318
|
+
this.cursor_renderer = null;
|
|
1713
2319
|
this.opts = options;
|
|
1714
2320
|
this.style = mergeStyle(DEFAULT_STYLE, options.style);
|
|
1715
|
-
this.
|
|
2321
|
+
this.colorOverride = options.color;
|
|
2322
|
+
this.hudCanvas = new HUDCanvas(canvas, { color: this.colorOverride ?? this.style.chromeColor });
|
|
1716
2323
|
this.state = new SurfaceState();
|
|
1717
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);
|
|
1718
2337
|
}
|
|
1719
2338
|
setSize(w, h) {
|
|
1720
2339
|
this.width = w;
|
|
@@ -1740,7 +2359,15 @@ var Surface = class {
|
|
|
1740
2359
|
}
|
|
1741
2360
|
setStyle(partial) {
|
|
1742
2361
|
this.style = mergeStyle(this.style, partial);
|
|
1743
|
-
this.hudCanvas.setColor(this.style.chromeColor);
|
|
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);
|
|
1744
2371
|
}
|
|
1745
2372
|
setReadonly(v) {
|
|
1746
2373
|
this.state.setReadonly(v);
|
|
@@ -1777,11 +2404,13 @@ var Surface = class {
|
|
|
1777
2404
|
state: this.state,
|
|
1778
2405
|
shapeOf: this.opts.shapeOf,
|
|
1779
2406
|
style: this.style,
|
|
2407
|
+
groups: this.opts.groups,
|
|
1780
2408
|
width: this.width,
|
|
1781
2409
|
height: this.height
|
|
1782
2410
|
});
|
|
1783
|
-
const
|
|
1784
|
-
|
|
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 }));
|
|
1785
2414
|
}
|
|
1786
2415
|
/** Convenience: clear the canvas (e.g. when the host stops the surface). */
|
|
1787
2416
|
clear() {
|
|
@@ -1800,9 +2429,43 @@ var Surface = class {
|
|
|
1800
2429
|
cursor() {
|
|
1801
2430
|
return this.state.cursor;
|
|
1802
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
|
+
}
|
|
1803
2459
|
modifiers() {
|
|
1804
2460
|
return this.state.modifiers;
|
|
1805
2461
|
}
|
|
1806
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
|
+
}
|
|
1807
2470
|
//#endregion
|
|
1808
|
-
export {
|
|
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 };
|