@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.
@@ -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