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