@canvus/core 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/README.md +80 -0
- package/dist/drop-zone.d.ts +48 -0
- package/dist/drop-zone.d.ts.map +1 -0
- package/dist/drop-zone.js +230 -0
- package/dist/drop-zone.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/layout.d.ts +143 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +278 -0
- package/dist/layout.js.map +1 -0
- package/dist/matrix.d.ts +168 -0
- package/dist/matrix.d.ts.map +1 -0
- package/dist/matrix.js +264 -0
- package/dist/matrix.js.map +1 -0
- package/dist/renderer.d.ts +286 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +892 -0
- package/dist/renderer.js.map +1 -0
- package/dist/shadow-mount.d.ts +367 -0
- package/dist/shadow-mount.d.ts.map +1 -0
- package/dist/shadow-mount.js +1120 -0
- package/dist/shadow-mount.js.map +1 -0
- package/dist/tree.d.ts +134 -0
- package/dist/tree.d.ts.map +1 -0
- package/dist/tree.js +458 -0
- package/dist/tree.js.map +1 -0
- package/dist/types.d.ts +180 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +43 -0
- package/dist/types.js.map +1 -0
- package/dist/workspace.d.ts +371 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +3922 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +30 -0
package/dist/renderer.js
ADDED
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// canvus/src/renderer.ts
|
|
3
|
+
// Canvas Overlay Rendering Engine — Selection outlines, resize
|
|
4
|
+
// handle affordances, hover highlights, alignment guides, and
|
|
5
|
+
// interactive handle hit-testing.
|
|
6
|
+
//
|
|
7
|
+
// All drawing is DPR-aware and operates in screen-space after
|
|
8
|
+
// projecting canvas-space geometry through the viewport matrix.
|
|
9
|
+
// ─────────────────────────────────────────────────────────────
|
|
10
|
+
/** Sensible defaults tuned for a dark workspace aesthetic. */
|
|
11
|
+
const DEFAULT_STYLE = {
|
|
12
|
+
selectionStroke: "#6366f1",
|
|
13
|
+
selectionWidth: 2,
|
|
14
|
+
selectionGlow: "rgba(99, 102, 241, 0.4)",
|
|
15
|
+
selectionGlowRadius: 12,
|
|
16
|
+
hoverStroke: "rgba(99, 102, 241, 0.6)",
|
|
17
|
+
hoverWidth: 1.5,
|
|
18
|
+
hoverDash: [5, 5],
|
|
19
|
+
handleSize: 8,
|
|
20
|
+
handleFill: "#ffffff",
|
|
21
|
+
handleStroke: "#6366f1",
|
|
22
|
+
handleStrokeWidth: 1.5,
|
|
23
|
+
handleRadius: 2,
|
|
24
|
+
handleHitRadius: 8,
|
|
25
|
+
handleActiveFill: "#6366f1",
|
|
26
|
+
guideStroke: "#f43f5e",
|
|
27
|
+
guideWidth: 1,
|
|
28
|
+
guideDash: [4, 4],
|
|
29
|
+
originStroke: "rgba(99, 102, 241, 0.12)",
|
|
30
|
+
originDash: [6, 6],
|
|
31
|
+
multiSelectStroke: "rgba(99, 102, 241, 0.5)",
|
|
32
|
+
multiSelectDash: [6, 4],
|
|
33
|
+
layoutBadgeBg: "rgba(99, 102, 241, 0.85)",
|
|
34
|
+
layoutBadgeText: "#ffffff",
|
|
35
|
+
layoutBadgeFont: "600 9px 'Inter', system-ui, sans-serif",
|
|
36
|
+
gridTrackStroke: "rgba(99, 102, 241, 0.2)",
|
|
37
|
+
gridTrackDash: [3, 3],
|
|
38
|
+
parentHighlightStroke: "rgba(99, 102, 241, 0.35)",
|
|
39
|
+
childOutlineStroke: "rgba(99, 102, 241, 0.18)",
|
|
40
|
+
dropZoneStroke: "#3b82f6",
|
|
41
|
+
dropZoneWidth: 2,
|
|
42
|
+
insertionLineStroke: "#3b82f6",
|
|
43
|
+
insertionLineWidth: 3,
|
|
44
|
+
};
|
|
45
|
+
// ── Anchor Ordering ─────────────────────────────────────────
|
|
46
|
+
/**
|
|
47
|
+
* Canonical order of the 8 resize anchors, clockwise from NW.
|
|
48
|
+
* Used for iteration in drawing and hit-testing routines.
|
|
49
|
+
*/
|
|
50
|
+
const ANCHOR_ORDER = [
|
|
51
|
+
"nw", "n", "ne", "e", "se", "s", "sw", "w",
|
|
52
|
+
];
|
|
53
|
+
// ── Cursor Mapping ──────────────────────────────────────────
|
|
54
|
+
/** Maps each resize anchor to its CSS cursor value. */
|
|
55
|
+
const ANCHOR_CURSORS = {
|
|
56
|
+
nw: "nwse-resize",
|
|
57
|
+
n: "ns-resize",
|
|
58
|
+
ne: "nesw-resize",
|
|
59
|
+
e: "ew-resize",
|
|
60
|
+
se: "nwse-resize",
|
|
61
|
+
s: "ns-resize",
|
|
62
|
+
sw: "nesw-resize",
|
|
63
|
+
w: "ew-resize",
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Returns the appropriate CSS cursor string for a given
|
|
67
|
+
* resize anchor direction.
|
|
68
|
+
*
|
|
69
|
+
* @param anchor - The anchor being hovered, or `null`.
|
|
70
|
+
* @returns CSS cursor value (e.g. `"nwse-resize"`), or
|
|
71
|
+
* `"default"` if no anchor is active.
|
|
72
|
+
*/
|
|
73
|
+
export function anchorCursor(anchor) {
|
|
74
|
+
return anchor ? ANCHOR_CURSORS[anchor] : "default";
|
|
75
|
+
}
|
|
76
|
+
// ── Overlay Renderer ────────────────────────────────────────
|
|
77
|
+
/**
|
|
78
|
+
* Hardware-accelerated canvas overlay renderer.
|
|
79
|
+
*
|
|
80
|
+
* Handles all visual affordances drawn on top of the Shadow DOM
|
|
81
|
+
* projection layer: selection outlines with glow, 8-point resize
|
|
82
|
+
* handles, hover highlights, alignment guides, and origin markers.
|
|
83
|
+
*
|
|
84
|
+
* ### Usage
|
|
85
|
+
* ```ts
|
|
86
|
+
* const renderer = new OverlayRenderer(canvas);
|
|
87
|
+
* renderer.resize(width, height);
|
|
88
|
+
* renderer.render(frame);
|
|
89
|
+
* ```
|
|
90
|
+
*
|
|
91
|
+
* ### Performance Notes
|
|
92
|
+
* - Single `render()` call per frame — no internal rAF loop.
|
|
93
|
+
* - DPR-aware: physical pixels are scaled so lines stay crisp
|
|
94
|
+
* on retina displays.
|
|
95
|
+
* - Canvas state changes are minimized by batching similar
|
|
96
|
+
* operations (hover pass → selection pass → handle pass).
|
|
97
|
+
*/
|
|
98
|
+
export function isContainerNode(node) {
|
|
99
|
+
if (node.childIds.length > 0 ||
|
|
100
|
+
node.layoutMode === "flex" ||
|
|
101
|
+
node.layoutMode === "grid" ||
|
|
102
|
+
node.layoutMode === "inline-flex" ||
|
|
103
|
+
node.layoutMode === "inline-grid") {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
if (node.rawMarkup) {
|
|
107
|
+
const match = node.rawMarkup.trim().match(/^<([a-zA-Z0-9-]+)/);
|
|
108
|
+
if (match && match[1]) {
|
|
109
|
+
const tag = match[1].toLowerCase();
|
|
110
|
+
const nonContainerTags = new Set([
|
|
111
|
+
"p", "h1", "h2", "h3", "h4", "h5", "h6", "span", "img", "br", "hr",
|
|
112
|
+
"input", "button", "textarea", "select", "a", "strong", "em", "code", "pre"
|
|
113
|
+
]);
|
|
114
|
+
return !nonContainerTags.has(tag);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
export class OverlayRenderer {
|
|
120
|
+
canvas;
|
|
121
|
+
ctx;
|
|
122
|
+
style;
|
|
123
|
+
dpr = 1;
|
|
124
|
+
width = 0;
|
|
125
|
+
height = 0;
|
|
126
|
+
/**
|
|
127
|
+
* @param canvas - The `<canvas>` element to draw on.
|
|
128
|
+
* @param style - Optional partial style overrides.
|
|
129
|
+
*/
|
|
130
|
+
constructor(canvas, style) {
|
|
131
|
+
this.canvas = canvas;
|
|
132
|
+
const ctx = canvas.getContext("2d");
|
|
133
|
+
if (!ctx)
|
|
134
|
+
throw new Error("[OverlayRenderer] Failed to get 2D context.");
|
|
135
|
+
this.ctx = ctx;
|
|
136
|
+
this.style = { ...DEFAULT_STYLE, ...style };
|
|
137
|
+
}
|
|
138
|
+
// ── Lifecycle ───────────────────────────────────
|
|
139
|
+
/**
|
|
140
|
+
* Resizes the canvas buffer to match the given CSS dimensions,
|
|
141
|
+
* scaling by the device pixel ratio for crisp rendering.
|
|
142
|
+
*
|
|
143
|
+
* Call this on window resize and initial setup.
|
|
144
|
+
*
|
|
145
|
+
* @param cssWidth - Desired CSS width in pixels.
|
|
146
|
+
* @param cssHeight - Desired CSS height in pixels.
|
|
147
|
+
*/
|
|
148
|
+
resize(cssWidth, cssHeight) {
|
|
149
|
+
this.dpr = window.devicePixelRatio || 1;
|
|
150
|
+
this.width = cssWidth;
|
|
151
|
+
this.height = cssHeight;
|
|
152
|
+
this.canvas.width = cssWidth * this.dpr;
|
|
153
|
+
this.canvas.height = cssHeight * this.dpr;
|
|
154
|
+
this.canvas.style.width = `${cssWidth}px`;
|
|
155
|
+
this.canvas.style.height = `${cssHeight}px`;
|
|
156
|
+
// Scale the context so all drawing commands use CSS pixels.
|
|
157
|
+
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
|
|
158
|
+
}
|
|
159
|
+
// ── Main Render Pass ────────────────────────────
|
|
160
|
+
/**
|
|
161
|
+
* Draws a complete overlay frame.
|
|
162
|
+
*
|
|
163
|
+
* Rendering order (painter's algorithm, back to front):
|
|
164
|
+
* 1. Clear
|
|
165
|
+
* 2. Origin crosshair
|
|
166
|
+
* 3. Alignment guides
|
|
167
|
+
* 4. Hover outlines (non-selected, hovered node)
|
|
168
|
+
* 5. Selection outlines + glow
|
|
169
|
+
* 6. Multi-select bounding box (if > 1 selected)
|
|
170
|
+
* 7. Resize handles (selected nodes only)
|
|
171
|
+
*/
|
|
172
|
+
render(frame) {
|
|
173
|
+
const { ctx, style, width, height } = this;
|
|
174
|
+
const { viewport, nodes, selectedIds, hoveredId, activeAnchor, guides } = frame;
|
|
175
|
+
// 1. Clear
|
|
176
|
+
ctx.clearRect(0, 0, width, height);
|
|
177
|
+
// 2. Origin crosshair
|
|
178
|
+
this.drawOrigin(viewport);
|
|
179
|
+
// 3. Alignment guides
|
|
180
|
+
for (const guide of guides) {
|
|
181
|
+
this.drawGuide(guide, viewport);
|
|
182
|
+
}
|
|
183
|
+
// Pre-compute projected rects for all nodes with bounds.
|
|
184
|
+
const projected = new Map();
|
|
185
|
+
for (const node of nodes) {
|
|
186
|
+
if (!node.currentRect)
|
|
187
|
+
continue;
|
|
188
|
+
const r = node.currentRect;
|
|
189
|
+
projected.set(node.id, {
|
|
190
|
+
sx: r.x * viewport.scale + viewport.offsetX,
|
|
191
|
+
sy: r.y * viewport.scale + viewport.offsetY,
|
|
192
|
+
sw: r.width * viewport.scale,
|
|
193
|
+
sh: r.height * viewport.scale,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
// 4. Hover outlines
|
|
197
|
+
if (hoveredId && !selectedIds.has(hoveredId)) {
|
|
198
|
+
const p = projected.get(hoveredId);
|
|
199
|
+
if (p) {
|
|
200
|
+
ctx.strokeStyle = style.hoverStroke;
|
|
201
|
+
ctx.lineWidth = style.hoverWidth;
|
|
202
|
+
ctx.setLineDash(style.hoverDash);
|
|
203
|
+
ctx.strokeRect(p.sx, p.sy, p.sw, p.sh);
|
|
204
|
+
ctx.setLineDash([]);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
// 4.5 Scoped Selection Affordances (M7)
|
|
208
|
+
// (Static children outlines removed per request; outlines are now only shown on hover)
|
|
209
|
+
// Draw parent highlight when a child is selected to maintain spatial context.
|
|
210
|
+
ctx.strokeStyle = style.parentHighlightStroke;
|
|
211
|
+
ctx.lineWidth = 1.5;
|
|
212
|
+
ctx.setLineDash([4, 4]);
|
|
213
|
+
for (const id of selectedIds) {
|
|
214
|
+
const node = nodes.find(n => n.id === id);
|
|
215
|
+
if (node?.parentId) {
|
|
216
|
+
const p = projected.get(node.parentId);
|
|
217
|
+
if (p) {
|
|
218
|
+
ctx.strokeRect(p.sx - 1, p.sy - 1, p.sw + 2, p.sh + 2);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
ctx.setLineDash([]);
|
|
223
|
+
// 5. Selection outlines + glow
|
|
224
|
+
for (const id of selectedIds) {
|
|
225
|
+
const p = projected.get(id);
|
|
226
|
+
if (!p)
|
|
227
|
+
continue;
|
|
228
|
+
// Glow pass (drawn first, behind the solid stroke).
|
|
229
|
+
ctx.save();
|
|
230
|
+
ctx.shadowColor = style.selectionGlow;
|
|
231
|
+
ctx.shadowBlur = style.selectionGlowRadius;
|
|
232
|
+
ctx.strokeStyle = style.selectionStroke;
|
|
233
|
+
ctx.lineWidth = style.selectionWidth;
|
|
234
|
+
ctx.strokeRect(p.sx - 1, p.sy - 1, p.sw + 2, p.sh + 2);
|
|
235
|
+
ctx.restore();
|
|
236
|
+
// Solid stroke on top.
|
|
237
|
+
ctx.strokeStyle = style.selectionStroke;
|
|
238
|
+
ctx.lineWidth = style.selectionWidth;
|
|
239
|
+
ctx.strokeRect(p.sx - 1, p.sy - 1, p.sw + 2, p.sh + 2);
|
|
240
|
+
}
|
|
241
|
+
// 6. Multi-select bounding box
|
|
242
|
+
if (selectedIds.size > 1) {
|
|
243
|
+
this.drawMultiSelectBounds(selectedIds, projected);
|
|
244
|
+
}
|
|
245
|
+
// 7. Resize handles
|
|
246
|
+
for (const id of selectedIds) {
|
|
247
|
+
const p = projected.get(id);
|
|
248
|
+
if (!p)
|
|
249
|
+
continue;
|
|
250
|
+
this.drawHandles(p.sx, p.sy, p.sw, p.sh, activeAnchor);
|
|
251
|
+
}
|
|
252
|
+
// 7b. Corner radius handles
|
|
253
|
+
for (const id of selectedIds) {
|
|
254
|
+
const node = frame.nodes.find(n => n.id === id);
|
|
255
|
+
if (node && isContainerNode(node)) {
|
|
256
|
+
const isHovered = frame.hoveredId === id;
|
|
257
|
+
const shouldDrawHandles = selectedIds.size === 1 || isHovered || frame.activeRadiusCorner;
|
|
258
|
+
if (shouldDrawHandles) {
|
|
259
|
+
const p = projected.get(id);
|
|
260
|
+
if (p && p.sw >= 64 && p.sh >= 64) {
|
|
261
|
+
const inset = 16;
|
|
262
|
+
const handles = [
|
|
263
|
+
{ type: "tl", hx: p.sx + inset, hy: p.sy + inset },
|
|
264
|
+
{ type: "tr", hx: p.sx + p.sw - inset, hy: p.sy + inset },
|
|
265
|
+
{ type: "bl", hx: p.sx + inset, hy: p.sy + p.sh - inset },
|
|
266
|
+
{ type: "br", hx: p.sx + p.sw - inset, hy: p.sy + p.sh - inset },
|
|
267
|
+
];
|
|
268
|
+
for (const handle of handles) {
|
|
269
|
+
const isActive = handle.type === frame.activeRadiusCorner && (selectedIds.size === 1 || isHovered || frame.activeRadiusCorner);
|
|
270
|
+
ctx.beginPath();
|
|
271
|
+
ctx.arc(handle.hx, handle.hy, isActive ? 5 : 3.5, 0, Math.PI * 2);
|
|
272
|
+
ctx.fillStyle = isActive ? style.selectionStroke : "#ffffff";
|
|
273
|
+
ctx.fill();
|
|
274
|
+
ctx.strokeStyle = style.selectionStroke;
|
|
275
|
+
ctx.lineWidth = isActive ? 2 : 1.5;
|
|
276
|
+
ctx.stroke();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// 8. Layout badges (M6)
|
|
283
|
+
if (frame.layoutBadges) {
|
|
284
|
+
const offsets = new Map();
|
|
285
|
+
for (const badge of frame.layoutBadges) {
|
|
286
|
+
const key = `${badge.rect.x},${badge.rect.y}`;
|
|
287
|
+
const currentOffset = offsets.get(key) || 0;
|
|
288
|
+
const widthUsed = this.drawLayoutBadge(badge.rect, badge.label, viewport, currentOffset, badge.isJS);
|
|
289
|
+
offsets.set(key, currentOffset + widthUsed + 4);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// 9. Grid track overlays (M6)
|
|
293
|
+
if (frame.gridOverlays) {
|
|
294
|
+
for (const overlay of frame.gridOverlays) {
|
|
295
|
+
this.drawGridOverlay(overlay.rect, overlay.columns, overlay.rows, viewport);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// 10. Drop zone highlight (M8)
|
|
299
|
+
const target = frame.activeDropTarget;
|
|
300
|
+
if (target) {
|
|
301
|
+
const parentNode = nodes.find(n => n.id === target.parentId);
|
|
302
|
+
if (parentNode?.currentRect) {
|
|
303
|
+
const p = projected.get(parentNode.id);
|
|
304
|
+
if (p) {
|
|
305
|
+
ctx.strokeStyle = style.dropZoneStroke;
|
|
306
|
+
ctx.lineWidth = style.dropZoneWidth;
|
|
307
|
+
ctx.strokeRect(p.sx - 1, p.sy - 1, p.sw + 2, p.sh + 2);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// 11. Insertion preview (M8) - line or grid cell highlights
|
|
311
|
+
if (target.gridPlacement) {
|
|
312
|
+
const gp = target.gridPlacement;
|
|
313
|
+
// Project to screen space
|
|
314
|
+
const gsx = gp.rect.x * viewport.scale + viewport.offsetX;
|
|
315
|
+
const gsy = gp.rect.y * viewport.scale + viewport.offsetY;
|
|
316
|
+
const gsw = gp.rect.width * viewport.scale;
|
|
317
|
+
const gsh = gp.rect.height * viewport.scale;
|
|
318
|
+
// Draw translucent cell fill
|
|
319
|
+
ctx.fillStyle = "rgba(99, 102, 241, 0.18)";
|
|
320
|
+
ctx.fillRect(gsx, gsy, gsw, gsh);
|
|
321
|
+
// Draw solid drop zone outline
|
|
322
|
+
ctx.strokeStyle = style.dropZoneStroke;
|
|
323
|
+
ctx.lineWidth = style.dropZoneWidth;
|
|
324
|
+
ctx.strokeRect(gsx, gsy, gsw, gsh);
|
|
325
|
+
// Draw Premium Grid Badge Tooltip at top-left of the shaded rect
|
|
326
|
+
const label = `Grid: Row ${gp.rowStart}, Col ${gp.colStart} (Span ${gp.colSpan}x${gp.rowSpan})`;
|
|
327
|
+
ctx.font = "600 9px 'JetBrains Mono', monospace";
|
|
328
|
+
ctx.textBaseline = "middle";
|
|
329
|
+
ctx.textAlign = "center";
|
|
330
|
+
const tm = ctx.measureText(label);
|
|
331
|
+
const tw = tm.width;
|
|
332
|
+
const th = 14;
|
|
333
|
+
const pad = 5;
|
|
334
|
+
const bx = gsx + gsw / 2 - tw / 2 - pad;
|
|
335
|
+
const by = gsy - th - 5; // Above the cell highlight
|
|
336
|
+
ctx.fillStyle = "rgba(10, 10, 15, 0.95)";
|
|
337
|
+
ctx.strokeStyle = style.dropZoneStroke;
|
|
338
|
+
ctx.lineWidth = 1;
|
|
339
|
+
ctx.beginPath();
|
|
340
|
+
ctx.roundRect(bx, by, tw + pad * 2, th, 3);
|
|
341
|
+
ctx.fill();
|
|
342
|
+
ctx.stroke();
|
|
343
|
+
ctx.fillStyle = "#ffffff";
|
|
344
|
+
ctx.fillText(label, gsx + gsw / 2, by + th / 2);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
const ind = target.indicator;
|
|
348
|
+
ctx.strokeStyle = style.insertionLineStroke;
|
|
349
|
+
ctx.lineWidth = style.insertionLineWidth;
|
|
350
|
+
const x1 = ind.x1 * viewport.scale + viewport.offsetX;
|
|
351
|
+
const y1 = ind.y1 * viewport.scale + viewport.offsetY;
|
|
352
|
+
const x2 = ind.x2 * viewport.scale + viewport.offsetX;
|
|
353
|
+
const y2 = ind.y2 * viewport.scale + viewport.offsetY;
|
|
354
|
+
ctx.beginPath();
|
|
355
|
+
ctx.moveTo(x1, y1);
|
|
356
|
+
ctx.lineTo(x2, y2);
|
|
357
|
+
ctx.stroke();
|
|
358
|
+
// Premium terminal dots
|
|
359
|
+
ctx.fillStyle = style.insertionLineStroke;
|
|
360
|
+
ctx.beginPath();
|
|
361
|
+
ctx.arc(x1, y1, 4, 0, Math.PI * 2);
|
|
362
|
+
ctx.arc(x2, y2, 4, 0, Math.PI * 2);
|
|
363
|
+
ctx.fill();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// 12. Marquee selection (dashed blue outline + translucent fill)
|
|
367
|
+
if (frame.marqueeRect) {
|
|
368
|
+
const p = {
|
|
369
|
+
sx: frame.marqueeRect.x * viewport.scale + viewport.offsetX,
|
|
370
|
+
sy: frame.marqueeRect.y * viewport.scale + viewport.offsetY,
|
|
371
|
+
sw: frame.marqueeRect.width * viewport.scale,
|
|
372
|
+
sh: frame.marqueeRect.height * viewport.scale,
|
|
373
|
+
};
|
|
374
|
+
ctx.strokeStyle = style.selectionStroke;
|
|
375
|
+
ctx.lineWidth = 1;
|
|
376
|
+
ctx.setLineDash([4, 3]);
|
|
377
|
+
ctx.strokeRect(p.sx, p.sy, p.sw, p.sh);
|
|
378
|
+
ctx.setLineDash([]);
|
|
379
|
+
ctx.fillStyle = "rgba(99, 102, 241, 0.08)";
|
|
380
|
+
ctx.fillRect(p.sx, p.sy, p.sw, p.sh);
|
|
381
|
+
}
|
|
382
|
+
// 13. Spacing adjusters (padding & margin)
|
|
383
|
+
if (frame.spacingAdjusters) {
|
|
384
|
+
let activeAdjuster = null;
|
|
385
|
+
let activeProjectedRect = null;
|
|
386
|
+
for (const adj of frame.spacingAdjusters) {
|
|
387
|
+
const pVisual = {
|
|
388
|
+
sx: adj.visualRect.x * viewport.scale + viewport.offsetX,
|
|
389
|
+
sy: adj.visualRect.y * viewport.scale + viewport.offsetY,
|
|
390
|
+
sw: adj.visualRect.width * viewport.scale,
|
|
391
|
+
sh: adj.visualRect.height * viewport.scale,
|
|
392
|
+
};
|
|
393
|
+
if (adj.isActive) {
|
|
394
|
+
activeAdjuster = adj;
|
|
395
|
+
activeProjectedRect = {
|
|
396
|
+
sx: adj.rect.x * viewport.scale + viewport.offsetX,
|
|
397
|
+
sy: adj.rect.y * viewport.scale + viewport.offsetY,
|
|
398
|
+
sw: adj.rect.width * viewport.scale,
|
|
399
|
+
sh: adj.rect.height * viewport.scale,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
const isPadding = adj.type.startsWith("padding");
|
|
403
|
+
const baseColor = isPadding ? "34, 197, 94" : "249, 115, 22"; // Green vs Orange
|
|
404
|
+
if (adj.isHovered || adj.isActive) {
|
|
405
|
+
ctx.fillStyle = `rgba(${baseColor}, 0.25)`;
|
|
406
|
+
ctx.fillRect(pVisual.sx, pVisual.sy, pVisual.sw, pVisual.sh);
|
|
407
|
+
ctx.strokeStyle = `rgba(${baseColor}, 0.85)`;
|
|
408
|
+
ctx.lineWidth = 1.5;
|
|
409
|
+
ctx.strokeRect(pVisual.sx, pVisual.sy, pVisual.sw, pVisual.sh);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// Draw tooltip for the active adjuster on top
|
|
413
|
+
if (activeAdjuster && activeProjectedRect) {
|
|
414
|
+
const adj = activeAdjuster;
|
|
415
|
+
const p = activeProjectedRect;
|
|
416
|
+
const label = `${adj.type}: ${adj.value}px`;
|
|
417
|
+
ctx.font = "600 10px 'JetBrains Mono', monospace";
|
|
418
|
+
ctx.textBaseline = "middle";
|
|
419
|
+
ctx.textAlign = "center";
|
|
420
|
+
const tm = ctx.measureText(label);
|
|
421
|
+
const tw = tm.width;
|
|
422
|
+
const th = 16;
|
|
423
|
+
const pad = 6;
|
|
424
|
+
const bx = p.sx + p.sw / 2 - tw / 2 - pad;
|
|
425
|
+
const by = p.sy + p.sh / 2 - th / 2;
|
|
426
|
+
ctx.fillStyle = "rgba(10, 10, 15, 0.95)";
|
|
427
|
+
ctx.strokeStyle = "rgba(99, 102, 241, 0.8)";
|
|
428
|
+
ctx.lineWidth = 1;
|
|
429
|
+
ctx.beginPath();
|
|
430
|
+
ctx.roundRect(bx, by, tw + pad * 2, th, 4);
|
|
431
|
+
ctx.fill();
|
|
432
|
+
ctx.stroke();
|
|
433
|
+
ctx.fillStyle = "#ffffff";
|
|
434
|
+
ctx.fillText(label, p.sx + p.sw / 2, p.sy + p.sh / 2);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// 14. Drag & Resize tooltips
|
|
438
|
+
if (frame.draggedNodeId || frame.resizedNodeId) {
|
|
439
|
+
const targetId = frame.draggedNodeId || frame.resizedNodeId;
|
|
440
|
+
const node = nodes.find(n => n.id === targetId);
|
|
441
|
+
const p = targetId ? projected.get(targetId) : null;
|
|
442
|
+
if (node && node.currentRect && p) {
|
|
443
|
+
let label = "";
|
|
444
|
+
if (frame.draggedNodeId) {
|
|
445
|
+
label = `X: ${Math.round(node.currentRect.x)} Y: ${Math.round(node.currentRect.y)}`;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
label = `W: ${Math.round(node.currentRect.width)} H: ${Math.round(node.currentRect.height)}`;
|
|
449
|
+
}
|
|
450
|
+
ctx.font = "600 10px 'JetBrains Mono', monospace";
|
|
451
|
+
ctx.textBaseline = "middle";
|
|
452
|
+
ctx.textAlign = "center";
|
|
453
|
+
const tm = ctx.measureText(label);
|
|
454
|
+
const tw = tm.width;
|
|
455
|
+
const th = 16;
|
|
456
|
+
const pad = 6;
|
|
457
|
+
const gap = 8;
|
|
458
|
+
const tooltipX = p.sx + p.sw / 2;
|
|
459
|
+
let by = p.sy + p.sh + gap;
|
|
460
|
+
// Flip to top if it overflows the canvas bottom
|
|
461
|
+
if (by + th > height) {
|
|
462
|
+
by = p.sy - gap - th;
|
|
463
|
+
}
|
|
464
|
+
const bx = tooltipX - tw / 2 - pad;
|
|
465
|
+
ctx.fillStyle = "rgba(10, 10, 15, 0.95)";
|
|
466
|
+
ctx.strokeStyle = style.selectionStroke;
|
|
467
|
+
ctx.lineWidth = 1;
|
|
468
|
+
ctx.beginPath();
|
|
469
|
+
ctx.roundRect(bx, by, tw + pad * 2, th, 4);
|
|
470
|
+
ctx.fill();
|
|
471
|
+
ctx.stroke();
|
|
472
|
+
ctx.fillStyle = "#ffffff";
|
|
473
|
+
ctx.fillText(label, tooltipX, by + th / 2);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// 15. Active node drawing preview
|
|
477
|
+
if (frame.drawingRect) {
|
|
478
|
+
const p = {
|
|
479
|
+
sx: frame.drawingRect.x * viewport.scale + viewport.offsetX,
|
|
480
|
+
sy: frame.drawingRect.y * viewport.scale + viewport.offsetY,
|
|
481
|
+
sw: frame.drawingRect.width * viewport.scale,
|
|
482
|
+
sh: frame.drawingRect.height * viewport.scale,
|
|
483
|
+
};
|
|
484
|
+
ctx.strokeStyle = "rgba(99, 102, 241, 0.8)";
|
|
485
|
+
ctx.lineWidth = 1.5;
|
|
486
|
+
ctx.setLineDash([4, 3]);
|
|
487
|
+
ctx.strokeRect(p.sx, p.sy, p.sw, p.sh);
|
|
488
|
+
ctx.setLineDash([]);
|
|
489
|
+
ctx.fillStyle = "rgba(99, 102, 241, 0.06)";
|
|
490
|
+
ctx.fillRect(p.sx, p.sy, p.sw, p.sh);
|
|
491
|
+
const tag = frame.drawingTag || "div";
|
|
492
|
+
const label = `${tag}: ${Math.round(frame.drawingRect.width)} x ${Math.round(frame.drawingRect.height)}`;
|
|
493
|
+
ctx.font = "600 10px 'JetBrains Mono', monospace";
|
|
494
|
+
ctx.textBaseline = "middle";
|
|
495
|
+
ctx.textAlign = "center";
|
|
496
|
+
const tm = ctx.measureText(label);
|
|
497
|
+
const tw = tm.width;
|
|
498
|
+
const th = 16;
|
|
499
|
+
const pad = 6;
|
|
500
|
+
const gap = 8;
|
|
501
|
+
const tooltipX = p.sx + p.sw / 2;
|
|
502
|
+
let by = p.sy + p.sh + gap;
|
|
503
|
+
if (by + th > height) {
|
|
504
|
+
by = p.sy - gap - th;
|
|
505
|
+
}
|
|
506
|
+
const bx = tooltipX - tw / 2 - pad;
|
|
507
|
+
ctx.fillStyle = "rgba(10, 10, 15, 0.95)";
|
|
508
|
+
ctx.strokeStyle = "rgba(99, 102, 241, 0.85)";
|
|
509
|
+
ctx.lineWidth = 1;
|
|
510
|
+
ctx.beginPath();
|
|
511
|
+
ctx.roundRect(bx, by, tw + pad * 2, th, 4);
|
|
512
|
+
ctx.fill();
|
|
513
|
+
ctx.stroke();
|
|
514
|
+
ctx.fillStyle = "#ffffff";
|
|
515
|
+
ctx.fillText(label, tooltipX, by + th / 2);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
// ── Handle Hit-Testing ──────────────────────────
|
|
519
|
+
/**
|
|
520
|
+
* Tests if a screen-space point is within the hit radius of
|
|
521
|
+
* any of the 8 resize handles for a given element.
|
|
522
|
+
*
|
|
523
|
+
* The `canvasRect` parameter is not needed here because
|
|
524
|
+
* `bounds` is in canvas-space and we project it using the
|
|
525
|
+
* viewport ourselves.
|
|
526
|
+
*
|
|
527
|
+
* @param screenX - Pointer X in screen-space (relative to
|
|
528
|
+
* the canvas element, NOT clientX).
|
|
529
|
+
* @param screenY - Pointer Y relative to canvas element.
|
|
530
|
+
* @param bounds - The element's bounding rect in canvas-space.
|
|
531
|
+
* @param viewport - Current viewport transform.
|
|
532
|
+
* @returns The anchor being hovered, or `null`.
|
|
533
|
+
*/
|
|
534
|
+
hitTestHandle(screenX, screenY, bounds, viewport) {
|
|
535
|
+
const anchors = computeScreenAnchors(bounds, viewport);
|
|
536
|
+
const r = this.style.handleHitRadius;
|
|
537
|
+
for (const dir of ANCHOR_ORDER) {
|
|
538
|
+
const a = anchors[dir];
|
|
539
|
+
const dx = screenX - a.x;
|
|
540
|
+
const dy = screenY - a.y;
|
|
541
|
+
if (dx * dx + dy * dy <= r * r) {
|
|
542
|
+
return dir;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
// ── Private Drawing Routines ────────────────────
|
|
548
|
+
/** Draws the 8 resize handles around a projected rect. */
|
|
549
|
+
drawHandles(sx, sy, sw, sh, activeAnchor) {
|
|
550
|
+
const { ctx, style } = this;
|
|
551
|
+
const size = style.handleSize;
|
|
552
|
+
const half = size / 2;
|
|
553
|
+
const midX = sx + sw / 2;
|
|
554
|
+
const midY = sy + sh / 2;
|
|
555
|
+
const right = sx + sw;
|
|
556
|
+
const bottom = sy + sh;
|
|
557
|
+
const positions = {
|
|
558
|
+
nw: [sx, sy],
|
|
559
|
+
n: [midX, sy],
|
|
560
|
+
ne: [right, sy],
|
|
561
|
+
e: [right, midY],
|
|
562
|
+
se: [right, bottom],
|
|
563
|
+
s: [midX, bottom],
|
|
564
|
+
sw: [sx, bottom],
|
|
565
|
+
w: [sx, midY],
|
|
566
|
+
};
|
|
567
|
+
for (const dir of ANCHOR_ORDER) {
|
|
568
|
+
const [hx, hy] = positions[dir];
|
|
569
|
+
const isActive = dir === activeAnchor;
|
|
570
|
+
ctx.fillStyle = isActive ? style.handleActiveFill : style.handleFill;
|
|
571
|
+
ctx.strokeStyle = style.handleStroke;
|
|
572
|
+
ctx.lineWidth = style.handleStrokeWidth;
|
|
573
|
+
ctx.beginPath();
|
|
574
|
+
ctx.roundRect(hx - half, hy - half, size, size, style.handleRadius);
|
|
575
|
+
ctx.fill();
|
|
576
|
+
ctx.stroke();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
/** Draws the origin crosshair spanning the full viewport. */
|
|
580
|
+
drawOrigin(viewport) {
|
|
581
|
+
const { ctx, style, width, height } = this;
|
|
582
|
+
ctx.strokeStyle = style.originStroke;
|
|
583
|
+
ctx.lineWidth = 1;
|
|
584
|
+
ctx.setLineDash(style.originDash);
|
|
585
|
+
// Vertical line at x=0.
|
|
586
|
+
const ox = viewport.offsetX;
|
|
587
|
+
ctx.beginPath();
|
|
588
|
+
ctx.moveTo(ox, 0);
|
|
589
|
+
ctx.lineTo(ox, height);
|
|
590
|
+
ctx.stroke();
|
|
591
|
+
// Horizontal line at y=0.
|
|
592
|
+
const oy = viewport.offsetY;
|
|
593
|
+
ctx.beginPath();
|
|
594
|
+
ctx.moveTo(0, oy);
|
|
595
|
+
ctx.lineTo(width, oy);
|
|
596
|
+
ctx.stroke();
|
|
597
|
+
ctx.setLineDash([]);
|
|
598
|
+
}
|
|
599
|
+
/** Draws a single alignment guide line. */
|
|
600
|
+
drawGuide(guide, viewport) {
|
|
601
|
+
const { ctx, style, width, height } = this;
|
|
602
|
+
ctx.strokeStyle = style.guideStroke;
|
|
603
|
+
ctx.lineWidth = style.guideWidth;
|
|
604
|
+
ctx.setLineDash(style.guideDash);
|
|
605
|
+
ctx.beginPath();
|
|
606
|
+
if (guide.axis === "x") {
|
|
607
|
+
// Vertical guide at canvas-space x → screen-space.
|
|
608
|
+
const sx = guide.position * viewport.scale + viewport.offsetX;
|
|
609
|
+
ctx.moveTo(sx, 0);
|
|
610
|
+
ctx.lineTo(sx, height);
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
// Horizontal guide at canvas-space y → screen-space.
|
|
614
|
+
const sy = guide.position * viewport.scale + viewport.offsetY;
|
|
615
|
+
ctx.moveTo(0, sy);
|
|
616
|
+
ctx.lineTo(width, sy);
|
|
617
|
+
}
|
|
618
|
+
ctx.stroke();
|
|
619
|
+
ctx.setLineDash([]);
|
|
620
|
+
}
|
|
621
|
+
/** Draws the aggregate bounding box around all selected nodes. */
|
|
622
|
+
drawMultiSelectBounds(selectedIds, projected) {
|
|
623
|
+
let minX = Infinity;
|
|
624
|
+
let minY = Infinity;
|
|
625
|
+
let maxX = -Infinity;
|
|
626
|
+
let maxY = -Infinity;
|
|
627
|
+
for (const id of selectedIds) {
|
|
628
|
+
const p = projected.get(id);
|
|
629
|
+
if (!p)
|
|
630
|
+
continue;
|
|
631
|
+
minX = Math.min(minX, p.sx);
|
|
632
|
+
minY = Math.min(minY, p.sy);
|
|
633
|
+
maxX = Math.max(maxX, p.sx + p.sw);
|
|
634
|
+
maxY = Math.max(maxY, p.sy + p.sh);
|
|
635
|
+
}
|
|
636
|
+
if (!isFinite(minX))
|
|
637
|
+
return;
|
|
638
|
+
const { ctx, style } = this;
|
|
639
|
+
const pad = 6; // Screen-pixel padding around the aggregate box.
|
|
640
|
+
ctx.strokeStyle = style.multiSelectStroke;
|
|
641
|
+
ctx.lineWidth = 1;
|
|
642
|
+
ctx.setLineDash(style.multiSelectDash);
|
|
643
|
+
ctx.strokeRect(minX - pad, minY - pad, maxX - minX + pad * 2, maxY - minY + pad * 2);
|
|
644
|
+
ctx.setLineDash([]);
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Draws a layout mode badge pill (e.g. "FLEX →", "GRID")
|
|
648
|
+
* anchored to the top-left corner of a container's projected rect.
|
|
649
|
+
*/
|
|
650
|
+
drawLayoutBadge(canvasRect, label, viewport, xOffset, isJS) {
|
|
651
|
+
const { ctx, style } = this;
|
|
652
|
+
const { scale, offsetX, offsetY } = viewport;
|
|
653
|
+
// Project to screen-space.
|
|
654
|
+
const sx = canvasRect.x * scale + offsetX;
|
|
655
|
+
const sy = canvasRect.y * scale + offsetY;
|
|
656
|
+
// Measure text.
|
|
657
|
+
ctx.font = style.layoutBadgeFont;
|
|
658
|
+
const tm = ctx.measureText(label);
|
|
659
|
+
const textW = tm.width;
|
|
660
|
+
const padH = 6;
|
|
661
|
+
const badgeW = textW + padH * 2;
|
|
662
|
+
const badgeH = 14;
|
|
663
|
+
const badgeX = sx - 1 + xOffset;
|
|
664
|
+
const badgeY = sy - badgeH - 4; // Above the selection outline.
|
|
665
|
+
// Draw badge background.
|
|
666
|
+
if (isJS) {
|
|
667
|
+
ctx.fillStyle = "#d97706"; // Premium Amber for JS/Script badge
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
ctx.fillStyle = style.layoutBadgeBg;
|
|
671
|
+
}
|
|
672
|
+
ctx.beginPath();
|
|
673
|
+
ctx.roundRect(badgeX, badgeY, badgeW, badgeH, 3);
|
|
674
|
+
ctx.fill();
|
|
675
|
+
// Draw text.
|
|
676
|
+
if (isJS) {
|
|
677
|
+
ctx.fillStyle = "#ffffff";
|
|
678
|
+
}
|
|
679
|
+
else {
|
|
680
|
+
ctx.fillStyle = style.layoutBadgeText;
|
|
681
|
+
}
|
|
682
|
+
ctx.textBaseline = "middle";
|
|
683
|
+
ctx.textAlign = "left";
|
|
684
|
+
ctx.fillText(label, badgeX + padH, badgeY + badgeH / 2 + 0.5);
|
|
685
|
+
return badgeW;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Draws dotted grid track lines overlaying a grid container.
|
|
689
|
+
*/
|
|
690
|
+
drawGridOverlay(canvasRect, columns, rows, viewport) {
|
|
691
|
+
const { ctx, style } = this;
|
|
692
|
+
const { scale, offsetX, offsetY } = viewport;
|
|
693
|
+
// Container screen-space origin.
|
|
694
|
+
const sx = canvasRect.x * scale + offsetX;
|
|
695
|
+
const sy = canvasRect.y * scale + offsetY;
|
|
696
|
+
const sw = canvasRect.width * scale;
|
|
697
|
+
const sh = canvasRect.height * scale;
|
|
698
|
+
ctx.strokeStyle = style.gridTrackStroke;
|
|
699
|
+
ctx.lineWidth = 1;
|
|
700
|
+
ctx.setLineDash(style.gridTrackDash);
|
|
701
|
+
// Column track boundaries (vertical lines).
|
|
702
|
+
for (const col of columns) {
|
|
703
|
+
const x = sx + (col.start + col.size) * scale;
|
|
704
|
+
if (x > sx && x < sx + sw) {
|
|
705
|
+
ctx.beginPath();
|
|
706
|
+
ctx.moveTo(x, sy);
|
|
707
|
+
ctx.lineTo(x, sy + sh);
|
|
708
|
+
ctx.stroke();
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
// Row track boundaries (horizontal lines).
|
|
712
|
+
for (const row of rows) {
|
|
713
|
+
const y = sy + (row.start + row.size) * scale;
|
|
714
|
+
if (y > sy && y < sy + sh) {
|
|
715
|
+
ctx.beginPath();
|
|
716
|
+
ctx.moveTo(sx, y);
|
|
717
|
+
ctx.lineTo(sx + sw, y);
|
|
718
|
+
ctx.stroke();
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
ctx.setLineDash([]);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// ── Alignment Guide Computation ─────────────────────────────
|
|
725
|
+
/**
|
|
726
|
+
* Threshold (in canvas-space pixels) for snapping alignment.
|
|
727
|
+
* When two edges or centers are within this distance, a guide
|
|
728
|
+
* is generated.
|
|
729
|
+
*/
|
|
730
|
+
const DEFAULT_SNAP_THRESHOLD = 5;
|
|
731
|
+
/**
|
|
732
|
+
* Computes alignment guide lines between a moving element and
|
|
733
|
+
* all other elements. Detects edge-to-edge and center-to-center
|
|
734
|
+
* alignment on both axes.
|
|
735
|
+
*
|
|
736
|
+
* ### Checked alignments (per axis)
|
|
737
|
+
* - **Left/Top edge** of the moving element ↔ left/top, center,
|
|
738
|
+
* right/bottom of each other element.
|
|
739
|
+
* - **Center** of the moving element ↔ left/top, center,
|
|
740
|
+
* right/bottom of each other element.
|
|
741
|
+
* - **Right/Bottom edge** of the moving element ↔ left/top,
|
|
742
|
+
* center, right/bottom of each other element.
|
|
743
|
+
*
|
|
744
|
+
* @param movingRect - The bounding rect of the element being
|
|
745
|
+
* dragged or resized (canvas-space).
|
|
746
|
+
* @param otherRects - Array of bounding rects of all other
|
|
747
|
+
* elements to snap against (canvas-space).
|
|
748
|
+
* @param threshold - Snap distance in canvas-space pixels.
|
|
749
|
+
* @returns Array of `Guide` objects to render.
|
|
750
|
+
*/
|
|
751
|
+
export function computeAlignmentGuides(movingRect, otherRects, threshold = DEFAULT_SNAP_THRESHOLD) {
|
|
752
|
+
const guides = [];
|
|
753
|
+
const seen = new Set(); // Deduplicate overlapping guides.
|
|
754
|
+
// Moving element's reference points.
|
|
755
|
+
const mLeft = movingRect.x;
|
|
756
|
+
const mRight = movingRect.x + movingRect.width;
|
|
757
|
+
const mCenterX = movingRect.x + movingRect.width / 2;
|
|
758
|
+
const mTop = movingRect.y;
|
|
759
|
+
const mBottom = movingRect.y + movingRect.height;
|
|
760
|
+
const mCenterY = movingRect.y + movingRect.height / 2;
|
|
761
|
+
const mPointsX = [mLeft, mCenterX, mRight];
|
|
762
|
+
const mPointsY = [mTop, mCenterY, mBottom];
|
|
763
|
+
for (const other of otherRects) {
|
|
764
|
+
const oLeft = other.x;
|
|
765
|
+
const oRight = other.x + other.width;
|
|
766
|
+
const oCenterX = other.x + other.width / 2;
|
|
767
|
+
const oTop = other.y;
|
|
768
|
+
const oBottom = other.y + other.height;
|
|
769
|
+
const oCenterY = other.y + other.height / 2;
|
|
770
|
+
const oPointsX = [oLeft, oCenterX, oRight];
|
|
771
|
+
const oPointsY = [oTop, oCenterY, oBottom];
|
|
772
|
+
// X-axis (vertical guides): compare left/center/right.
|
|
773
|
+
for (const mp of mPointsX) {
|
|
774
|
+
for (const op of oPointsX) {
|
|
775
|
+
if (Math.abs(mp - op) <= threshold) {
|
|
776
|
+
const key = `x:${op.toFixed(1)}`;
|
|
777
|
+
if (!seen.has(key)) {
|
|
778
|
+
seen.add(key);
|
|
779
|
+
guides.push({ axis: "x", position: op });
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
// Y-axis (horizontal guides): compare top/center/bottom.
|
|
785
|
+
for (const mp of mPointsY) {
|
|
786
|
+
for (const op of oPointsY) {
|
|
787
|
+
if (Math.abs(mp - op) <= threshold) {
|
|
788
|
+
const key = `y:${op.toFixed(1)}`;
|
|
789
|
+
if (!seen.has(key)) {
|
|
790
|
+
seen.add(key);
|
|
791
|
+
guides.push({ axis: "y", position: op });
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return guides;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Computes snap-corrected position for a moving rect.
|
|
801
|
+
*
|
|
802
|
+
* If any edge or center of the moving rect is within `threshold`
|
|
803
|
+
* of an alignment target, the returned position is adjusted to
|
|
804
|
+
* snap exactly onto that target.
|
|
805
|
+
*
|
|
806
|
+
* @param movingRect - The element's current canvas-space rect.
|
|
807
|
+
* @param otherRects - All other element rects to snap against.
|
|
808
|
+
* @param threshold - Snap distance in canvas-space pixels.
|
|
809
|
+
* @returns A new `{ x, y }` position with snapping applied.
|
|
810
|
+
*/
|
|
811
|
+
export function computeSnappedPosition(movingRect, otherRects, threshold = DEFAULT_SNAP_THRESHOLD) {
|
|
812
|
+
let bestDx = Infinity;
|
|
813
|
+
let bestDy = Infinity;
|
|
814
|
+
let snapX = movingRect.x;
|
|
815
|
+
let snapY = movingRect.y;
|
|
816
|
+
const mLeft = movingRect.x;
|
|
817
|
+
const mRight = movingRect.x + movingRect.width;
|
|
818
|
+
const mCenterX = movingRect.x + movingRect.width / 2;
|
|
819
|
+
const mTop = movingRect.y;
|
|
820
|
+
const mBottom = movingRect.y + movingRect.height;
|
|
821
|
+
const mCenterY = movingRect.y + movingRect.height / 2;
|
|
822
|
+
for (const other of otherRects) {
|
|
823
|
+
const oLeft = other.x;
|
|
824
|
+
const oRight = other.x + other.width;
|
|
825
|
+
const oCenterX = other.x + other.width / 2;
|
|
826
|
+
const oTop = other.y;
|
|
827
|
+
const oBottom = other.y + other.height;
|
|
828
|
+
const oCenterY = other.y + other.height / 2;
|
|
829
|
+
// X snap candidates: [movingRefPoint, otherRefPoint]
|
|
830
|
+
const xPairs = [
|
|
831
|
+
[mLeft, oLeft], [mLeft, oCenterX], [mLeft, oRight],
|
|
832
|
+
[mCenterX, oLeft], [mCenterX, oCenterX], [mCenterX, oRight],
|
|
833
|
+
[mRight, oLeft], [mRight, oCenterX], [mRight, oRight],
|
|
834
|
+
];
|
|
835
|
+
for (const [mp, op] of xPairs) {
|
|
836
|
+
const d = Math.abs(mp - op);
|
|
837
|
+
if (d <= threshold && d < bestDx) {
|
|
838
|
+
bestDx = d;
|
|
839
|
+
// Offset is the difference between the moving point and the target,
|
|
840
|
+
// applied back to the origin.
|
|
841
|
+
snapX = movingRect.x + (op - mp);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
// Y snap candidates.
|
|
845
|
+
const yPairs = [
|
|
846
|
+
[mTop, oTop], [mTop, oCenterY], [mTop, oBottom],
|
|
847
|
+
[mCenterY, oTop], [mCenterY, oCenterY], [mCenterY, oBottom],
|
|
848
|
+
[mBottom, oTop], [mBottom, oCenterY], [mBottom, oBottom],
|
|
849
|
+
];
|
|
850
|
+
for (const [mp, op] of yPairs) {
|
|
851
|
+
const d = Math.abs(mp - op);
|
|
852
|
+
if (d <= threshold && d < bestDy) {
|
|
853
|
+
bestDy = d;
|
|
854
|
+
snapY = movingRect.y + (op - mp);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return { x: snapX, y: snapY };
|
|
859
|
+
}
|
|
860
|
+
// ── Handle Anchor Projection ────────────────────────────────
|
|
861
|
+
/**
|
|
862
|
+
* Computes the screen-space center positions of all 8 resize
|
|
863
|
+
* handles for a given canvas-space bounding rect.
|
|
864
|
+
*
|
|
865
|
+
* Unlike `getAnchorPositions` in `matrix.ts`, this function
|
|
866
|
+
* uses the offset-only transform (no `canvasRect` subtraction)
|
|
867
|
+
* because the overlay renderer works in canvas-element-relative
|
|
868
|
+
* coordinates, not page-absolute `clientX/Y`.
|
|
869
|
+
*/
|
|
870
|
+
function computeScreenAnchors(bounds, viewport) {
|
|
871
|
+
const { x, y, width, height } = bounds;
|
|
872
|
+
const s = viewport.scale;
|
|
873
|
+
const ox = viewport.offsetX;
|
|
874
|
+
const oy = viewport.offsetY;
|
|
875
|
+
const left = x * s + ox;
|
|
876
|
+
const top = y * s + oy;
|
|
877
|
+
const right = (x + width) * s + ox;
|
|
878
|
+
const bottom = (y + height) * s + oy;
|
|
879
|
+
const midX = (x + width / 2) * s + ox;
|
|
880
|
+
const midY = (y + height / 2) * s + oy;
|
|
881
|
+
return {
|
|
882
|
+
nw: { x: left, y: top },
|
|
883
|
+
n: { x: midX, y: top },
|
|
884
|
+
ne: { x: right, y: top },
|
|
885
|
+
e: { x: right, y: midY },
|
|
886
|
+
se: { x: right, y: bottom },
|
|
887
|
+
s: { x: midX, y: bottom },
|
|
888
|
+
sw: { x: left, y: bottom },
|
|
889
|
+
w: { x: left, y: midY },
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
//# sourceMappingURL=renderer.js.map
|