living-ai-documentation 2.3.0 → 2.5.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,841 @@
1
+ // ── Types ─────────────────────────────────────────────────────────────────────
2
+
3
+ interface Folder {
4
+ name: string;
5
+ path: string;
6
+ hasChildren: boolean;
7
+ }
8
+
9
+ interface BlueprintResponse {
10
+ sourceRoot: string;
11
+ path: string;
12
+ name: string;
13
+ folders: Folder[];
14
+ }
15
+
16
+ interface Box {
17
+ folder: Folder;
18
+ x: number;
19
+ y: number;
20
+ w: number;
21
+ h: number;
22
+ }
23
+
24
+
25
+ interface BreadcrumbEntry {
26
+ name: string;
27
+ path: string;
28
+ }
29
+
30
+ // ── Constants ─────────────────────────────────────────────────────────────────
31
+
32
+ const BOX_W = 200;
33
+ const BOX_H = 110;
34
+ const GAP_X = 40;
35
+ const GAP_Y = 40;
36
+ const COLS = 4;
37
+ const MENU_ICON_SIZE = 20;
38
+ const CORNER_R = 18;
39
+ const ANIM_MS = 420;
40
+ const VIEW_PADDING = 80;
41
+
42
+ const COLOR_BOX_FILL = '#1e293b';
43
+ const COLOR_BOX_FILL_CHILD = '#172033';
44
+ const COLOR_BOX_STROKE = 'rgba(255,255,255,0.12)';
45
+ const COLOR_BOX_STROKE_HOVER = '#3b82f6';
46
+ const COLOR_NAME = '#f1f5f9';
47
+ const COLOR_MUTED = '#64748b';
48
+ const COLOR_ARROW = '#3b82f6';
49
+
50
+ // ── DOM refs ──────────────────────────────────────────────────────────────────
51
+
52
+ const canvas = document.getElementById('blueprintCanvas') as HTMLCanvasElement;
53
+ const ctx = canvas.getContext('2d')!;
54
+ const fitButton = document.getElementById('fitButton') as HTMLButtonElement;
55
+ const dragToggle = document.getElementById('dragToggle') as HTMLButtonElement;
56
+ const fileExplorer = document.getElementById('fileExplorer') as HTMLElement;
57
+ const explorerBreadcrumb = document.getElementById('explorerBreadcrumb') as HTMLElement;
58
+ const explorerList = document.getElementById('explorerList') as HTMLElement;
59
+ const explorerClose = document.getElementById('explorerClose') as HTMLButtonElement;
60
+ const filePreview = document.getElementById('filePreview') as HTMLElement;
61
+ let explorerCurrentPath = '';
62
+ const breadcrumbEl = document.getElementById('breadcrumb') as HTMLElement;
63
+ const emptyState = document.getElementById('emptyState') as HTMLElement;
64
+
65
+ // ── State ─────────────────────────────────────────────────────────────────────
66
+
67
+ let cameraX = 0;
68
+ let cameraY = 0;
69
+ let zoom = 1;
70
+ let boxes: Box[] = [];
71
+ let hoverIndex = -1;
72
+ let dragMode = false;
73
+ let draggingIndex = -1;
74
+ const selectedIndices = new Set<number>();
75
+ let dragStartPositions: Map<number, { x: number; y: number }> = new Map();
76
+ let dragStartWorld = { x: 0, y: 0 };
77
+ // Rubber band selection
78
+ let isSelecting = false;
79
+ let selectStartWorld = { x: 0, y: 0 };
80
+ let selectCurrentWorld = { x: 0, y: 0 };
81
+ let activeExplorerPath: string | null = null;
82
+ let currentCanvasPath = '';
83
+ let dragOffsetX = 0;
84
+ let dragOffsetY = 0;
85
+ let isDragging = false;
86
+ let isPanning = false;
87
+ let didPan = false;
88
+ let panStartX = 0;
89
+ let panStartY = 0;
90
+ let panStartCX = 0;
91
+ let panStartCY = 0;
92
+ let breadcrumb: BreadcrumbEntry[] = [];
93
+ let animFrame: number | null = null;
94
+ type PositionEntry = { x: number; y: number; expanded?: boolean };
95
+ let positions: Record<string, PositionEntry> = {};
96
+ let saveTimer: number | null = null;
97
+
98
+ // Animation state
99
+ let animStartX = 0;
100
+ let animStartY = 0;
101
+ let animStartZ = 0;
102
+ let animTargetX = 0;
103
+ let animTargetY = 0;
104
+ let animTargetZ = 0;
105
+ let animStartTime = 0;
106
+ let isAnimating = false;
107
+
108
+ // ── Layout ────────────────────────────────────────────────────────────────────
109
+
110
+ function layoutFolders(folders: Folder[]): Box[] {
111
+ let gridCol = 0;
112
+ let gridRow = 0;
113
+ return folders.map((folder) => {
114
+ // Use saved position if available, otherwise next grid slot
115
+ const saved = positions[folder.path];
116
+ const x = saved ? saved.x : gridCol * (BOX_W + GAP_X);
117
+ const y = saved ? saved.y : gridRow * (BOX_H + GAP_Y);
118
+ if (!saved) {
119
+ gridCol++;
120
+ if (gridCol >= COLS) { gridCol = 0; gridRow++; }
121
+ }
122
+ return { folder, x, y, w: BOX_W, h: BOX_H };
123
+ });
124
+ }
125
+
126
+ function boundsOfBoxes(b: Box[]): { minX: number; minY: number; maxX: number; maxY: number } {
127
+ if (!b.length) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
128
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
129
+ for (const box of b) {
130
+ minX = Math.min(minX, box.x);
131
+ minY = Math.min(minY, box.y);
132
+ maxX = Math.max(maxX, box.x + box.w);
133
+ maxY = Math.max(maxY, box.y + box.h);
134
+ }
135
+ return { minX, minY, maxX, maxY };
136
+ }
137
+
138
+ // ── Camera helpers ────────────────────────────────────────────────────────────
139
+
140
+ function getParentPath(p: string): string {
141
+ const i = p.lastIndexOf('/');
142
+ return i >= 0 ? p.slice(0, i) : '';
143
+ }
144
+
145
+ function centerOnBox(box: Box) {
146
+ const W = canvas.clientWidth;
147
+ const H = canvas.clientHeight;
148
+ // Center the box in the left half (file explorer takes the right half)
149
+ const availW = fileExplorer.hidden ? W : W / 2;
150
+ const targetX = availW / 2 - (box.x + box.w / 2) * zoom;
151
+ const targetY = H / 2 - (box.y + box.h / 2) * zoom;
152
+ startAnim(targetX, targetY, zoom);
153
+ }
154
+
155
+ function fitToBoxes(animate: boolean) {
156
+ if (!boxes.length) return;
157
+ const { minX, minY, maxX, maxY } = boundsOfBoxes(boxes);
158
+ const W = canvas.clientWidth;
159
+ const H = canvas.clientHeight;
160
+ const fitZoom = Math.min(
161
+ (W - VIEW_PADDING * 2) / Math.max(1, maxX - minX),
162
+ (H - VIEW_PADDING * 2) / Math.max(1, maxY - minY),
163
+ 1.4,
164
+ );
165
+ const cx = (minX + maxX) / 2;
166
+ const cy = (minY + maxY) / 2;
167
+ const targetX = W / 2 - cx * fitZoom;
168
+ const targetY = H / 2 - cy * fitZoom;
169
+ if (animate) {
170
+ startAnim(targetX, targetY, fitZoom);
171
+ } else {
172
+ cameraX = targetX;
173
+ cameraY = targetY;
174
+ zoom = fitZoom;
175
+ }
176
+ }
177
+
178
+ function zoomIntoBox(box: Box) {
179
+ const W = canvas.clientWidth;
180
+ const H = canvas.clientHeight;
181
+ const targetZoom = Math.min(
182
+ (W - VIEW_PADDING * 2) / box.w,
183
+ (H - VIEW_PADDING * 2) / box.h,
184
+ 1.6,
185
+ );
186
+ const cx = box.x + box.w / 2;
187
+ const cy = box.y + box.h / 2;
188
+ const targetX = W / 2 - cx * targetZoom;
189
+ const targetY = H / 2 - cy * targetZoom;
190
+ startAnim(targetX, targetY, targetZoom);
191
+ }
192
+
193
+ // ── Animation ─────────────────────────────────────────────────────────────────
194
+
195
+ function easeInOut(t: number): number {
196
+ return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
197
+ }
198
+
199
+ function startAnim(tx: number, ty: number, tz: number) {
200
+ animStartX = cameraX;
201
+ animStartY = cameraY;
202
+ animStartZ = zoom;
203
+ animTargetX = tx;
204
+ animTargetY = ty;
205
+ animTargetZ = tz;
206
+ animStartTime = performance.now();
207
+ isAnimating = true;
208
+ scheduleRender();
209
+ }
210
+
211
+ function tickAnim(now: number) {
212
+ const t = Math.min(1, (now - animStartTime) / ANIM_MS);
213
+ const e = easeInOut(t);
214
+ cameraX = animStartX + (animTargetX - animStartX) * e;
215
+ cameraY = animStartY + (animTargetY - animStartY) * e;
216
+ zoom = animStartZ + (animTargetZ - animStartZ) * e;
217
+ if (t >= 1) isAnimating = false;
218
+ }
219
+
220
+ // ── Render ────────────────────────────────────────────────────────────────────
221
+
222
+ function scheduleRender() {
223
+ if (animFrame !== null) return;
224
+ animFrame = requestAnimationFrame(render);
225
+ }
226
+
227
+ function render(now: number) {
228
+ animFrame = null;
229
+ if (isAnimating) {
230
+ tickAnim(now);
231
+ scheduleRender();
232
+ }
233
+
234
+ const dpr = window.devicePixelRatio || 1;
235
+ const W = canvas.clientWidth;
236
+ const H = canvas.clientHeight;
237
+ if (canvas.width !== W * dpr || canvas.height !== H * dpr) {
238
+ canvas.width = W * dpr;
239
+ canvas.height = H * dpr;
240
+ }
241
+
242
+ ctx.save();
243
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
244
+ ctx.scale(dpr, dpr);
245
+
246
+ // Background grid
247
+ drawGrid(W, H);
248
+
249
+ ctx.translate(cameraX, cameraY);
250
+ ctx.scale(zoom, zoom);
251
+
252
+ for (let i = 0; i < boxes.length; i++) {
253
+ drawBox(boxes[i], i === hoverIndex, i === draggingIndex, selectedIndices.has(i));
254
+ }
255
+
256
+ // Rubber band selection rect
257
+ if (isSelecting) {
258
+ const rx = Math.min(selectStartWorld.x, selectCurrentWorld.x);
259
+ const ry = Math.min(selectStartWorld.y, selectCurrentWorld.y);
260
+ const rw = Math.abs(selectCurrentWorld.x - selectStartWorld.x);
261
+ const rh = Math.abs(selectCurrentWorld.y - selectStartWorld.y);
262
+ ctx.strokeStyle = '#3b82f6';
263
+ ctx.lineWidth = 1.5 / zoom;
264
+ ctx.fillStyle = 'rgba(59,130,246,0.08)';
265
+ ctx.beginPath();
266
+ ctx.roundRect(rx, ry, rw, rh, 6 / zoom);
267
+ ctx.fill();
268
+ ctx.stroke();
269
+ }
270
+
271
+ ctx.restore();
272
+ }
273
+
274
+ function drawGrid(W: number, H: number) {
275
+ const step = 40 * zoom;
276
+ const ox = ((cameraX % step) + step) % step;
277
+ const oy = ((cameraY % step) + step) % step;
278
+ ctx.strokeStyle = 'rgba(255,255,255,0.04)';
279
+ ctx.lineWidth = 1;
280
+ ctx.beginPath();
281
+ for (let x = ox; x < W; x += step) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }
282
+ for (let y = oy; y < H; y += step) { ctx.moveTo(0, y); ctx.lineTo(W, y); }
283
+ ctx.stroke();
284
+ }
285
+
286
+ function roundRect(x: number, y: number, w: number, h: number, r: number) {
287
+ ctx.beginPath();
288
+ ctx.roundRect(x, y, w, h, r);
289
+ }
290
+
291
+ function drawBox(box: Box, hovered: boolean, dragging = false, selected = false) {
292
+ const { x, y, w, h, folder } = box;
293
+ const fill = folder.hasChildren ? COLOR_BOX_FILL : COLOR_BOX_FILL_CHILD;
294
+ const isExplorerOpen = activeExplorerPath === folder.path;
295
+ const stroke = dragging ? '#f59e0b' : selected ? '#fbbf24' : isExplorerOpen ? '#60a5fa' : hovered ? COLOR_BOX_STROKE_HOVER : COLOR_BOX_STROKE;
296
+ const lw = (hovered || dragging || selected || isExplorerOpen) ? 2 : 1;
297
+
298
+ // Shadow
299
+ ctx.save();
300
+ ctx.shadowColor = dragging ? 'rgba(245,158,11,0.4)' : 'rgba(0,0,0,0.4)';
301
+ ctx.shadowBlur = dragging ? 36 : hovered ? 24 : 12;
302
+ roundRect(x, y, w, h, CORNER_R);
303
+ ctx.fillStyle = fill;
304
+ ctx.fill();
305
+ ctx.restore();
306
+
307
+ // Border
308
+ roundRect(x, y, w, h, CORNER_R);
309
+ ctx.strokeStyle = stroke;
310
+ ctx.lineWidth = lw / zoom;
311
+ ctx.stroke();
312
+
313
+ // ☰ icon top-left
314
+ const menuActive = isExplorerOpen;
315
+ ctx.fillStyle = menuActive ? '#60a5fa' : hovered ? '#94a3b8' : 'rgba(255,255,255,0.3)';
316
+ ctx.font = `${MENU_ICON_SIZE * 0.7}px sans-serif`;
317
+ ctx.textBaseline = 'top';
318
+ ctx.textAlign = 'left';
319
+ ctx.fillText('☰', x + 10, y + 10);
320
+
321
+ // Folder icon + name (centered vertically)
322
+ const iconSize = 18;
323
+ const iconX = x + 18;
324
+ const iconY = y + h / 2 - iconSize / 2 - 4;
325
+ drawFolderIcon(iconX, iconY, iconSize, hovered);
326
+
327
+ const fontSize = Math.max(10, Math.min(14, 120 / folder.name.length));
328
+ ctx.fillStyle = hovered ? '#fff' : COLOR_NAME;
329
+ ctx.font = `700 ${fontSize}px Inter, ui-sans-serif, sans-serif`;
330
+ ctx.textBaseline = 'middle';
331
+ ctx.textAlign = 'left';
332
+ const maxTextW = w - 36 - iconSize - 8;
333
+ const label = truncateText(folder.name, maxTextW, fontSize);
334
+ ctx.fillText(label, iconX + iconSize + 8, y + h / 2);
335
+
336
+ // Arrow (navigate into sub-folders)
337
+ if (folder.hasChildren) {
338
+ drawArrow(x + w - 22, y + h / 2, hovered);
339
+ }
340
+ }
341
+
342
+ function drawFolderIcon(x: number, y: number, size: number, hovered: boolean) {
343
+ const color = hovered ? COLOR_ARROW : COLOR_MUTED;
344
+ ctx.fillStyle = color;
345
+ // Folder body
346
+ ctx.beginPath();
347
+ ctx.roundRect(x, y + size * 0.3, size, size * 0.7, 2);
348
+ ctx.fill();
349
+ // Folder tab
350
+ ctx.beginPath();
351
+ ctx.roundRect(x, y + size * 0.2, size * 0.45, size * 0.18, 2);
352
+ ctx.fill();
353
+ }
354
+
355
+ function drawArrow(x: number, y: number, hovered: boolean) {
356
+ const s = 6;
357
+ ctx.fillStyle = hovered ? COLOR_ARROW : COLOR_MUTED;
358
+ ctx.beginPath();
359
+ ctx.moveTo(x - s, y - s);
360
+ ctx.lineTo(x + s, y);
361
+ ctx.lineTo(x - s, y + s);
362
+ ctx.closePath();
363
+ ctx.fill();
364
+ }
365
+
366
+ function truncateText(text: string, maxW: number, fontSize: number): string {
367
+ ctx.font = `700 ${fontSize}px Inter, ui-sans-serif, sans-serif`;
368
+ if (ctx.measureText(text).width <= maxW) return text;
369
+ let truncated = text;
370
+ while (truncated.length > 1 && ctx.measureText(truncated + '…').width > maxW) {
371
+ truncated = truncated.slice(0, -1);
372
+ }
373
+ return truncated + '…';
374
+ }
375
+
376
+ // ── Persistence ───────────────────────────────────────────────────────────────
377
+
378
+ async function loadPositions(): Promise<void> {
379
+ try {
380
+ positions = await fetch('/api/blueprint/positions').then((r) => r.json()) as Record<string, PositionEntry>;
381
+ } catch {
382
+ positions = {};
383
+ }
384
+ }
385
+
386
+ function scheduleSavePositions(): void {
387
+ if (saveTimer !== null) clearTimeout(saveTimer);
388
+ saveTimer = window.setTimeout(() => {
389
+ saveTimer = null;
390
+ void fetch('/api/blueprint/positions', {
391
+ method: 'PUT',
392
+ headers: { 'Content-Type': 'application/json' },
393
+ body: JSON.stringify(positions),
394
+ });
395
+ }, 600);
396
+ }
397
+
398
+ // ── API ───────────────────────────────────────────────────────────────────────
399
+
400
+ async function loadPath(folderPath: string, animate: boolean, zoomBox?: Box) {
401
+ try {
402
+ const url = `/api/blueprint${folderPath ? `?path=${encodeURIComponent(folderPath)}` : ''}`;
403
+ const data = await fetch(url).then((r) => r.json()) as BlueprintResponse;
404
+
405
+ // Update breadcrumb
406
+ if (!folderPath) {
407
+ breadcrumb = [{ name: data.sourceRoot, path: '' }];
408
+ } else {
409
+ const segments = folderPath.split('/');
410
+ let built = '';
411
+ breadcrumb = [{ name: data.sourceRoot, path: '' }];
412
+ for (const seg of segments) {
413
+ built = built ? `${built}/${seg}` : seg;
414
+ breadcrumb.push({ name: seg, path: built });
415
+ }
416
+ }
417
+ renderBreadcrumb();
418
+
419
+ currentCanvasPath = folderPath;
420
+ boxes = layoutFolders(data.folders);
421
+ emptyState.hidden = boxes.length > 0;
422
+
423
+ if (zoomBox && animate) {
424
+ // Zoom into the clicked box first, then snap to new content
425
+ zoomIntoBox(zoomBox);
426
+ setTimeout(() => {
427
+ fitToBoxes(true);
428
+ }, ANIM_MS * 0.6);
429
+ } else {
430
+ fitToBoxes(animate);
431
+ }
432
+
433
+ scheduleRender();
434
+ } catch (e) {
435
+ console.error('Blueprint load error', e);
436
+ }
437
+ }
438
+
439
+ // ── Breadcrumb ────────────────────────────────────────────────────────────────
440
+
441
+ function renderBreadcrumb() {
442
+ breadcrumbEl.innerHTML = '';
443
+ breadcrumb.forEach((entry, i) => {
444
+ const isLast = i === breadcrumb.length - 1;
445
+ if (i > 0) {
446
+ const sep = document.createElement('span');
447
+ sep.className = 'breadcrumb-sep';
448
+ sep.textContent = '/';
449
+ breadcrumbEl.appendChild(sep);
450
+ }
451
+ const item = document.createElement('span');
452
+ item.className = `breadcrumb-item${isLast ? ' current' : ''}`;
453
+ item.textContent = entry.name;
454
+ if (!isLast) {
455
+ item.addEventListener('click', () => { closeFileExplorer(); loadPath(entry.path, true); });
456
+ }
457
+ breadcrumbEl.appendChild(item);
458
+ });
459
+ }
460
+
461
+ // ── Hit test ──────────────────────────────────────────────────────────────────
462
+
463
+ function worldFromScreen(sx: number, sy: number): { x: number; y: number } {
464
+ return {
465
+ x: (sx - cameraX) / zoom,
466
+ y: (sy - cameraY) / zoom,
467
+ };
468
+ }
469
+
470
+ function boxAt(wx: number, wy: number): number {
471
+ for (let i = 0; i < boxes.length; i++) {
472
+ const b = boxes[i];
473
+ if (wx >= b.x && wx <= b.x + b.w && wy >= b.y && wy <= b.y + b.h) return i;
474
+ }
475
+ return -1;
476
+ }
477
+
478
+ function isOnMenuIcon(box: Box, wx: number, wy: number): boolean {
479
+ return wx >= box.x + 6 && wx <= box.x + 6 + MENU_ICON_SIZE &&
480
+ wy >= box.y + 6 && wy <= box.y + 6 + MENU_ICON_SIZE;
481
+ }
482
+
483
+ async function navigateToFolder(childPath: string): Promise<void> {
484
+ const parentPath = getParentPath(childPath);
485
+
486
+ // 1. Navigate canvas to parent level if needed
487
+ if (currentCanvasPath !== parentPath) {
488
+ await loadPath(parentPath, true);
489
+ }
490
+
491
+ // 2. Find the box for childPath and center on it
492
+ const box = boxes.find((b) => b.folder.path === childPath);
493
+ if (box) {
494
+ centerOnBox(box);
495
+ activeExplorerPath = childPath;
496
+ scheduleRender();
497
+ }
498
+
499
+ // 3. Update the file explorer panel
500
+ await populateExplorer(childPath);
501
+ }
502
+
503
+ async function populateExplorer(folderPath: string): Promise<void> {
504
+ explorerCurrentPath = folderPath;
505
+ renderExplorerBreadcrumb(folderPath);
506
+ explorerList.innerHTML = '<div class="explorer-section-label">Loading…</div>';
507
+ try {
508
+ const data = await fetch(`/api/blueprint/files?path=${encodeURIComponent(folderPath)}`).then((r) => r.json()) as { folders: string[]; files: string[] };
509
+ explorerList.innerHTML = '';
510
+ if (data.folders.length) {
511
+ const lbl = document.createElement('div');
512
+ lbl.className = 'explorer-section-label';
513
+ lbl.textContent = 'Folders';
514
+ explorerList.appendChild(lbl);
515
+ for (const name of data.folders) {
516
+ const childPath = folderPath ? `${folderPath}/${name}` : name;
517
+ const item = document.createElement('div');
518
+ item.className = 'explorer-item is-folder';
519
+ item.innerHTML = `<span class="explorer-item-icon">📁</span><span class="explorer-item-name">${name}</span>`;
520
+ item.addEventListener('click', () => void navigateToFolder(childPath));
521
+ explorerList.appendChild(item);
522
+ }
523
+ }
524
+ if (data.files.length) {
525
+ const lbl = document.createElement('div');
526
+ lbl.className = 'explorer-section-label';
527
+ lbl.textContent = 'Files';
528
+ explorerList.appendChild(lbl);
529
+ for (const name of data.files) {
530
+ const filePath = folderPath ? `${folderPath}/${name}` : name;
531
+ const item = document.createElement('div');
532
+ item.className = 'explorer-item is-file';
533
+ item.innerHTML = `<span class="explorer-item-icon">📄</span><span class="explorer-item-name">${name}</span>`;
534
+ item.addEventListener('click', () => void showFilePreview(filePath, name));
535
+ explorerList.appendChild(item);
536
+ }
537
+ }
538
+ if (!data.folders.length && !data.files.length) {
539
+ explorerList.innerHTML = '<div class="explorer-section-label">Empty folder</div>';
540
+ }
541
+ } catch {
542
+ explorerList.innerHTML = '<div class="explorer-section-label">Error loading files</div>';
543
+ }
544
+ }
545
+
546
+ function renderExplorerBreadcrumb(folderPath: string): void {
547
+ explorerBreadcrumb.innerHTML = '';
548
+ const rootSpan = document.createElement('span');
549
+ rootSpan.className = `explorer-breadcrumb-item${!folderPath ? ' current' : ''}`;
550
+ rootSpan.textContent = '~';
551
+ if (folderPath) rootSpan.addEventListener('click', () => void navigateToFolder(''));
552
+ explorerBreadcrumb.appendChild(rootSpan);
553
+
554
+ if (folderPath) {
555
+ const parts = folderPath.split('/');
556
+ parts.forEach((part, i) => {
557
+ const sep = document.createElement('span');
558
+ sep.className = 'explorer-breadcrumb-sep';
559
+ sep.textContent = '/';
560
+ explorerBreadcrumb.appendChild(sep);
561
+ const isLast = i === parts.length - 1;
562
+ const partPath = parts.slice(0, i + 1).join('/');
563
+ const span = document.createElement('span');
564
+ span.className = `explorer-breadcrumb-item${isLast ? ' current' : ''}`;
565
+ span.textContent = part;
566
+ if (!isLast) span.addEventListener('click', () => void navigateToFolder(partPath));
567
+ explorerBreadcrumb.appendChild(span);
568
+ });
569
+ }
570
+ }
571
+
572
+ async function showFilePreview(filePath: string, fileName: string): Promise<void> {
573
+ filePreview.innerHTML = `<p class="file-preview-name">${fileName}</p><p class="file-preview-placeholder">Loading…</p>`;
574
+ try {
575
+ const data = await fetch(`/api/blueprint/file-content?path=${encodeURIComponent(filePath)}`).then((r) => r.json()) as {
576
+ type: 'text' | 'image' | 'video' | 'binary';
577
+ content?: string;
578
+ language?: string;
579
+ truncated?: boolean;
580
+ url?: string;
581
+ ext?: string;
582
+ };
583
+
584
+ filePreview.innerHTML = `<p class="file-preview-name">${fileName}</p>`;
585
+
586
+ if (data.type === 'image') {
587
+ const img = document.createElement('img');
588
+ img.src = data.url!;
589
+ img.alt = fileName;
590
+ filePreview.appendChild(img);
591
+ } else if (data.type === 'video') {
592
+ const video = document.createElement('video');
593
+ video.src = data.url!;
594
+ video.controls = true;
595
+ filePreview.appendChild(video);
596
+ } else if (data.type === 'binary') {
597
+ const badge = document.createElement('span');
598
+ badge.className = 'file-preview-binary';
599
+ badge.textContent = '⊘ Binary file';
600
+ filePreview.appendChild(badge);
601
+ } else if (data.type === 'text' && data.content !== undefined) {
602
+ if (data.truncated) {
603
+ const badge = document.createElement('span');
604
+ badge.className = 'file-preview-truncated';
605
+ badge.textContent = '⚠ Truncated at 1 MB';
606
+ filePreview.appendChild(badge);
607
+ }
608
+ const pre = document.createElement('pre');
609
+ const code = document.createElement('code');
610
+ if (data.language && data.language !== 'plaintext') code.className = `language-${data.language}`;
611
+ code.textContent = data.content;
612
+ pre.appendChild(code);
613
+ filePreview.appendChild(pre);
614
+ // @ts-ignore
615
+ if (window.hljs) {
616
+ // @ts-ignore
617
+ window.hljs.highlightElement(code);
618
+ // @ts-ignore
619
+ if (window.hljs.lineNumbersBlock) window.hljs.lineNumbersBlock(code);
620
+ }
621
+ }
622
+ } catch {
623
+ filePreview.innerHTML = `<p class="file-preview-name">${fileName}</p><p class="file-preview-placeholder">Error loading file</p>`;
624
+ }
625
+ }
626
+
627
+ async function openFileExplorer(box: Box): Promise<void> {
628
+ activeExplorerPath = box.folder.path;
629
+ fileExplorer.hidden = false;
630
+ filePreview.innerHTML = '<p class="file-preview-placeholder">Click a file to preview it</p>';
631
+ scheduleRender();
632
+ await populateExplorer(box.folder.path);
633
+ }
634
+
635
+ function closeFileExplorer(): void {
636
+ activeExplorerPath = null;
637
+ fileExplorer.hidden = true;
638
+ scheduleRender();
639
+ }
640
+
641
+ // ── Events ────────────────────────────────────────────────────────────────────
642
+
643
+ const DRAG_THRESHOLD = 6;
644
+
645
+ canvas.addEventListener('pointermove', (e) => {
646
+ const rect = canvas.getBoundingClientRect();
647
+ const sx = e.clientX - rect.left;
648
+ const sy = e.clientY - rect.top;
649
+
650
+ // Dragging box(es) (only in drag mode)
651
+ if (isDragging && draggingIndex >= 0 && dragMode) {
652
+ const { x: wx, y: wy } = worldFromScreen(sx, sy);
653
+ const dx = wx - dragStartWorld.x;
654
+ const dy = wy - dragStartWorld.y;
655
+ if (Math.hypot(dx, dy) > DRAG_THRESHOLD / zoom) didPan = true;
656
+ // Move all selected boxes (or just the dragged one if not in selection)
657
+ const toMove = selectedIndices.has(draggingIndex) && selectedIndices.size > 1
658
+ ? [...selectedIndices]
659
+ : [draggingIndex];
660
+ for (const idx of toMove) {
661
+ const orig = dragStartPositions.get(idx);
662
+ if (orig) { boxes[idx].x = orig.x + dx; boxes[idx].y = orig.y + dy; }
663
+ }
664
+ scheduleRender();
665
+ return;
666
+ }
667
+
668
+ // Rubber band selection
669
+ if (isSelecting) {
670
+ selectCurrentWorld = worldFromScreen(sx, sy);
671
+ scheduleRender();
672
+ return;
673
+ }
674
+
675
+ // Panning the camera
676
+ if (isPanning) {
677
+ const dx = sx - panStartX;
678
+ const dy = sy - panStartY;
679
+ if (Math.hypot(dx, dy) > DRAG_THRESHOLD) didPan = true;
680
+ cameraX = panStartCX + dx;
681
+ cameraY = panStartCY + dy;
682
+ scheduleRender();
683
+ return;
684
+ }
685
+
686
+ const { x, y } = worldFromScreen(sx, sy);
687
+ const idx = boxAt(x, y);
688
+ if (idx !== hoverIndex) {
689
+ hoverIndex = idx;
690
+ canvas.style.cursor = idx >= 0 && dragMode ? 'grab' : idx >= 0 ? 'pointer' : 'default';
691
+ scheduleRender();
692
+ }
693
+ });
694
+
695
+ canvas.addEventListener('pointerdown', (e) => {
696
+ const rect = canvas.getBoundingClientRect();
697
+ const sx = e.clientX - rect.left;
698
+ const sy = e.clientY - rect.top;
699
+ const { x: wx, y: wy } = worldFromScreen(sx, sy);
700
+ const idx = boxAt(wx, wy);
701
+
702
+ panStartX = sx;
703
+ panStartY = sy;
704
+ didPan = false;
705
+ canvas.setPointerCapture(e.pointerId);
706
+
707
+ if (idx >= 0) {
708
+ draggingIndex = idx;
709
+ isDragging = true;
710
+ // Store start world pos and all box start positions for multi-drag
711
+ dragStartWorld = { x: wx, y: wy };
712
+ dragStartPositions = new Map(boxes.map((b, i) => [i, { x: b.x, y: b.y }]));
713
+ dragOffsetX = wx - boxes[idx].x;
714
+ dragOffsetY = wy - boxes[idx].y;
715
+ if (dragMode) canvas.style.cursor = 'grabbing';
716
+ } else if (dragMode && e.shiftKey) {
717
+ // Rubber band selection on empty canvas
718
+ isSelecting = true;
719
+ selectStartWorld = { x: wx, y: wy };
720
+ selectCurrentWorld = { x: wx, y: wy };
721
+ canvas.style.cursor = 'crosshair';
722
+ } else {
723
+ // Pan the camera
724
+ isPanning = true;
725
+ panStartCX = cameraX;
726
+ panStartCY = cameraY;
727
+ }
728
+ });
729
+
730
+ canvas.addEventListener('pointerup', (e) => {
731
+ const rect = canvas.getBoundingClientRect();
732
+ const sx = e.clientX - rect.left;
733
+ const sy = e.clientY - rect.top;
734
+ canvas.releasePointerCapture(e.pointerId);
735
+
736
+ if (isDragging) {
737
+ isDragging = false;
738
+ canvas.style.cursor = dragMode ? 'grab' : 'pointer';
739
+ if (didPan) {
740
+ // Save new positions for all moved boxes
741
+ const toMove = selectedIndices.has(draggingIndex) && selectedIndices.size > 1
742
+ ? [...selectedIndices]
743
+ : [draggingIndex];
744
+ for (const idx of toMove) {
745
+ const b = boxes[idx];
746
+ positions[b.folder.path] = { ...positions[b.folder.path] ?? {}, x: b.x, y: b.y };
747
+ }
748
+ scheduleSavePositions();
749
+ } else {
750
+ // Click on box (no drag)
751
+ const box = boxes[draggingIndex];
752
+ const { x: wx, y: wy } = worldFromScreen(sx, sy);
753
+ if (dragMode && e.shiftKey) {
754
+ // Shift+click → toggle selection
755
+ if (selectedIndices.has(draggingIndex)) {
756
+ selectedIndices.delete(draggingIndex);
757
+ } else {
758
+ selectedIndices.add(draggingIndex);
759
+ }
760
+ scheduleRender();
761
+ } else if (dragMode) {
762
+ // Regular click in drag mode → select only this box
763
+ selectedIndices.clear();
764
+ selectedIndices.add(draggingIndex);
765
+ scheduleRender();
766
+ } else if (isOnMenuIcon(box, wx, wy)) {
767
+ if (activeExplorerPath === box.folder.path) closeFileExplorer();
768
+ else void openFileExplorer(box);
769
+ } else if (box.folder.hasChildren) {
770
+ closeFileExplorer();
771
+ loadPath(box.folder.path, true, box);
772
+ }
773
+ }
774
+ draggingIndex = -1;
775
+ return;
776
+ }
777
+
778
+ if (isSelecting) {
779
+ isSelecting = false;
780
+ canvas.style.cursor = 'crosshair';
781
+ // Select boxes intersecting the rubber band rect
782
+ const rx1 = Math.min(selectStartWorld.x, selectCurrentWorld.x);
783
+ const ry1 = Math.min(selectStartWorld.y, selectCurrentWorld.y);
784
+ const rx2 = Math.max(selectStartWorld.x, selectCurrentWorld.x);
785
+ const ry2 = Math.max(selectStartWorld.y, selectCurrentWorld.y);
786
+ if (rx2 - rx1 > 4 || ry2 - ry1 > 4) {
787
+ for (let i = 0; i < boxes.length; i++) {
788
+ const b = boxes[i];
789
+ if (b.x < rx2 && b.x + b.w > rx1 && b.y < ry2 && b.y + b.h > ry1) {
790
+ selectedIndices.add(i);
791
+ }
792
+ }
793
+ }
794
+ scheduleRender();
795
+ return;
796
+ }
797
+
798
+ if (isPanning) {
799
+ isPanning = false;
800
+ const dx = sx - panStartX;
801
+ const dy = sy - panStartY;
802
+ if (Math.hypot(dx, dy) <= DRAG_THRESHOLD) {
803
+ // Click on empty canvas → clear selection
804
+ if (dragMode) { selectedIndices.clear(); scheduleRender(); }
805
+ }
806
+ }
807
+ });
808
+
809
+ canvas.addEventListener('wheel', (e) => {
810
+ e.preventDefault();
811
+ const rect = canvas.getBoundingClientRect();
812
+ const sx = e.clientX - rect.left;
813
+ const sy = e.clientY - rect.top;
814
+ // Zoom the canvas
815
+ const factor = 1 - e.deltaY * 0.0018;
816
+ const newZoom = Math.max(0.15, Math.min(4, zoom * factor));
817
+ cameraX = sx - (sx - cameraX) * (newZoom / zoom);
818
+ cameraY = sy - (sy - cameraY) * (newZoom / zoom);
819
+ zoom = newZoom;
820
+ scheduleRender();
821
+ }, { passive: false });
822
+
823
+ fitButton.addEventListener('click', () => fitToBoxes(true));
824
+ explorerClose.addEventListener('click', () => closeFileExplorer());
825
+
826
+ dragToggle.addEventListener('click', () => {
827
+ dragMode = !dragMode;
828
+ dragToggle.classList.toggle('active', dragMode);
829
+ if (!dragMode) { selectedIndices.clear(); scheduleRender(); }
830
+ canvas.style.cursor = dragMode && hoverIndex >= 0 ? 'grab' : 'default';
831
+ });
832
+
833
+ window.addEventListener('resize', () => {
834
+ scheduleRender();
835
+ });
836
+
837
+ // ── Init ──────────────────────────────────────────────────────────────────────
838
+
839
+ void loadPositions().then(async () => {
840
+ await loadPath('', false);
841
+ });