import { WorkspaceNavigator } from "./WorkspaceNavigator.js"; import { NodeRenderer } from "./NodeRenderer.js"; import { ConnectionRenderer } from "./ConnectionRenderer.js"; import { GraphNode } from "./GraphNode.js"; import { GraphConnection } from "./GraphConnection.js"; import { GraphComment } from "./GraphComment.js"; import { DebugPanel } from "./DebugPanel.js"; import { PropertiesPanel } from "./PropertiesPanel.js"; import { ItemDragger } from "./ItemDragger.js"; import { PinConnectionManager } from "./PinConnectionManager.js"; import { GraphExecutor } from "./GraphExecutor.js"; import { PrintBubble } from "./PrintBubble.js"; import { SpatialAudio } from "./SpatialAudio.js"; import { getTypeColor } from "./getTypeColor.js"; import { NodeContextMenu } from "./NodeContextMenu.js"; import { NodeRegistry } from "./nodes/NodeRegistry.js"; const canvas = document.getElementById("workspaceCanvas"); const worldLayer = document.getElementById("worldLayer"); const nodeLayer = document.getElementById("nodeLayer"); const connLayerSvg = document.getElementById("connectionLayer"); const worldBoxLayer = document.getElementById("worldBoxLayer"); const viewboxLayer = document.getElementById("viewboxLayer"); const jsonEditorModal = document.getElementById("jsonEditorModal"); const jsonEditorTextarea = document.getElementById("jsonEditorTextarea"); const resetBtn = document.getElementById("resetViewBtn"); const draggableToggle = document.getElementById("draggableToggle"); const debugPanelEl = document.getElementById("debugPanel"); const propertiesPanelEl = document.getElementById("propertiesPanel"); /** @type {HTMLTemplateElement} */ const nodeTemplate = document.getElementById("nodeTemplate"); /** @type {HTMLTemplateElement} */ const pinTemplate = document.getElementById("pinTemplate"); const nav = new WorkspaceNavigator(canvas, worldLayer); const audio = new SpatialAudio(() => nav.getViewInfo().viewbox); const nodeRend = new NodeRenderer(nodeLayer, nodeTemplate, pinTemplate, (nodeId, pinId, direction) => { const targetId = nodeRend.getConnectedNodeId(nodeId, pinId, direction); if (!targetId) return; const rect = nodeRend.getNodeWorldRect(targetId); if (!rect) return; const focusOptions = nodeRend.getNodes().find(n => n.id === targetId)?.focusOptions ?? {}; nav.animateFocusOnRect(rect, focusOptions); history.pushState({ nodeId: targetId }, "", `#${targetId}`); }, (nodeId, pinId) => executor.execute(nodeId, pinId), () => connRend.render()); /** @type {Map} */ const printBubbles = new Map(); const executor = new GraphExecutor(nodeRend, (nodeId, value) => { printBubbles.get(nodeId)?.push(value); // Play spatial audio at node position const node = nodeRend.getNodes().find(n => n.id === nodeId); if (node) { const rect = nodeRend.getNodeWorldRect(nodeId); if (rect) { const centerX = rect.x + rect.width / 2; const centerY = rect.y + rect.height / 2; audio.play('pop', centerX, centerY, rect.width, rect.height); } } }, async (connId, kind) => { await connRend.activatePath(connId, kind === "exec" ? 200 : 150); }); const connRend = new ConnectionRenderer(connLayerSvg, canvas, nodeRend, nav); const pinConns = new PinConnectionManager(canvas, connLayerSvg, nodeLayer, nodeRend, nav, connRend); // Properties Panel - Initialize early so it can be referenced const propertiesPanel = new PropertiesPanel(propertiesPanelEl, () => { connRend.render(); renderWorldBoxes(); renderViewbox(); }); const dragger = new ItemDragger(canvas, nav, nodeRend, () => { const activeNode = dragger.activeId ? nodeRend.getNodes().find(n => n.id === dragger.activeId) : null; debugPanel.update(nav.getViewInfo(), dragger.activeId, dragger.activeId ? nodeRend.getNodeWorldRect(dragger.activeId) : null, activeNode); propertiesPanel.loadNode(activeNode); }, () => { connRend.render(); } ); // Suppress navigator pan while a node drag is actively in progress (enables edge-pan on mobile + mouse) nav.setDragInProgressChecker(() => dragger.isDragging); // Drop the active drag immediately when a pinch-to-zoom starts so the two gestures don't conflict nav.setOnPinchStart(() => dragger.cancelDrag()); // Node context menu for adding nodes const nodeContextMenu = new NodeContextMenu(canvas, (type, worldX, worldY) => { // Generate unique node ID const existingIds = nodeRend.getNodes().map(n => n.id); let counter = 1; let newId = type; while (existingIds.includes(newId)) { counter++; newId = `${type}_${counter}`; } // Get default pins from node definition const pins = NodeRegistry.BlueprintPure_GetDefaultPins(type); // Create and add new node const newNode = new GraphNode({ id: newId, type: type, title: type.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '), position: { x: Math.round(worldX), y: Math.round(worldY) }, inputs: pins.inputs.map(p => ({ ...p })), outputs: pins.outputs.map(p => ({ ...p })), userSpawned: true }); nodeRend.addNode(newNode); dragger.registerNode(newId); connRend.render(); // Attach PrintBubble if it's a print node if (type === "print") { const el = nodeRend.getNodeElements().get(newId); if (el) printBubbles.set(newId, new PrintBubble(el)); } }); const debugPanel = new DebugPanel(debugPanelEl, (enabled) => { dragger.enabled = enabled; pinConns.enabled = enabled; }, () => { const liveGraph = { settings: graphData.settings, images: graphData.images, nodes: nodeRend.getNodes().map(n => ({ id: n.id, type: n.type, title: n.title, position: { x: Math.round(n.position.x), y: Math.round(n.position.y) }, ...(n.width !== undefined ? { width: n.width } : {}), ...(n.markdownSrc !== undefined ? { markdownSrc: n.markdownSrc } : {}), ...(n.imageSrc !== undefined ? { imageSrc: n.imageSrc } : {}), ...(n.lottieSrc !== undefined ? { lottieSrc: n.lottieSrc } : {}), ...(n.focusOptions !== undefined ? { focusOptions: n.focusOptions } : {}), inputs: n.inputs, outputs: n.outputs, })), connections: nodeRend.getConnections().map(c => ({ id: c.id, from: c.from, to: c.to, kind: c.kind, })), }; navigator.clipboard.writeText(JSON.stringify(liveGraph, null, 2)); }, (nodeId, updates) => { const node = nodeRend.getNodes().find(n => n.id === nodeId); if (!node) return; let needsRerender = false; if (updates.title !== undefined) node.title = updates.title; if (updates.position) { nodeRend.setNodePosition(nodeId, updates.position.x, updates.position.y); } if (updates.width !== undefined) { node.width = updates.width; const el = nodeRend.getNodeElement(nodeId); if (el) el.style.width = updates.width != null ? `${updates.width}px` : ''; } if (updates.inputs !== undefined) { node.inputs = updates.inputs; needsRerender = true; } if (updates.outputs !== undefined) { node.outputs = updates.outputs; needsRerender = true; } const nodeEl = nodeRend.getNodeElement(nodeId); if (nodeEl && updates.title !== undefined) { const titleEl = nodeEl.querySelector('.node-title'); if (titleEl) titleEl.textContent = updates.title; } // Re-render node if pins changed if (needsRerender && nodeEl) { const parent = nodeEl.parentElement; nodeEl.remove(); // Re-add using internal render method const fragment = nodeTemplate.content.cloneNode(true); const article = fragment.querySelector('.blueprint-node'); const title = article.querySelector('.node-title'); const inputs = article.querySelector('.node-inputs'); const outputs = article.querySelector('.node-outputs'); article.dataset.nodeId = node.id; article.dataset.nodeType = node.type; title.textContent = node.title; article.style.transform = `translate3d(${node.position.x}px, ${node.position.y}px, 0)`; if (node.width != null) article.style.width = `${node.width}px`; // Re-render pins using pinTemplate inputs.innerHTML = ''; outputs.innerHTML = ''; node.inputs.forEach(pin => { const pinFrag = pinTemplate.content.cloneNode(true); const container = pinFrag.firstElementChild; const label = container.querySelector('.pin-label'); const handle = container.querySelector('.pin-handle'); container.dataset.pinId = pin.id; container.dataset.type = pin.kind; container.dataset.direction = 'input'; container.classList.add('is-disconnected'); container.style.setProperty('--pin-kind-color', getTypeColor(pin.kind)); const isStandardExec = pin.kind === 'exec' && (pin.id === 'exec_in' || pin.id === 'exec_out'); if (isStandardExec && !pin.name) { label.textContent = ''; label.classList.add('is-hidden'); } else { label.textContent = pin.name; } inputs.appendChild(container); }); node.outputs.forEach(pin => { const pinFrag = pinTemplate.content.cloneNode(true); const container = pinFrag.firstElementChild; const label = container.querySelector('.pin-label'); const handle = container.querySelector('.pin-handle'); container.dataset.pinId = pin.id; container.dataset.type = pin.kind; container.dataset.direction = 'output'; container.classList.add('is-disconnected'); container.style.setProperty('--pin-kind-color', getTypeColor(pin.kind)); const isStandardExec = pin.kind === 'exec' && (pin.id === 'exec_in' || pin.id === 'exec_out'); if (isStandardExec && !pin.name) { label.textContent = ''; label.classList.add('is-hidden'); } else { label.textContent = pin.name; } outputs.appendChild(container); }); parent?.appendChild(article); // Update internal node elements map const nodeElements = nodeRend.getNodeElements(); nodeElements.set(nodeId, article); // Update debug panel with refreshed node const activeNode = nodeRend.getNodes().find(n => n.id === dragger.activeId); debugPanel.update(nav.getViewInfo(), dragger.activeId, dragger.activeId ? nodeRend.getNodeWorldRect(dragger.activeId) : null, activeNode); } connRend.render(); }, (enabled) => { // WorldBox toggle if (worldBoxLayer) { worldBoxLayer.style.display = enabled ? 'block' : 'none'; renderWorldBoxes(); } }, () => { // Copy entire graph as JSON const liveGraph = { settings: graphData.settings, images: graphData.images, comments: graphData.comments, nodes: nodeRend.getNodes().map(n => ({ id: n.id, type: n.type, title: n.title, position: { x: Math.round(n.position.x), y: Math.round(n.position.y) }, ...(n.width !== undefined ? { width: n.width } : {}), ...(n.markdownSrc !== undefined ? { markdownSrc: n.markdownSrc } : {}), ...(n.imageSrc !== undefined ? { imageSrc: n.imageSrc } : {}), ...(n.lottieSrc !== undefined ? { lottieSrc: n.lottieSrc } : {}), ...(n.focusOptions !== undefined ? { focusOptions: n.focusOptions } : {}), inputs: n.inputs, outputs: n.outputs, })), connections: nodeRend.getConnections().map(c => ({ id: c.id, from: c.from, to: c.to, kind: c.kind, })), }; navigator.clipboard.writeText(JSON.stringify(liveGraph, null, 2)); }, (enabled) => { // Viewbox toggle if (viewboxLayer) { viewboxLayer.style.display = enabled ? 'block' : 'none'; renderViewbox(); } }, () => { // Show Properties Panel propertiesPanel.expand(); } ); // Connection SVG is screen-space — must re-render on every pan/zoom so paths track node positions. let hasTransformed = false; let trackTransform = false; nav.setOnAfterTransform(() => { connRend.render(); renderWorldBoxes(); renderViewbox(); const activeNode = dragger.activeId ? nodeRend.getNodes().find(n => n.id === dragger.activeId) : null; debugPanel.update(nav.getViewInfo(), dragger.activeId, dragger.activeId ? nodeRend.getNodeWorldRect(dragger.activeId) : null, activeNode); if (trackTransform && !hasTransformed) { hasTransformed = true; canvas.classList.add('has-transformed'); } }); // ─── WorldBox Visualizer ────────────────────────────────────────────────────── /** * Renders worldbox overlays for all nodes with focusOptions */ function renderWorldBoxes() { if (!worldBoxLayer || worldBoxLayer.style.display === 'none') return; // Clear existing worldBoxLayer.innerHTML = ''; const viewInfo = nav.getViewInfo(); const zoom = viewInfo.zoom; const offset = nav.getEffectiveOffset(); const canvasRect = canvas.getBoundingClientRect(); nodeRend.getNodes().forEach(node => { if (!node.focusOptions?.minWorldBox && !node.focusOptions?.responsiveWorldBox) return; const rect = nodeRend.getNodeWorldRect(node.id); if (!rect) return; const nodeCenterX = rect.x + rect.width / 2; const nodeCenterY = rect.y + rect.height / 2; // Determine which breakpoint would be active at current viewport width const vw = window.innerWidth; let activeBreakpointIndex = -1; // -1 = base worldBox is active if (node.focusOptions.responsiveWorldBox && Array.isArray(node.focusOptions.responsiveWorldBox)) { const sorted = [...node.focusOptions.responsiveWorldBox] .map((bp, i) => ({ bp, i })) .sort((a, b) => (a.bp.minViewportWidth ?? 0) - (b.bp.minViewportWidth ?? 0)); for (const { bp, i } of sorted) { if (vw < (bp.minViewportWidth ?? Infinity)) { activeBreakpointIndex = i; break; } } } // Render base worldbox if it exists if (node.focusOptions.minWorldBox) { const worldBox = node.focusOptions.minWorldBox; const anchorX = node.focusOptions.anchorX ?? 0.5; const anchorY = node.focusOptions.anchorY ?? 0.5; const isActive = activeBreakpointIndex === -1; // The viewport region is offset from the node center based on anchor. // anchor=0.5 means node is centered in viewport (no offset). // The box shifts so the node sits at the anchor position within it. const boxX = nodeCenterX - worldBox.width * anchorX; const boxY = nodeCenterY - worldBox.height * anchorY; // Convert to screen space const screenX = (boxX + offset.x) * zoom; const screenY = (boxY + offset.y) * zoom; const screenW = worldBox.width * zoom; const screenH = worldBox.height * zoom; const rectEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rectEl.setAttribute('class', isActive ? 'worldbox-rect worldbox-rect--active' : 'worldbox-rect'); rectEl.setAttribute('x', String(screenX)); rectEl.setAttribute('y', String(screenY)); rectEl.setAttribute('width', String(screenW)); rectEl.setAttribute('height', String(screenH)); worldBoxLayer.appendChild(rectEl); const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text'); textEl.setAttribute('class', isActive ? 'worldbox-label worldbox-label--active' : 'worldbox-label'); textEl.setAttribute('x', String(screenX + 6)); textEl.setAttribute('y', String(screenY + 14)); textEl.textContent = `${node.id} (${worldBox.width}×${worldBox.height})`; worldBoxLayer.appendChild(textEl); // Draw crosshair at the node center (the focus point) const anchorScreenX = (nodeCenterX + offset.x) * zoom; const anchorScreenY = (nodeCenterY + offset.y) * zoom; const crossSize = 8; const hLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); hLine.setAttribute('class', isActive ? 'worldbox-anchor worldbox-anchor--active' : 'worldbox-anchor'); hLine.setAttribute('x1', String(anchorScreenX - crossSize)); hLine.setAttribute('y1', String(anchorScreenY)); hLine.setAttribute('x2', String(anchorScreenX + crossSize)); hLine.setAttribute('y2', String(anchorScreenY)); worldBoxLayer.appendChild(hLine); const vLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); vLine.setAttribute('class', isActive ? 'worldbox-anchor worldbox-anchor--active' : 'worldbox-anchor'); vLine.setAttribute('x1', String(anchorScreenX)); vLine.setAttribute('y1', String(anchorScreenY - crossSize)); vLine.setAttribute('x2', String(anchorScreenX)); vLine.setAttribute('y2', String(anchorScreenY + crossSize)); worldBoxLayer.appendChild(vLine); } // Render responsive worldboxes if (node.focusOptions.responsiveWorldBox && Array.isArray(node.focusOptions.responsiveWorldBox)) { node.focusOptions.responsiveWorldBox.forEach((responsive, index) => { const worldBox = responsive.minWorldBox; if (!worldBox) return; const anchorX = responsive.anchorX ?? 0.5; const anchorY = responsive.anchorY ?? 0.5; const isActive = activeBreakpointIndex === index; // The viewport region is offset from the node center based on anchor. const boxX = nodeCenterX - worldBox.width * anchorX; const boxY = nodeCenterY - worldBox.height * anchorY; // Convert to screen space const screenX = (boxX + offset.x) * zoom; const screenY = (boxY + offset.y) * zoom; const screenW = worldBox.width * zoom; const screenH = worldBox.height * zoom; const opacity = isActive ? '1' : String(0.4 + (index * 0.15)); const rectEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rectEl.setAttribute('class', isActive ? 'worldbox-rect worldbox-rect--active' : 'worldbox-rect'); rectEl.setAttribute('x', String(screenX)); rectEl.setAttribute('y', String(screenY)); rectEl.setAttribute('width', String(screenW)); rectEl.setAttribute('height', String(screenH)); rectEl.setAttribute('opacity', opacity); worldBoxLayer.appendChild(rectEl); const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text'); textEl.setAttribute('class', isActive ? 'worldbox-label worldbox-label--active' : 'worldbox-label'); textEl.setAttribute('x', String(screenX + 6)); textEl.setAttribute('y', String(screenY + 14 + (index * 16))); textEl.textContent = `@${responsive.minViewportWidth}px: ${worldBox.width}×${worldBox.height}`; textEl.setAttribute('opacity', opacity); worldBoxLayer.appendChild(textEl); // Draw crosshair at the node center (the focus point) const anchorScreenX = (nodeCenterX + offset.x) * zoom; const anchorScreenY = (nodeCenterY + offset.y) * zoom; const crossSize = 8; const hLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); hLine.setAttribute('class', isActive ? 'worldbox-anchor worldbox-anchor--active' : 'worldbox-anchor'); hLine.setAttribute('x1', String(anchorScreenX - crossSize)); hLine.setAttribute('y1', String(anchorScreenY)); hLine.setAttribute('x2', String(anchorScreenX + crossSize)); hLine.setAttribute('y2', String(anchorScreenY)); hLine.setAttribute('opacity', opacity); worldBoxLayer.appendChild(hLine); const vLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); vLine.setAttribute('class', isActive ? 'worldbox-anchor worldbox-anchor--active' : 'worldbox-anchor'); vLine.setAttribute('x1', String(anchorScreenX)); vLine.setAttribute('y1', String(anchorScreenY - crossSize)); vLine.setAttribute('x2', String(anchorScreenX)); vLine.setAttribute('y2', String(anchorScreenY + crossSize)); vLine.setAttribute('opacity', opacity); worldBoxLayer.appendChild(vLine); }); } }); } // ─── Viewbox Visualizer ─────────────────────────────────────────────────────── /** * Renders the current viewbox overlay showing visible world area */ function renderViewbox() { if (!viewboxLayer || viewboxLayer.style.display === 'none') return; // Clear existing viewboxLayer.innerHTML = ''; const viewInfo = nav.getViewInfo(); const zoom = viewInfo.zoom; const offset = nav.getEffectiveOffset(); const viewbox = viewInfo.viewbox; // Convert viewbox (world coords) to screen space const screenX = (viewbox.x + offset.x) * zoom; const screenY = (viewbox.y + offset.y) * zoom; const screenW = viewbox.width * zoom; const screenH = viewbox.height * zoom; // Create rectangle const rectEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rectEl.setAttribute('class', 'viewbox-rect'); rectEl.setAttribute('x', String(screenX)); rectEl.setAttribute('y', String(screenY)); rectEl.setAttribute('width', String(screenW)); rectEl.setAttribute('height', String(screenH)); viewboxLayer.appendChild(rectEl); // Add label const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text'); textEl.setAttribute('class', 'viewbox-label'); textEl.setAttribute('x', String(screenX + 10)); textEl.setAttribute('y', String(screenY + 20)); textEl.textContent = `Viewbox (${Math.round(viewbox.width)}×${Math.round(viewbox.height)})`; viewboxLayer.appendChild(textEl); } // ─── Load graph from JSON ────────────────────────────────────────────────── const response = await fetch("data/graph.json"); let graphData = await response.json(); // Load spatial audio await audio.load('pop', 'data/pop.mp3'); await audio.load('denied', 'data/denied.mp3'); graphData.comments?.forEach(c => nodeRend.addComment(new GraphComment(c))); graphData.nodes.forEach(n => nodeRend.addNode(new GraphNode(n))); graphData.connections.forEach(c => nodeRend.addConnection(new GraphConnection(c.from, c.to, c.kind, c.id))); // Load graph settings into properties panel propertiesPanel.loadGraphSettings(graphData); // Attach a PrintBubble to every print-type node graphData.nodes.forEach(n => { if (n.type !== "print") return; const el = nodeRend.getNodeElements().get(n.id); if (el) printBubbles.set(n.id, new PrintBubble(el)); }); for (const img of graphData.images ?? []) { if (img.inline) { // Fetch and inline SVG for direct style control try { const response = await fetch(img.src); const svgText = await response.text(); const parser = new DOMParser(); const svgDoc = parser.parseFromString(svgText, 'image/svg+xml'); const svgEl = svgDoc.documentElement; svgEl.classList.add('world-image'); // Remove percentage dimensions, set explicit width svgEl.removeAttribute('height'); svgEl.setAttribute('width', String(img.width)); // Preserve viewBox for aspect ratio if (!svgEl.hasAttribute('viewBox')) { const w = svgEl.getAttribute('width'); const h = svgEl.getAttribute('height'); if (w && h) svgEl.setAttribute('viewBox', `0 0 ${w} ${h}`); } svgEl.style.transform = `translate(${img.position.x}px, ${img.position.y}px)`; svgEl.style.display = 'block'; // Apply cssStyle to all paths/shapes in SVG const shapes = svgEl.querySelectorAll('path, rect, circle, ellipse, polygon, polyline'); if (img.cssStyle) { const styleProps = img.cssStyle.split(';').filter(s => s.trim()); styleProps.forEach(prop => { const [key, val] = prop.split(':').map(s => s.trim()); if (!key || !val) return; if (key === 'fill' || key === 'stroke' || key === 'stroke-width' || key === 'opacity') { shapes.forEach(el => { // Skip elements with fill="none" (background/artboard rects) if (el.getAttribute('fill') === 'none') return; el.style[key] = val; }); } else { svgEl.style[key] = val; } }); } else { // Default fill to white if no style specified shapes.forEach(el => { if (el.getAttribute('fill') === 'none') return; // Skip background if (!el.hasAttribute('fill') && !el.style.fill) { el.style.fill = '#ffffff'; } }); } worldLayer.prepend(svgEl); dragger.registerImage(img.src, svgEl, { ...img.position }); console.log('Inlined SVG:', img.src, svgEl); } catch (err) { console.error(`Failed to inline SVG: ${img.src}`, err); } } else { // Standard img element const el = document.createElement("img"); el.className = "world-image"; el.src = img.src; el.width = img.width; el.alt = ""; el.style.transform = `translate(${img.position.x}px, ${img.position.y}px)`; if (img.cssStyle) { el.style.cssText += `;${img.cssStyle}`; } el.addEventListener("dragstart", (e) => e.preventDefault()); worldLayer.prepend(el); dragger.registerImage(img.src, el, { ...img.position }); } } graphData.nodes.forEach(n => dragger.registerNode(n.id)); // ─── Initial focus ───────────────────────────────────────────────────────── const settings = graphData.settings ?? {}; const introDurationMs = settings.introDurationMs ?? 700; const initialViewbox = settings.initialViewbox ?? { x: 0, y: 0, width: 1280, height: 720 }; const initialDraggable = settings.draggable ?? false; dragger.enabled = initialDraggable; pinConns.enabled = initialDraggable; debugPanel.setDraggable(initialDraggable); // Snap to a tiny zoom first so the intro animation has somewhere to travel from const introRect = { x: initialViewbox.x + initialViewbox.width / 2 - 50, y: initialViewbox.y + initialViewbox.height / 2 - 50, width: 100, height: 100, }; const anchorId = location.hash.slice(1); const anchorNode = anchorId ? nodeRend.getNodes().find(n => n.id === anchorId) : null; const anchorRect = anchorNode ? nodeRend.getNodeWorldRect(anchorNode.id) : null; // Seed the current history entry so popstate can restore it when navigating back history.replaceState({ nodeId: anchorId || null }, ""); nav.focusOnRect(introRect); // Initial connection render — one rAF so layout is measured after nodes are in the DOM. requestAnimationFrame(() => connRend.render()); if (anchorNode) { // Wait for all markdown to finish loading so info nodes have their real height, // then one rAF to let the browser measure layout before animating. nodeRend.contentReady().then(() => requestAnimationFrame(() => { const rect = nodeRend.getNodeWorldRect(anchorNode.id); if (rect) { nav.animateFocusOnRect(rect, { ...(anchorNode.focusOptions ?? {}), durationMs: introDurationMs }); } else { nav.animateFocusOnRect(initialViewbox, { paddingFraction: 0, durationMs: introDurationMs }); } setTimeout(() => { trackTransform = true; }, introDurationMs + 100); })); } else { nav.animateFocusOnRect(initialViewbox, { paddingFraction: 0, durationMs: introDurationMs }); setTimeout(() => { trackTransform = true; }, introDurationMs + 100); } resetBtn.addEventListener("click", () => { history.pushState({ nodeId: null }, "", location.pathname + location.search); nav.animateFocusOnRect(initialViewbox, { paddingFraction: 0, durationMs: introDurationMs }); }); window.addEventListener("popstate", (e) => { const nodeId = (e.state?.nodeId ?? location.hash.slice(1)) || null; if (nodeId) { const node = nodeRend.getNodes().find(n => n.id === nodeId); if (node) { nodeRend.contentReady().then(() => requestAnimationFrame(() => { const rect = nodeRend.getNodeWorldRect(node.id); if (rect) nav.animateFocusOnRect(rect, { ...(node.focusOptions ?? {}), durationMs: introDurationMs }); })); return; } } nav.animateFocusOnRect(initialViewbox, { paddingFraction: 0, durationMs: introDurationMs }); }); draggableToggle.addEventListener("click", () => { const isActive = draggableToggle.classList.toggle("active"); dragger.enabled = isActive; pinConns.enabled = isActive; }); // Clear selection when clicking on canvas background canvas.addEventListener("click", (e) => { // Only clear if clicking directly on canvas (not on nodes or other elements) if (e.target === canvas) { dragger.clearSelection(); propertiesPanel.loadNode(null); } }); // Delete key to remove selected node document.addEventListener("keydown", (e) => { // Skip if user is typing in an input if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target.isContentEditable) { return; } if (e.key === "Delete" || e.key === "Backspace") { const activeId = dragger.getActiveId(); if (activeId) { const node = nodeRend.getNodes().find(n => n.id === activeId); if (node?.userSpawned) { e.preventDefault(); dragger.unregisterNode(activeId); nodeRend.removeNode(activeId); printBubbles.delete(activeId); propertiesPanel.loadNode(null); connRend.render(); } else if (node) { // Node can't be deleted - shake it red and play denied sound e.preventDefault(); const nodeEl = nodeRend.getNodeElements().get(activeId); if (nodeEl) { nodeEl.classList.add('node-shake-red'); nodeEl.addEventListener('animationend', () => { nodeEl.classList.remove('node-shake-red'); }, { once: true }); } // Play denied sound at node position const rect = nodeRend.getNodeWorldRect(activeId); if (rect) { const centerX = rect.x + rect.width / 2; const centerY = rect.y + rect.height / 2; audio.play('denied', centerX, centerY, rect.width, rect.height); } } } } }); // Right-click context menu for adding nodes canvas.addEventListener("contextmenu", (e) => { e.preventDefault(); // Don't show menu if we just finished a pan drag if (nav.didPanRecently(150)) { return; } // Convert client coords to world coords const rect = canvas.getBoundingClientRect(); const zoom = Math.max(0.01, nav.zoomLevel || 1); const offset = nav.getEffectiveOffset(); const worldX = (e.clientX - rect.left) / zoom - offset.x; const worldY = (e.clientY - rect.top) / zoom - offset.y; nodeContextMenu.show(e.clientX, e.clientY, worldX, worldY); }); // Initialize dragging as disabled dragger.enabled = false; pinConns.enabled = false;