backpack-viewer 0.5.1 → 0.7.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.
Files changed (62) hide show
  1. package/bin/serve.js +159 -396
  2. package/dist/app/assets/index-D-H7agBH.js +12 -0
  3. package/dist/app/assets/index-DE73ngo-.css +1 -0
  4. package/dist/app/assets/index-DFW3OKgJ.js +6 -0
  5. package/dist/app/assets/layout-worker-4xak23M6.js +1 -0
  6. package/dist/app/index.html +2 -2
  7. package/dist/bridge.d.ts +22 -0
  8. package/dist/bridge.js +41 -0
  9. package/dist/canvas.d.ts +15 -0
  10. package/dist/canvas.js +352 -12
  11. package/dist/config.js +10 -0
  12. package/dist/copy-prompt.d.ts +17 -0
  13. package/dist/copy-prompt.js +81 -0
  14. package/dist/default-config.json +6 -1
  15. package/dist/dom-utils.d.ts +46 -0
  16. package/dist/dom-utils.js +57 -0
  17. package/dist/empty-state.js +63 -31
  18. package/dist/extensions/api.d.ts +15 -0
  19. package/dist/extensions/api.js +185 -0
  20. package/dist/extensions/chat/backpack-extension.json +23 -0
  21. package/dist/extensions/chat/src/index.js +32 -0
  22. package/dist/extensions/chat/src/panel.js +306 -0
  23. package/dist/extensions/chat/src/providers/anthropic.js +158 -0
  24. package/dist/extensions/chat/src/providers/types.js +15 -0
  25. package/dist/extensions/chat/src/tools.js +281 -0
  26. package/dist/extensions/chat/style.css +147 -0
  27. package/dist/extensions/event-bus.d.ts +12 -0
  28. package/dist/extensions/event-bus.js +30 -0
  29. package/dist/extensions/loader.d.ts +32 -0
  30. package/dist/extensions/loader.js +71 -0
  31. package/dist/extensions/manifest.d.ts +54 -0
  32. package/dist/extensions/manifest.js +116 -0
  33. package/dist/extensions/panel-mount.d.ts +26 -0
  34. package/dist/extensions/panel-mount.js +377 -0
  35. package/dist/extensions/taskbar.d.ts +29 -0
  36. package/dist/extensions/taskbar.js +64 -0
  37. package/dist/extensions/types.d.ts +182 -0
  38. package/dist/extensions/types.js +8 -0
  39. package/dist/info-panel.d.ts +2 -1
  40. package/dist/info-panel.js +78 -87
  41. package/dist/keybindings.d.ts +1 -1
  42. package/dist/keybindings.js +1 -0
  43. package/dist/layout-worker.d.ts +4 -1
  44. package/dist/layout-worker.js +51 -1
  45. package/dist/layout.d.ts +8 -0
  46. package/dist/layout.js +8 -1
  47. package/dist/main.js +216 -35
  48. package/dist/search.js +1 -1
  49. package/dist/server-api-routes.d.ts +56 -0
  50. package/dist/server-api-routes.js +442 -0
  51. package/dist/server-extensions.d.ts +126 -0
  52. package/dist/server-extensions.js +272 -0
  53. package/dist/server-viewer-state.d.ts +18 -0
  54. package/dist/server-viewer-state.js +33 -0
  55. package/dist/shortcuts.js +6 -2
  56. package/dist/sidebar.js +19 -7
  57. package/dist/style.css +356 -74
  58. package/dist/tools-pane.js +31 -14
  59. package/package.json +4 -3
  60. package/dist/app/assets/index-B3z5bBGl.css +0 -1
  61. package/dist/app/assets/index-CKYlU1zT.js +0 -35
  62. package/dist/app/assets/layout-worker-BZXiBoiC.js +0 -1
package/dist/canvas.js CHANGED
@@ -34,6 +34,22 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
34
34
  let showEdgeLabels = true;
35
35
  let showTypeHulls = true;
36
36
  let showMinimap = true;
37
+ let dragMode = "idle";
38
+ // Node(s) being dragged, with their starting world positions so the
39
+ // mouse delta can translate a whole selection as a rigid body.
40
+ let dragNodes = [];
41
+ // World coords of the cursor when the drag started — used to compute
42
+ // how far the dragged group should move each mousemove.
43
+ let dragStartWorldX = 0;
44
+ let dragStartWorldY = 0;
45
+ // Rubber-band rectangle in world coords during shift-drag on empty canvas.
46
+ let rubberBand = null;
47
+ // IDs of nodes that currently have `pinned: true`. Tracked here for
48
+ // rendering the pin indicator without scanning the full nodes array.
49
+ let pinnedNodeIds = new Set();
50
+ // Pixels the cursor must move between mousedown and mousemove before
51
+ // the gesture is promoted from "click" to "drag". Matches pan threshold.
52
+ const CLICK_DRAG_THRESHOLD = 5;
37
53
  // Focus mode state
38
54
  let lastLoadedData = null;
39
55
  let focusSeedIds = null;
@@ -143,6 +159,31 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
143
159
  }
144
160
  // --- Rendering ---
145
161
  /** Draw only walk pulse effects (edges + node glows) + minimap on top of cached scene. */
162
+ /**
163
+ * Draw the rubber-band selection rectangle in world coords, if active.
164
+ * Intended to be called AFTER the main scene has been drawn and
165
+ * AFTER the camera transform has been applied (so the rect is in
166
+ * world space). Idempotent when no rubber-band is active.
167
+ */
168
+ function drawRubberBandIfActive() {
169
+ if (!rubberBand)
170
+ return;
171
+ ctx.save();
172
+ const minX = Math.min(rubberBand.x1, rubberBand.x2);
173
+ const maxX = Math.max(rubberBand.x1, rubberBand.x2);
174
+ const minY = Math.min(rubberBand.y1, rubberBand.y2);
175
+ const maxY = Math.max(rubberBand.y1, rubberBand.y2);
176
+ ctx.strokeStyle = cssVar("--accent") || "#d4a27f";
177
+ ctx.fillStyle = cssVar("--accent") || "#d4a27f";
178
+ ctx.globalAlpha = 0.12;
179
+ ctx.fillRect(minX, minY, maxX - minX, maxY - minY);
180
+ ctx.globalAlpha = 0.8;
181
+ ctx.lineWidth = 1 / Math.max(camera.scale, 0.5);
182
+ ctx.setLineDash([6 / Math.max(camera.scale, 0.5), 4 / Math.max(camera.scale, 0.5)]);
183
+ ctx.strokeRect(minX, minY, maxX - minX, maxY - minY);
184
+ ctx.setLineDash([]);
185
+ ctx.restore();
186
+ }
146
187
  function renderWalkOverlay() {
147
188
  if (!state)
148
189
  return;
@@ -203,6 +244,8 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
203
244
  ctx.stroke();
204
245
  }
205
246
  ctx.globalAlpha = 1;
247
+ // Rubber-band overlay (still inside the world-transform save/restore)
248
+ drawRubberBandIfActive();
206
249
  ctx.restore();
207
250
  // Minimap
208
251
  if (showMinimap && state.nodes.length > 1) {
@@ -468,6 +511,22 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
468
511
  ctx.lineWidth = isSelected ? 3 : 1.5;
469
512
  ctx.stroke();
470
513
  ctx.globalAlpha = 1;
514
+ // Pin indicator — subtle dashed outer ring on pinned nodes so the
515
+ // user can see which positions are being manually held. Rendered
516
+ // after the main circle but before the label so the label stays
517
+ // on top.
518
+ if (node.pinned) {
519
+ ctx.save();
520
+ ctx.strokeStyle = nodeBorder;
521
+ ctx.globalAlpha = 0.55;
522
+ ctx.lineWidth = 1;
523
+ ctx.setLineDash([3, 3]);
524
+ ctx.beginPath();
525
+ ctx.arc(node.x, node.y, r + 4, 0, Math.PI * 2);
526
+ ctx.stroke();
527
+ ctx.setLineDash([]);
528
+ ctx.restore();
529
+ }
471
530
  // Highlighted path glow
472
531
  if (highlightedPath && highlightedPath.nodeIds.has(node.id) && !isSelected) {
473
532
  ctx.save();
@@ -528,6 +587,16 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
528
587
  if (showMinimap && state.nodes.length > 1) {
529
588
  drawMinimap();
530
589
  }
590
+ // Rubber-band overlay — drawn AFTER the cache snapshot so it never
591
+ // gets baked into the cached scene. Uses its own camera transform.
592
+ if (rubberBand) {
593
+ ctx.save();
594
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
595
+ ctx.translate(-camera.x * camera.scale, -camera.y * camera.scale);
596
+ ctx.scale(camera.scale, camera.scale);
597
+ drawRubberBandIfActive();
598
+ ctx.restore();
599
+ }
531
600
  }
532
601
  function drawMinimap() {
533
602
  if (!state)
@@ -689,6 +758,11 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
689
758
  }
690
759
  function simulate() {
691
760
  if (!state || alpha < ALPHA_MIN) {
761
+ // Clear animFrame so callers using the `if (!animFrame) simulate()`
762
+ // idiom can kick the sim back on after releasing pins or bumping
763
+ // alpha. Without this reset, animFrame stays truthy from the last
764
+ // RAF handle and subsequent restart attempts silently no-op.
765
+ animFrame = 0;
692
766
  // Start walk animation loop if simulation stopped but walk mode is active
693
767
  if (walkMode && walkTrail.length > 0 && !walkAnimFrame) {
694
768
  walkAnimFrame = requestAnimationFrame(walkAnimate);
@@ -700,32 +774,185 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
700
774
  render();
701
775
  animFrame = requestAnimationFrame(simulate);
702
776
  }
703
- // --- Interaction: Pan + Click ---
704
- let dragging = false;
777
+ // --- Interaction: Pan + Click + Node drag + Rubber-band select ---
778
+ //
779
+ // Gesture dispatch on mousedown:
780
+ // 1. mousedown on a node (no modifier) + cursor moves > threshold →
781
+ // NODE DRAG. If the node is part of the current selection, drag
782
+ // the whole selection as a rigid body. Otherwise drag just this
783
+ // node. On drop, pin all dragged nodes.
784
+ // 2. mousedown on a node (no movement) → CLICK (existing selection
785
+ // and walk-mode behavior).
786
+ // 3. mousedown on empty canvas with Shift → RUBBER-BAND select.
787
+ // 4. mousedown on empty canvas without modifier → PAN (existing).
788
+ //
789
+ // Node drag is disabled during walk mode — in walk mode, clicking a
790
+ // node advances the path, and drag gestures would fight the animation.
705
791
  let didDrag = false;
706
792
  let lastX = 0;
707
793
  let lastY = 0;
794
+ let mouseDownStartX = 0;
795
+ let mouseDownStartY = 0;
708
796
  canvas.addEventListener("mousedown", (e) => {
709
- dragging = true;
797
+ dragMode = "pending";
710
798
  didDrag = false;
711
799
  lastX = e.clientX;
712
800
  lastY = e.clientY;
801
+ mouseDownStartX = e.clientX;
802
+ mouseDownStartY = e.clientY;
803
+ // Decide the potential gesture now — committed on first movement
804
+ // past the threshold. We still track "pending" so mouseup without
805
+ // movement falls through to the click path below.
806
+ const rect = canvas.getBoundingClientRect();
807
+ const mx = e.clientX - rect.left;
808
+ const my = e.clientY - rect.top;
809
+ const hit = nodeAtScreen(mx, my);
810
+ if (hit && !walkMode) {
811
+ // Set up the pending node drag. On first movement past the
812
+ // threshold we commit to "nodeDrag" mode.
813
+ dragNodes = [];
814
+ if (selectedNodeIds.has(hit.id) && selectedNodeIds.size > 1) {
815
+ // Drag the whole selection as a group
816
+ for (const id of selectedNodeIds) {
817
+ const n = state?.nodeMap.get(id);
818
+ if (n)
819
+ dragNodes.push({ node: n, startX: n.x, startY: n.y });
820
+ }
821
+ }
822
+ else {
823
+ dragNodes.push({ node: hit, startX: hit.x, startY: hit.y });
824
+ }
825
+ const [wx, wy] = screenToWorld(mx, my);
826
+ dragStartWorldX = wx;
827
+ dragStartWorldY = wy;
828
+ }
829
+ else if (!hit && e.shiftKey) {
830
+ // Pending rubber-band (commits on first movement)
831
+ const [wx, wy] = screenToWorld(mx, my);
832
+ rubberBand = { x1: wx, y1: wy, x2: wx, y2: wy };
833
+ }
834
+ // Otherwise (empty canvas, no shift): will become pan on first move
713
835
  });
714
836
  canvas.addEventListener("mousemove", (e) => {
715
- if (!dragging)
837
+ if (dragMode === "idle")
716
838
  return;
717
839
  const dx = e.clientX - lastX;
718
840
  const dy = e.clientY - lastY;
719
- if (Math.abs(dx) > 5 || Math.abs(dy) > 5)
841
+ const totalDx = Math.abs(e.clientX - mouseDownStartX);
842
+ const totalDy = Math.abs(e.clientY - mouseDownStartY);
843
+ // Commit to a gesture once the cursor crosses the threshold
844
+ if (dragMode === "pending" &&
845
+ (totalDx > CLICK_DRAG_THRESHOLD || totalDy > CLICK_DRAG_THRESHOLD)) {
720
846
  didDrag = true;
721
- camera.x -= dx / camera.scale;
722
- camera.y -= dy / camera.scale;
847
+ if (dragNodes.length > 0) {
848
+ dragMode = "nodeDrag";
849
+ // Freeze the worker while the user is actively dragging so its
850
+ // incoming tick messages don't fight our local x/y updates
851
+ if (useWorker && layoutWorker) {
852
+ layoutWorker.postMessage({ type: "stop" });
853
+ }
854
+ }
855
+ else if (rubberBand) {
856
+ dragMode = "rubberBand";
857
+ }
858
+ else {
859
+ dragMode = "pan";
860
+ }
861
+ }
862
+ if (dragMode === "nodeDrag") {
863
+ // Translate the dragged group by the cursor delta in world coords
864
+ const rect = canvas.getBoundingClientRect();
865
+ const mx = e.clientX - rect.left;
866
+ const my = e.clientY - rect.top;
867
+ const [wx, wy] = screenToWorld(mx, my);
868
+ const worldDx = wx - dragStartWorldX;
869
+ const worldDy = wy - dragStartWorldY;
870
+ for (const d of dragNodes) {
871
+ d.node.x = d.startX + worldDx;
872
+ d.node.y = d.startY + worldDy;
873
+ d.node.vx = 0;
874
+ d.node.vy = 0;
875
+ d.node.pinned = true;
876
+ pinnedNodeIds.add(d.node.id);
877
+ }
878
+ nodeHash.rebuild(state?.nodes ?? []);
879
+ requestRedraw();
880
+ }
881
+ else if (dragMode === "rubberBand" && rubberBand) {
882
+ const rect = canvas.getBoundingClientRect();
883
+ const mx = e.clientX - rect.left;
884
+ const my = e.clientY - rect.top;
885
+ const [wx, wy] = screenToWorld(mx, my);
886
+ rubberBand.x2 = wx;
887
+ rubberBand.y2 = wy;
888
+ requestRedraw();
889
+ }
890
+ else if (dragMode === "pan") {
891
+ camera.x -= dx / camera.scale;
892
+ camera.y -= dy / camera.scale;
893
+ requestRedraw();
894
+ }
723
895
  lastX = e.clientX;
724
896
  lastY = e.clientY;
725
- requestRedraw();
726
897
  });
727
898
  canvas.addEventListener("mouseup", (e) => {
728
- dragging = false;
899
+ const wasNodeDrag = dragMode === "nodeDrag";
900
+ const wasRubberBand = dragMode === "rubberBand";
901
+ const draggedNodeIdsSnapshot = dragNodes.map((d) => d.node.id);
902
+ if (wasNodeDrag) {
903
+ // Commit pins to the worker's copy so its simulation respects them
904
+ if (useWorker && layoutWorker && state) {
905
+ const updates = dragNodes.map((d) => ({
906
+ id: d.node.id,
907
+ x: d.node.x,
908
+ y: d.node.y,
909
+ }));
910
+ layoutWorker.postMessage({ type: "pin", updates });
911
+ layoutWorker.postMessage({ type: "resume", alpha: 0.5 });
912
+ }
913
+ else {
914
+ // Main-thread simulation — just bump alpha so neighbors reflow
915
+ alpha = Math.max(alpha, 0.5);
916
+ if (!animFrame)
917
+ simulate();
918
+ }
919
+ dragMode = "idle";
920
+ dragNodes = [];
921
+ requestRedraw();
922
+ return;
923
+ }
924
+ if (wasRubberBand && rubberBand && state) {
925
+ // Compute which nodes are inside the rectangle (world coords)
926
+ const minX = Math.min(rubberBand.x1, rubberBand.x2);
927
+ const maxX = Math.max(rubberBand.x1, rubberBand.x2);
928
+ const minY = Math.min(rubberBand.y1, rubberBand.y2);
929
+ const maxY = Math.max(rubberBand.y1, rubberBand.y2);
930
+ // Shift extends the existing selection; rubber-band without shift
931
+ // is currently impossible (we only enter this mode on shift+drag),
932
+ // but if that changes later we handle both cases here.
933
+ if (!e.shiftKey)
934
+ selectedNodeIds.clear();
935
+ for (const node of state.nodes) {
936
+ if (node.x >= minX && node.x <= maxX && node.y >= minY && node.y <= maxY) {
937
+ selectedNodeIds.add(node.id);
938
+ }
939
+ }
940
+ const ids = [...selectedNodeIds];
941
+ onNodeClick?.(ids.length > 0 ? ids : null);
942
+ rubberBand = null;
943
+ dragMode = "idle";
944
+ requestRedraw();
945
+ return;
946
+ }
947
+ // Pan finishing → nothing to do, state cleanup below
948
+ if (dragMode === "pan") {
949
+ dragMode = "idle";
950
+ return;
951
+ }
952
+ // Fall through: this was a click (pending → no drag). Treat as the
953
+ // existing click-to-select / walk-mode-path behavior.
954
+ dragMode = "idle";
955
+ rubberBand = null;
729
956
  if (didDrag)
730
957
  return;
731
958
  // Click — hit test for node selection
@@ -733,7 +960,8 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
733
960
  const mx = e.clientX - rect.left;
734
961
  const my = e.clientY - rect.top;
735
962
  const hit = nodeAtScreen(mx, my);
736
- const multiSelect = e.ctrlKey || e.metaKey;
963
+ const multiSelect = e.ctrlKey || e.metaKey || e.shiftKey;
964
+ void draggedNodeIdsSnapshot;
737
965
  if (walkMode && focusSeedIds && hit && state) {
738
966
  // Walk mode: find path from current position to clicked node
739
967
  const currentId = walkTrail.length > 0 ? walkTrail[walkTrail.length - 1] : focusSeedIds[0];
@@ -829,7 +1057,32 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
829
1057
  requestRedraw();
830
1058
  });
831
1059
  canvas.addEventListener("mouseleave", () => {
832
- dragging = false;
1060
+ // Cancel any in-progress drag. Node drags keep the pins they already
1061
+ // applied (the dragged nodes stay where the cursor last was). Rubber-
1062
+ // band selects are abandoned without committing the selection.
1063
+ if (dragMode === "nodeDrag") {
1064
+ // Sync the final pin positions to the worker so simulation resumes
1065
+ if (useWorker && layoutWorker && dragNodes.length > 0) {
1066
+ const updates = dragNodes.map((d) => ({
1067
+ id: d.node.id,
1068
+ x: d.node.x,
1069
+ y: d.node.y,
1070
+ }));
1071
+ layoutWorker.postMessage({ type: "pin", updates });
1072
+ layoutWorker.postMessage({ type: "resume", alpha: 0.5 });
1073
+ }
1074
+ else {
1075
+ alpha = Math.max(alpha, 0.5);
1076
+ if (!animFrame)
1077
+ simulate();
1078
+ }
1079
+ }
1080
+ if (dragMode === "rubberBand") {
1081
+ rubberBand = null;
1082
+ requestRedraw();
1083
+ }
1084
+ dragMode = "idle";
1085
+ dragNodes = [];
833
1086
  });
834
1087
  // --- Interaction: Zoom (wheel + pinch) ---
835
1088
  canvas.addEventListener("wheel", (e) => {
@@ -996,7 +1249,8 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
996
1249
  let hoverNodeId = null;
997
1250
  let hoverTimeout = null;
998
1251
  canvas.addEventListener("mousemove", (e) => {
999
- if (dragging) {
1252
+ // Suppress hover tooltip while any drag gesture is active
1253
+ if (dragMode !== "idle" && dragMode !== "pending") {
1000
1254
  if (tooltip.style.display !== "none") {
1001
1255
  tooltip.style.display = "none";
1002
1256
  hoverNodeId = null;
@@ -1056,6 +1310,11 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
1056
1310
  alpha = 1;
1057
1311
  selectedNodeIds = new Set();
1058
1312
  filteredNodeIds = null;
1313
+ // Reset any lingering drag/pin state from the previous graph
1314
+ pinnedNodeIds.clear();
1315
+ dragMode = "idle";
1316
+ dragNodes = [];
1317
+ rubberBand = null;
1059
1318
  // Center camera on the graph
1060
1319
  camera = { x: 0, y: 0, scale: 1 };
1061
1320
  if (state.nodes.length > 0) {
@@ -1092,6 +1351,66 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
1092
1351
  filteredNodeIds = ids;
1093
1352
  requestRedraw();
1094
1353
  },
1354
+ /**
1355
+ * Release every manually-pinned node so the force simulation
1356
+ * reclaims them. Called by main.ts when a data event (node/edge
1357
+ * add/remove, graph switch, backpack switch, focus/walk mode
1358
+ * enter/exit, live reload) invalidates the user's temporary
1359
+ * layout tweaks.
1360
+ *
1361
+ * Returns `true` if any pins were released, so the caller can
1362
+ * show a toast only when the user actually loses work.
1363
+ */
1364
+ releaseAllPins() {
1365
+ if (!state)
1366
+ return false;
1367
+ let hadPins = false;
1368
+ for (const node of state.nodes) {
1369
+ if (node.pinned) {
1370
+ hadPins = true;
1371
+ node.pinned = false;
1372
+ }
1373
+ }
1374
+ pinnedNodeIds.clear();
1375
+ // Clear any in-progress drag state too — a data change mid-drag
1376
+ // should abort the drag cleanly
1377
+ dragMode = "idle";
1378
+ dragNodes = [];
1379
+ rubberBand = null;
1380
+ if (hadPins) {
1381
+ // Nudge the simulation so the freed nodes start moving
1382
+ if (useWorker && layoutWorker) {
1383
+ layoutWorker.postMessage({ type: "unpin", ids: "all" });
1384
+ }
1385
+ else {
1386
+ alpha = Math.max(alpha, 0.5);
1387
+ if (!animFrame)
1388
+ simulate();
1389
+ }
1390
+ requestRedraw();
1391
+ }
1392
+ return hadPins;
1393
+ },
1394
+ hasPinnedNodes() {
1395
+ if (!state)
1396
+ return false;
1397
+ for (const node of state.nodes) {
1398
+ if (node.pinned)
1399
+ return true;
1400
+ }
1401
+ return false;
1402
+ },
1403
+ /** Clear the multi-selection (used by ESC keyboard shortcut). */
1404
+ clearSelection() {
1405
+ if (selectedNodeIds.size === 0)
1406
+ return;
1407
+ selectedNodeIds.clear();
1408
+ onNodeClick?.(null);
1409
+ requestRedraw();
1410
+ },
1411
+ getSelectedNodeIds() {
1412
+ return [...selectedNodeIds];
1413
+ },
1095
1414
  panToNode(nodeId) {
1096
1415
  this.panToNodes([nodeId]);
1097
1416
  },
@@ -1215,6 +1534,14 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
1215
1534
  enterFocus(seedNodeIds, hops) {
1216
1535
  if (!lastLoadedData || !state)
1217
1536
  return;
1537
+ // Release any user-pinned nodes — focus has its own layout and
1538
+ // shouldn't inherit manual tweaks from the full-graph view.
1539
+ for (const n of state.nodes)
1540
+ n.pinned = false;
1541
+ pinnedNodeIds.clear();
1542
+ dragMode = "idle";
1543
+ dragNodes = [];
1544
+ rubberBand = null;
1218
1545
  // Save current full-graph state
1219
1546
  if (!focusSeedIds) {
1220
1547
  savedFullState = state;
@@ -1261,6 +1588,15 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
1261
1588
  if (layoutWorker)
1262
1589
  layoutWorker.postMessage({ type: "stop" });
1263
1590
  state = savedFullState;
1591
+ // The saved full-graph state may have stale pinned flags from
1592
+ // before the focus transition; scrub them so the user starts
1593
+ // fresh on exit (pins are temporary view tweaks, not persistent).
1594
+ for (const n of state.nodes)
1595
+ n.pinned = false;
1596
+ pinnedNodeIds.clear();
1597
+ dragMode = "idle";
1598
+ dragNodes = [];
1599
+ rubberBand = null;
1264
1600
  nodeHash.rebuild(state.nodes);
1265
1601
  camera = savedFullCamera ?? { x: 0, y: 0, scale: 1 };
1266
1602
  focusSeedIds = null;
@@ -1329,6 +1665,10 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
1329
1665
  },
1330
1666
  setWalkMode(enabled) {
1331
1667
  walkMode = enabled;
1668
+ // Walk mode has its own layout choreography and must not fight
1669
+ // with user-pinned positions. Releasing pins on mode transitions
1670
+ // (either direction) keeps the two subsystems independent.
1671
+ this.releaseAllPins();
1332
1672
  if (enabled) {
1333
1673
  walkTrail = focusSeedIds ? [...focusSeedIds] : [...selectedNodeIds];
1334
1674
  if (!walkAnimFrame)
package/dist/config.js CHANGED
@@ -26,6 +26,16 @@ export function loadViewerConfig() {
26
26
  lod: { ...defaultConfig.lod, ...(user.lod ?? {}) },
27
27
  walk: { ...defaultConfig.walk, ...(user.walk ?? {}) },
28
28
  limits: { ...defaultConfig.limits, ...(user.limits ?? {}) },
29
+ extensions: {
30
+ ...defaultConfig.extensions,
31
+ ...(user.extensions ?? {}),
32
+ disabled: Array.isArray(user.extensions?.disabled)
33
+ ? user.extensions.disabled
34
+ : defaultConfig.extensions.disabled,
35
+ external: Array.isArray(user.extensions?.external)
36
+ ? user.extensions.external
37
+ : defaultConfig.extensions.external,
38
+ },
29
39
  };
30
40
  }
31
41
  catch {
@@ -0,0 +1,17 @@
1
+ import type { LearningGraphData } from "backpack-ontology";
2
+ export interface CopyPromptContext {
3
+ graphName: string;
4
+ data: LearningGraphData | null;
5
+ selection: string[];
6
+ focus: {
7
+ seedNodeIds: string[];
8
+ hops: number;
9
+ } | null;
10
+ }
11
+ /**
12
+ * "Copy Prompt" button — universal floor for users with no in-viewer chat
13
+ * and no MCP-capable client. Builds a prompt that describes the current
14
+ * view (graph, selection, focus) and copies it to the clipboard so the
15
+ * user can paste into Claude.ai web, Claude Desktop, or anywhere else.
16
+ */
17
+ export declare function initCopyPromptButton(getCtx: () => CopyPromptContext): HTMLButtonElement;
@@ -0,0 +1,81 @@
1
+ import { showToast } from "./dialog";
2
+ /**
3
+ * "Copy Prompt" button — universal floor for users with no in-viewer chat
4
+ * and no MCP-capable client. Builds a prompt that describes the current
5
+ * view (graph, selection, focus) and copies it to the clipboard so the
6
+ * user can paste into Claude.ai web, Claude Desktop, or anywhere else.
7
+ */
8
+ export function initCopyPromptButton(getCtx) {
9
+ const btn = document.createElement("button");
10
+ btn.className = "copy-prompt-btn";
11
+ btn.title = "Copy a prompt about the current view to clipboard";
12
+ btn.textContent = "Copy Prompt";
13
+ btn.addEventListener("click", async () => {
14
+ const ctx = getCtx();
15
+ if (!ctx.graphName || !ctx.data) {
16
+ showToast("No graph loaded");
17
+ return;
18
+ }
19
+ const prompt = buildPrompt(ctx);
20
+ try {
21
+ await navigator.clipboard.writeText(prompt);
22
+ showToast("Prompt copied — paste into Claude");
23
+ }
24
+ catch {
25
+ showToast("Failed to copy to clipboard");
26
+ }
27
+ });
28
+ return btn;
29
+ }
30
+ function labelOf(node) {
31
+ const first = Object.values(node.properties).find((v) => typeof v === "string");
32
+ return first ?? node.id;
33
+ }
34
+ function buildPrompt(ctx) {
35
+ const { graphName, data, selection, focus } = ctx;
36
+ const lines = [];
37
+ lines.push(`I'm looking at the Backpack learning graph "${graphName}" in the viewer.`);
38
+ lines.push(`It has ${data.nodes.length} nodes and ${data.edges.length} edges.`);
39
+ const typeCounts = new Map();
40
+ for (const n of data.nodes) {
41
+ typeCounts.set(n.type, (typeCounts.get(n.type) ?? 0) + 1);
42
+ }
43
+ if (typeCounts.size > 0) {
44
+ const summary = Array.from(typeCounts.entries())
45
+ .sort((a, b) => b[1] - a[1])
46
+ .slice(0, 8)
47
+ .map(([t, c]) => `${t} (${c})`)
48
+ .join(", ");
49
+ lines.push(`Node types: ${summary}`);
50
+ }
51
+ if (focus && focus.seedNodeIds.length > 0) {
52
+ lines.push("");
53
+ lines.push(`I'm focused on ${focus.seedNodeIds.length} seed node(s) at ${focus.hops} hop(s):`);
54
+ for (const id of focus.seedNodeIds.slice(0, 10)) {
55
+ const node = data.nodes.find((n) => n.id === id);
56
+ if (!node)
57
+ continue;
58
+ lines.push(`- ${labelOf(node)} (type: ${node.type})`);
59
+ }
60
+ }
61
+ if (selection.length > 0) {
62
+ lines.push("");
63
+ lines.push(`I have ${selection.length} node(s) selected:`);
64
+ for (const id of selection.slice(0, 20)) {
65
+ const node = data.nodes.find((n) => n.id === id);
66
+ if (!node)
67
+ continue;
68
+ const props = Object.entries(node.properties)
69
+ .filter(([k]) => !k.startsWith("_"))
70
+ .slice(0, 5)
71
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
72
+ .join(", ");
73
+ lines.push(`- ${labelOf(node)} (type: ${node.type}, id: ${id}${props ? `, ${props}` : ""})`);
74
+ }
75
+ }
76
+ lines.push("");
77
+ lines.push("If you have access to the Backpack MCP tools, please use them to answer questions about this graph (backpack_get_node, backpack_get_neighbors, backpack_search, etc).");
78
+ lines.push("");
79
+ lines.push("My question: ");
80
+ return lines.join("\n");
81
+ }
@@ -31,7 +31,8 @@
31
31
  "clusteringIncrease": "}",
32
32
  "toggleSidebar": "Tab",
33
33
  "walkMode": "w",
34
- "walkIsolate": "i"
34
+ "walkIsolate": "i",
35
+ "resetPins": "r"
35
36
  },
36
37
  "server": {
37
38
  "host": "127.0.0.1",
@@ -71,5 +72,9 @@
71
72
  "maxQualityItems": 5,
72
73
  "maxMostConnected": 5,
73
74
  "searchDebounceMs": 150
75
+ },
76
+ "extensions": {
77
+ "disabled": [],
78
+ "external": []
74
79
  }
75
80
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Tiny DOM helpers used across the viewer to avoid `innerHTML` for
3
+ * static SVG icons and other markup that previously got built as
4
+ * strings. Keeps every call site CSP-clean and XSS-safe by construction
5
+ * (no string concatenation, no parsing of user content).
6
+ */
7
+ export interface SvgIconOptions {
8
+ /** SVG viewBox attribute, e.g. "0 0 24 24" */
9
+ viewBox?: string;
10
+ /** Width and height in pixels (sets both attributes) */
11
+ size?: number;
12
+ /** Stroke width */
13
+ strokeWidth?: number;
14
+ /** Stroke linecap */
15
+ strokeLinecap?: "round" | "square" | "butt";
16
+ /** Stroke linejoin */
17
+ strokeLinejoin?: "round" | "miter" | "bevel";
18
+ /** Optional CSS class for the root <svg> */
19
+ className?: string;
20
+ }
21
+ /**
22
+ * Build an SVG icon from a list of child element specs. Each spec is
23
+ * `{ tag, attrs }` where tag is one of the standard SVG element names.
24
+ *
25
+ * Example:
26
+ * makeSvgIcon({ size: 14 }, [
27
+ * { tag: "polyline", attrs: { points: "11 17 6 12 11 7" } },
28
+ * { tag: "polyline", attrs: { points: "18 17 13 12 18 7" } },
29
+ * ])
30
+ */
31
+ export declare function makeSvgIcon(opts: SvgIconOptions, children: {
32
+ tag: string;
33
+ attrs: Record<string, string | number>;
34
+ }[]): SVGSVGElement;
35
+ /**
36
+ * Snapshot the current children of an element so they can be restored
37
+ * later via `restoreChildren()`. Used by inline-edit flows that
38
+ * temporarily replace a row's contents with an input.
39
+ *
40
+ * Returns a frozen array of cloned nodes — the original references are
41
+ * NOT preserved (which would break if the parent is mutated). Cloning
42
+ * is fine because the snapshotted markup is static — no event handlers
43
+ * to lose.
44
+ */
45
+ export declare function snapshotChildren(el: Element): Node[];
46
+ export declare function restoreChildren(el: Element, snapshot: Node[]): void;