|
| 1 | +<script> |
| 2 | + import { onMount, tick } from 'svelte'; |
| 3 | + import { base } from '$app/paths'; |
| 4 | + import { page } from '$app/stores'; |
| 5 | + import Markdown from 'svelte-exmarkdown'; |
| 6 | + import { gfmPlugin } from 'svelte-exmarkdown/gfm'; |
| 7 | + import { canvases } from '$lib/canvases.js'; |
| 8 | + import '$lib/canvas.css'; |
| 9 | +
|
| 10 | + const plugins = [gfmPlugin()]; |
| 11 | +
|
| 12 | + let canvasData = $state(null); |
| 13 | + let error = $state(null); |
| 14 | + let fileContents = $state({}); |
| 15 | +
|
| 16 | + // Get canvas info from page data |
| 17 | + const { data } = $derived($page); |
| 18 | +
|
| 19 | + // Helper to load scripts dynamically |
| 20 | + function loadScript(src) { |
| 21 | + return new Promise((resolve, reject) => { |
| 22 | + const script = document.createElement('script'); |
| 23 | + script.src = src; |
| 24 | + script.onload = resolve; |
| 25 | + script.onerror = reject; |
| 26 | + document.head.appendChild(script); |
| 27 | + }); |
| 28 | + } |
| 29 | +
|
| 30 | + // Track if scripts are loaded |
| 31 | + let scriptsLoaded = false; |
| 32 | +
|
| 33 | + async function loadCanvas(canvasFile) { |
| 34 | + try { |
| 35 | + // Reset state |
| 36 | + canvasData = null; |
| 37 | + fileContents = {}; |
| 38 | + error = null; |
| 39 | +
|
| 40 | + // Files in static/ are served at root |
| 41 | + const response = await fetch(`${base}/${canvasFile}`); |
| 42 | + if (!response.ok) throw new Error('Canvas file not found'); |
| 43 | + canvasData = await response.json(); |
| 44 | +
|
| 45 | + // Load markdown file contents |
| 46 | + if (canvasData.nodes) { |
| 47 | + for (const node of canvasData.nodes) { |
| 48 | + if (node.type === 'file' && node.file.endsWith('.md')) { |
| 49 | + try { |
| 50 | + // Files in static/ are served at root, not /static/ |
| 51 | + const mdResponse = await fetch(`${base}/${node.file}`); |
| 52 | + if (mdResponse.ok) { |
| 53 | + let content = await mdResponse.text(); |
| 54 | + // Strip YAML frontmatter |
| 55 | + content = content.replace(/^---\n[\s\S]*?\n---\n/, ''); |
| 56 | + // Strip leading h1 header to avoid duplication with card title |
| 57 | + content = content.replace(/^#\s+.*\n+/, ''); |
| 58 | + fileContents[node.id] = content; |
| 59 | + } |
| 60 | + } catch (e) { |
| 61 | + console.warn(`Could not load ${node.file}:`, e); |
| 62 | + } |
| 63 | + } |
| 64 | + } |
| 65 | + } |
| 66 | +
|
| 67 | + // Wait for all nodes to be rendered in DOM |
| 68 | + await tick(); |
| 69 | + await new Promise(resolve => setTimeout(resolve, 100)); |
| 70 | +
|
| 71 | + // Store edges globally BEFORE loading canvas.js |
| 72 | + window.edges = canvasData.edges || []; |
| 73 | +
|
| 74 | + // Load scripts only once |
| 75 | + if (!scriptsLoaded) { |
| 76 | + await loadScript(`${base}/prism.js`); |
| 77 | + await loadScript(`${base}/canvas.js`); |
| 78 | + scriptsLoaded = true; |
| 79 | + } |
| 80 | +
|
| 81 | + // Wait a bit for canvas.js to initialize |
| 82 | + await new Promise(resolve => setTimeout(resolve, 100)); |
| 83 | +
|
| 84 | + // Initialize canvas.js functions |
| 85 | + if (typeof adjustCanvasToViewport === 'function') { |
| 86 | + adjustCanvasToViewport(); |
| 87 | + } |
| 88 | + if (typeof drawEdges === 'function') { |
| 89 | + drawEdges(); |
| 90 | + } |
| 91 | + if (typeof updateCanvasData === 'function') { |
| 92 | + updateCanvasData(); |
| 93 | + } |
| 94 | +
|
| 95 | + // Manually set up drag handlers for dynamically created nodes |
| 96 | + document.querySelectorAll('.node .node-name').forEach(nodeName => { |
| 97 | + nodeName.addEventListener('mousedown', function(e) { |
| 98 | + if (window.isSpacePressed) return; |
| 99 | + window.isDragging = true; |
| 100 | + window.startX = e.clientX; |
| 101 | + window.startY = e.clientY; |
| 102 | + window.selectedElement = this.parentElement; |
| 103 | + window.selectedElement.classList.add('is-dragging'); |
| 104 | + }); |
| 105 | + }); |
| 106 | + } catch (e) { |
| 107 | + error = e.message; |
| 108 | + console.error('Error loading canvas:', e); |
| 109 | + } |
| 110 | + } |
| 111 | +
|
| 112 | + onMount(() => { |
| 113 | + // Set body ID and styles for canvas.js |
| 114 | + document.body.id = 'home'; |
| 115 | + document.body.style.setProperty('--scale', '1'); |
| 116 | + document.body.style.setProperty('--pan-x', '0px'); |
| 117 | + document.body.style.setProperty('--pan-y', '0px'); |
| 118 | + }); |
| 119 | +
|
| 120 | + // Load canvas whenever the data changes |
| 121 | + $effect(() => { |
| 122 | + if (data.canvasFile) { |
| 123 | + loadCanvas(data.canvasFile); |
| 124 | + } |
| 125 | + }); |
| 126 | +</script> |
| 127 | + |
| 128 | +<svelte:head> |
| 129 | + <title>{data.canvasTitle} - JSON Canvas Viewer</title> |
| 130 | +</svelte:head> |
| 131 | + |
| 132 | +<nav id="canvas-nav"> |
| 133 | + {#each canvases as canvas} |
| 134 | + <a |
| 135 | + href="{base}/{canvas.name}" |
| 136 | + class="canvas-link" |
| 137 | + class:active={data.canvasName === canvas.name} |
| 138 | + > |
| 139 | + {canvas.title} |
| 140 | + </a> |
| 141 | + {/each} |
| 142 | +</nav> |
| 143 | + |
| 144 | +<div id="container"> |
| 145 | + <div id="canvas-container"> |
| 146 | + <svg id="canvas-edges" preserveAspectRatio="none"> |
| 147 | + <defs> |
| 148 | + <marker id="arrowhead" viewBox="0 0 10 10" refX="0" refY="5" markerWidth="6" markerHeight="6" orient="auto"> |
| 149 | + <path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor"/> |
| 150 | + </marker> |
| 151 | + </defs> |
| 152 | + <g id="edge-paths"></g> |
| 153 | + </svg> |
| 154 | + |
| 155 | + <div id="canvas-nodes"> |
| 156 | + {#if error} |
| 157 | + <div style="padding: 20px; color: red;"> |
| 158 | + Error: {error} |
| 159 | + </div> |
| 160 | + {:else if canvasData} |
| 161 | + {#each canvasData.nodes as node} |
| 162 | + <node |
| 163 | + id={node.id} |
| 164 | + class="node {node.type === 'file' ? 'node-link' : 'node-' + node.type}" |
| 165 | + data-node-type={node.type} |
| 166 | + style="left: {node.x}px; top: {node.y}px; width: {node.width}px; height: {node.height}px;" |
| 167 | + > |
| 168 | + <div class="node-name"> |
| 169 | + {#if node.type === 'file'} |
| 170 | + {node.file.replace(/\.md$/, '')} |
| 171 | + {/if} |
| 172 | + </div> |
| 173 | + |
| 174 | + {#if node.type === 'text'} |
| 175 | + <div class="node-text-content"> |
| 176 | + {@html node.text || ''} |
| 177 | + </div> |
| 178 | + {:else if node.type === 'file'} |
| 179 | + {#if fileContents[node.id]} |
| 180 | + <div class="node-text-content"> |
| 181 | + <h1>{node.file.replace(/\.md$/, '')}</h1> |
| 182 | + <Markdown md={fileContents[node.id]} {plugins} /> |
| 183 | + </div> |
| 184 | + {:else} |
| 185 | + <div class="node-text-content"> |
| 186 | + <p style="color: gray;">Loading {node.file}...</p> |
| 187 | + </div> |
| 188 | + {/if} |
| 189 | + {:else if node.type === 'link'} |
| 190 | + <a href={node.url} target="_blank" rel="noopener noreferrer"> |
| 191 | + {node.url} |
| 192 | + </a> |
| 193 | + {/if} |
| 194 | + </node> |
| 195 | + {/each} |
| 196 | + {:else} |
| 197 | + <div style="padding: 20px;">Loading canvas...</div> |
| 198 | + {/if} |
| 199 | + </div> |
| 200 | + |
| 201 | + <div id="output" class="theme-dark hidden"> |
| 202 | + <div class="code-header"> |
| 203 | + <span class="language">JSON Canvas</span> |
| 204 | + <span class="close-output">×</span> |
| 205 | + </div> |
| 206 | + <div id="output-code"> |
| 207 | + <pre><code class="language-json" id="positionsOutput"></code></pre> |
| 208 | + </div> |
| 209 | + <div class="code-footer"> |
| 210 | + <button class="button-copy">Copy code</button> |
| 211 | + <button class="button-download">Download file</button> |
| 212 | + </div> |
| 213 | + </div> |
| 214 | + |
| 215 | + <div id="controls"> |
| 216 | + <div id="zoom-controls"> |
| 217 | + <button id="toggle-output">Toggle output</button> |
| 218 | + <button id="zoom-out">Zoom out</button> |
| 219 | + <button id="zoom-in">Zoom in</button> |
| 220 | + <button id="zoom-reset">Reset</button> |
| 221 | + </div> |
| 222 | + </div> |
| 223 | + </div> |
| 224 | +</div> |
| 225 | + |
| 226 | +<style> |
| 227 | + :global(body) { |
| 228 | + margin: 0; |
| 229 | + padding: 0; |
| 230 | + } |
| 231 | +
|
| 232 | + :global(.node-text-content) { |
| 233 | + overflow-y: auto; |
| 234 | + overflow-x: hidden; |
| 235 | + padding: 0 0.5rem 0 1rem; |
| 236 | + height: 100%; |
| 237 | + box-sizing: border-box; |
| 238 | + position: relative; |
| 239 | + } |
| 240 | +
|
| 241 | + :global(.node-text-content > *:first-child) { |
| 242 | + margin-top: 1rem; |
| 243 | + } |
| 244 | +
|
| 245 | + :global(.node-text-content > *:last-child) { |
| 246 | + margin-bottom: 1rem; |
| 247 | + } |
| 248 | +
|
| 249 | + :global(.node) { |
| 250 | + overflow: hidden; |
| 251 | + display: flex; |
| 252 | + flex-direction: column; |
| 253 | + } |
| 254 | +
|
| 255 | + :global(.node-text) { |
| 256 | + overflow: visible; |
| 257 | + } |
| 258 | +
|
| 259 | + :global(#canvas-edges path) { |
| 260 | + stroke: var(--color-ui-3, #5E0641); |
| 261 | + stroke-width: 2px; |
| 262 | + fill: none; |
| 263 | + } |
| 264 | +
|
| 265 | + :global(#arrowhead polygon) { |
| 266 | + fill: var(--color-ui-3, #5E0641); |
| 267 | + } |
| 268 | +
|
| 269 | + #canvas-nav { |
| 270 | + position: fixed; |
| 271 | + top: 1rem; |
| 272 | + left: 1rem; |
| 273 | + z-index: 200; |
| 274 | + display: flex; |
| 275 | + gap: 0.5rem; |
| 276 | + background: var(--color-bg-1); |
| 277 | + border: 1px solid var(--color-ui-1); |
| 278 | + border-radius: 8px; |
| 279 | + padding: 0.5rem; |
| 280 | + box-shadow: 0 2px 8px rgba(0,0,0,0.1); |
| 281 | + } |
| 282 | +
|
| 283 | + .canvas-link { |
| 284 | + padding: 0.5rem 1rem; |
| 285 | + text-decoration: none; |
| 286 | + color: var(--color-tx-1); |
| 287 | + border-radius: 6px; |
| 288 | + font-weight: 500; |
| 289 | + transition: all 150ms; |
| 290 | + } |
| 291 | +
|
| 292 | + .canvas-link:hover { |
| 293 | + background: var(--color-bg-2); |
| 294 | + } |
| 295 | +
|
| 296 | + .canvas-link.active { |
| 297 | + background: var(--color-ui-3); |
| 298 | + color: var(--color-bg-1); |
| 299 | + } |
| 300 | +</style> |
0 commit comments