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