feat: Add canvas snapshots to save and restore full canvas state

Implements save/restore system that captures HTML base, injected CSS,
executed scripts, and floating Vue windows with their full definitions.
Adds 4 MCP tools, backend CRUD API, Pinia store, and script logger.
This commit is contained in:
2026-02-14 23:08:33 -06:00
parent 5fd57ba70f
commit 3f15aa590b
13 changed files with 641 additions and 119 deletions

View File

@@ -0,0 +1,284 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useWindowsStore } from './windows'
import {
getScriptLog,
clearScriptLog,
getWindowDefinitions,
clearWindowDefinitions,
type WindowDefinitionEntry
} from '../services/tools/handlers/canvasHandlers'
const API_URL = ''
// ============================================
// TYPES
// ============================================
export interface SnapshotWindow {
id: string
title: string
x: number
y: number
width: number
height: number
zIndex: number
source: 'db' | 'inline'
componentId?: string
definition?: {
id: string
name: string
template: string
setup?: string
style?: string
imports?: string[]
props?: string[]
}
componentProps: Record<string, any>
}
export interface SnapshotCSSBlock {
id: string
css: string
}
export interface CanvasSnapshot {
id: string
name: string
created: number
baseHTML: string | null
baseScripts: string[]
cssBlocks: SnapshotCSSBlock[]
windows: SnapshotWindow[]
}
export interface SnapshotSummary {
id: string
name: string
thumbnail: string | null
created_at: number
}
// ============================================
// STORE
// ============================================
export const useSnapshotsStore = defineStore('snapshots', () => {
const snapshots = ref<SnapshotSummary[]>([])
// ---- Capture current canvas state ----
function captureState(name: string): CanvasSnapshot {
const windowsStore = useWindowsStore()
const winDefs = getWindowDefinitions()
// 1. Capture base HTML (excluding floating window containers)
const container = document.getElementById('canvas-content')
let baseHTML: string | null = null
if (container) {
const clone = container.cloneNode(true) as HTMLElement
// Remove dynamic component wrappers (floating windows)
clone.querySelectorAll('.dynamic-component-wrapper').forEach(el => el.remove())
const html = clone.innerHTML.trim()
baseHTML = html || null
}
// 2. Capture scripts
const baseScripts = getScriptLog()
// 3. Capture CSS blocks
const cssBlocks: SnapshotCSSBlock[] = []
document.querySelectorAll('style[id^="canvas-css-"]').forEach(style => {
const id = style.id.replace('canvas-css-', '')
cssBlocks.push({ id, css: style.textContent || '' })
})
// 4. Capture windows with definitions
const windows: SnapshotWindow[] = []
for (const win of windowsStore.windowsList) {
const entry: WindowDefinitionEntry | undefined = winDefs.get(win.id)
const snapWin: SnapshotWindow = {
id: win.id,
title: win.title,
x: win.x,
y: win.y,
width: win.width,
height: win.height,
zIndex: win.zIndex,
source: entry?.source || 'inline',
componentProps: entry?.componentProps || {}
}
if (entry?.source === 'db' && entry.componentId) {
snapWin.componentId = entry.componentId
}
if (entry?.definition) {
snapWin.definition = {
id: entry.definition.id,
name: entry.definition.name,
template: entry.definition.template,
setup: entry.definition.setup,
style: entry.definition.style,
imports: entry.definition.imports,
props: entry.definition.props
}
}
windows.push(snapWin)
}
return {
id: `snap-${Date.now()}`,
name,
created: Date.now(),
baseHTML,
baseScripts,
cssBlocks,
windows
}
}
// ---- Save snapshot to backend ----
async function save(name: string): Promise<string> {
const snapshot = captureState(name)
const res = await fetch(`${API_URL}/api/snapshots`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: snapshot.id,
name: snapshot.name,
data: snapshot,
created_at: snapshot.created
})
})
const result = await res.json()
await list() // refresh list
return result.id
}
// ---- List snapshots ----
async function list(): Promise<SnapshotSummary[]> {
const res = await fetch(`${API_URL}/api/snapshots`)
const data = await res.json()
snapshots.value = data
return data
}
// ---- Load full snapshot ----
async function load(id: string): Promise<CanvasSnapshot> {
const res = await fetch(`${API_URL}/api/snapshots/${id}`)
const row = await res.json()
return row.data
}
// ---- Remove snapshot ----
async function remove(id: string): Promise<void> {
await fetch(`${API_URL}/api/snapshots/${id}`, { method: 'DELETE' })
await list()
}
// ---- Restore a snapshot ----
async function restore(id: string): Promise<void> {
const snapshot = await load(id)
const windowsStore = useWindowsStore()
// 1. Close all current windows
for (const win of windowsStore.windowsList) {
const container = document.getElementById(`inline-${win.id}`)
if (container) container.remove()
document.getElementById(`style-${win.id}`)?.remove()
}
windowsStore.clear()
clearWindowDefinitions()
clearScriptLog()
// 2. Remove existing canvas CSS
document.querySelectorAll('style[id^="canvas-css-"]').forEach(el => el.remove())
// 3. Restore base HTML
const canvasEl = document.getElementById('canvas-content')
if (canvasEl) {
if (snapshot.baseHTML) {
canvasEl.innerHTML = snapshot.baseHTML
// Execute inline scripts
canvasEl.querySelectorAll('script').forEach(oldScript => {
const newScript = document.createElement('script')
Array.from(oldScript.attributes).forEach(attr => {
newScript.setAttribute(attr.name, attr.value)
})
newScript.textContent = oldScript.textContent
oldScript.parentNode?.replaceChild(newScript, oldScript)
})
} else {
canvasEl.innerHTML = ''
}
}
// 4. Restore CSS blocks
for (const block of snapshot.cssBlocks) {
const styleEl = document.createElement('style')
styleEl.id = `canvas-css-${block.id}`
document.head.appendChild(styleEl)
styleEl.textContent = block.css
}
// 5. Re-execute scripts
if (canvasEl && snapshot.baseScripts.length > 0) {
for (const code of snapshot.baseScripts) {
try {
const context = {
canvas: canvasEl,
windows: windowsStore.windowsList,
getWindow: (wid: string) => document.querySelector(`[data-window-id="${wid}"]`),
$: (selector: string) => canvasEl.querySelector(selector),
$$: (selector: string) => canvasEl.querySelectorAll(selector)
}
const fn = new Function('ctx', `with(ctx) { ${code} }`)
fn(context)
} catch (e) {
console.warn('[Snapshot] Script replay error:', e)
}
}
}
// 6. Restore windows — dynamically import to avoid circular deps
const { renderInlineComponent } = await import('../services/dynamicComponents')
if (canvasEl) {
for (const win of snapshot.windows) {
if (!win.definition) continue
const definition = {
id: win.id,
name: win.definition.name,
template: win.definition.template,
setup: win.definition.setup,
style: win.definition.style,
props: win.definition.props,
imports: win.definition.imports || ['ref', 'reactive', 'computed']
}
const layout = { x: win.x, y: win.y, width: win.width, height: win.height }
renderInlineComponent(definition, canvasEl, win.componentProps || {}, true, layout)
// Re-track definition
getWindowDefinitions().set(win.id, {
source: win.source,
componentId: win.componentId,
definition,
componentProps: win.componentProps || {}
})
}
}
}
return {
snapshots,
captureState,
save,
list,
load,
remove,
restore
}
})