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