- Add project_canvas and canvas_components tables for persistent canvas storage - Add ProjectCanvas store with full CRUD operations - Add ProjectCanvasPage for rendering saved canvas with components - Add ProjectsPage for managing canvas list (create, clone, delete) - Add HomePage that loads default canvas or falls back to dynamic canvas - Add toolbar support for displaying canvas as pages with custom icons - Add component usage validation to prevent deletion of components in use - Add MCP tools for canvas management (list, create, update, delete, clone) - Update router with /canvas/:id and /projects routes - Update Toolbar to show dynamic canvas pages from database
324 lines
8.9 KiB
TypeScript
324 lines
8.9 KiB
TypeScript
import {
|
|
ref,
|
|
reactive,
|
|
computed,
|
|
watch,
|
|
watchEffect,
|
|
onMounted,
|
|
onUnmounted,
|
|
nextTick,
|
|
provide,
|
|
inject,
|
|
h,
|
|
render,
|
|
createVNode,
|
|
Suspense,
|
|
type App,
|
|
type Component
|
|
} from 'vue'
|
|
import { setActivePinia, type Pinia } from 'pinia'
|
|
import { useCanvasStore } from '../stores/canvas'
|
|
import { useThemeStore } from '../stores/theme'
|
|
|
|
const API_URL = 'http://localhost:4101'
|
|
|
|
// Tipos
|
|
export interface VueComponentDefinition {
|
|
id: string
|
|
name: string
|
|
template: string
|
|
setup?: string
|
|
style?: string
|
|
props?: string[]
|
|
imports?: string[]
|
|
}
|
|
|
|
// ============================================
|
|
// EVENT BUS
|
|
// ============================================
|
|
|
|
type EventCallback = (...args: any[]) => void
|
|
|
|
class EventBus {
|
|
private events: Map<string, Set<EventCallback>> = new Map()
|
|
|
|
on(event: string, callback: EventCallback) {
|
|
if (!this.events.has(event)) {
|
|
this.events.set(event, new Set())
|
|
}
|
|
this.events.get(event)!.add(callback)
|
|
return () => this.off(event, callback)
|
|
}
|
|
|
|
off(event: string, callback: EventCallback) {
|
|
this.events.get(event)?.delete(callback)
|
|
}
|
|
|
|
emit(event: string, ...args: any[]) {
|
|
this.events.get(event)?.forEach(cb => cb(...args))
|
|
}
|
|
|
|
once(event: string, callback: EventCallback) {
|
|
const wrapper = (...args: any[]) => {
|
|
callback(...args)
|
|
this.off(event, wrapper)
|
|
}
|
|
this.on(event, wrapper)
|
|
}
|
|
}
|
|
|
|
export const eventBus = new EventBus()
|
|
;(window as any).__eventBus = eventBus
|
|
|
|
// ============================================
|
|
// CSS SCOPING
|
|
// ============================================
|
|
|
|
function generateScopeId(id: string): string {
|
|
return 'v-' + id.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8)
|
|
}
|
|
|
|
function scopeCSS(css: string, scopeId: string): string {
|
|
const scopePrefix = `#canvas-content [data-${scopeId}]`
|
|
|
|
return css.replace(
|
|
/([^{}]+)\{([^{}]*)\}/g,
|
|
(_match, selectors: string, rules: string) => {
|
|
const scopedSelectors = selectors
|
|
.split(',')
|
|
.map((selector: string) => {
|
|
selector = selector.trim()
|
|
if (!selector || selector.startsWith('@')) return selector
|
|
return `${scopePrefix} ${selector}`
|
|
})
|
|
.join(', ')
|
|
|
|
return `${scopedSelectors} {${rules}}`
|
|
}
|
|
)
|
|
}
|
|
|
|
function injectScopedStyle(css: string, scopeId: string, componentId: string): void {
|
|
const styleId = `style-${componentId}`
|
|
let styleEl = document.getElementById(styleId)
|
|
|
|
if (!styleEl) {
|
|
styleEl = document.createElement('style')
|
|
styleEl.id = styleId
|
|
document.head.appendChild(styleEl)
|
|
}
|
|
|
|
styleEl.textContent = scopeCSS(css, scopeId)
|
|
}
|
|
|
|
// ============================================
|
|
// COMPONENTS API
|
|
// ============================================
|
|
|
|
export const componentsApi = {
|
|
async getAll(): Promise<VueComponentDefinition[]> {
|
|
const res = await fetch(`${API_URL}/api/components`)
|
|
const data = await res.json()
|
|
return data.map((row: any) => ({
|
|
...row,
|
|
props: JSON.parse(row.props || '[]'),
|
|
imports: JSON.parse(row.imports || '[]')
|
|
}))
|
|
},
|
|
|
|
async getById(id: string): Promise<VueComponentDefinition | null> {
|
|
const res = await fetch(`${API_URL}/api/components/${id}`)
|
|
if (!res.ok) return null
|
|
const row = await res.json()
|
|
return {
|
|
...row,
|
|
props: JSON.parse(row.props || '[]'),
|
|
imports: JSON.parse(row.imports || '[]')
|
|
}
|
|
},
|
|
|
|
async save(component: VueComponentDefinition): Promise<{ success: boolean; id: string }> {
|
|
const res = await fetch(`${API_URL}/api/components`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(component)
|
|
})
|
|
return res.json()
|
|
},
|
|
|
|
async delete(id: string): Promise<{ success: boolean; error?: string; usedBy?: { id: string; name: string }[] }> {
|
|
const res = await fetch(`${API_URL}/api/components/${id}`, { method: 'DELETE' })
|
|
const data = await res.json()
|
|
if (!res.ok) {
|
|
return { success: false, error: data.error || data.message, usedBy: data.usedBy }
|
|
}
|
|
return data
|
|
},
|
|
|
|
async deleteAll(): Promise<{ success: boolean }> {
|
|
const res = await fetch(`${API_URL}/api/components`, { method: 'DELETE' })
|
|
return res.json()
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// VUE EXPORTS & HELPERS
|
|
// ============================================
|
|
|
|
const vueExports = {
|
|
ref, reactive, computed, watch, watchEffect,
|
|
onMounted, onUnmounted, nextTick, provide, inject, h
|
|
}
|
|
|
|
function getCanvasStore() {
|
|
const globalPinia = (window as any).__pinia as Pinia | undefined
|
|
if (globalPinia) setActivePinia(globalPinia)
|
|
return useCanvasStore()
|
|
}
|
|
|
|
function getThemeStore() {
|
|
const globalPinia = (window as any).__pinia as Pinia | undefined
|
|
if (globalPinia) setActivePinia(globalPinia)
|
|
return useThemeStore()
|
|
}
|
|
|
|
const dynamicHelpers = {
|
|
$emit: (event: string, ...args: any[]) => eventBus.emit(event, ...args),
|
|
$on: (event: string, cb: EventCallback) => eventBus.on(event, cb),
|
|
$once: (event: string, cb: EventCallback) => eventBus.once(event, cb),
|
|
$off: (event: string, cb: EventCallback) => eventBus.off(event, cb),
|
|
$fetch: fetch.bind(window),
|
|
$components: {
|
|
load: (id: string) => componentsApi.getById(id),
|
|
list: () => componentsApi.getAll(),
|
|
save: (comp: VueComponentDefinition) => componentsApi.save(comp),
|
|
},
|
|
$theme: {
|
|
getVariable: (name: string) => getComputedStyle(document.documentElement).getPropertyValue(`--${name}`).trim(),
|
|
setVariable: (name: string, value: string) => document.documentElement.style.setProperty(`--${name}`, value),
|
|
getTokens: () => getThemeStore().designTokens,
|
|
getActiveTheme: () => getThemeStore().activeTheme,
|
|
getVariables: () => getThemeStore().currentVariables,
|
|
},
|
|
useCanvasStore: getCanvasStore,
|
|
useThemeStore: getThemeStore,
|
|
$nextTick: nextTick,
|
|
}
|
|
|
|
// ============================================
|
|
// BUILD COMPONENT
|
|
// ============================================
|
|
|
|
export function buildComponent(definition: VueComponentDefinition): Component {
|
|
const imports = definition.imports || ['ref', 'reactive', 'computed']
|
|
|
|
const vueImports: Record<string, any> = {}
|
|
imports.forEach(i => {
|
|
if (i in vueExports) vueImports[i] = (vueExports as any)[i]
|
|
})
|
|
|
|
const isAsync = definition.setup ? /\bawait\b/.test(definition.setup) : false
|
|
|
|
let setupFn: Function | null = null
|
|
if (definition.setup) {
|
|
try {
|
|
const fnBody = isAsync
|
|
? `return (async () => { ${definition.setup} })()`
|
|
: definition.setup
|
|
|
|
setupFn = new Function(
|
|
...Object.keys(vueImports),
|
|
'props',
|
|
'ctx',
|
|
...Object.keys(dynamicHelpers),
|
|
fnBody
|
|
)
|
|
} catch (e) {
|
|
console.error('[DynamicComponent] Error parsing setup:', e)
|
|
}
|
|
}
|
|
|
|
const componentDef: Component = {
|
|
name: definition.name,
|
|
props: definition.props || [],
|
|
template: definition.template
|
|
}
|
|
|
|
const executeSetup = (props: any, ctx: any) => {
|
|
if (!setupFn) return {}
|
|
return setupFn(
|
|
...Object.values(vueImports),
|
|
props,
|
|
ctx,
|
|
...Object.values(dynamicHelpers)
|
|
)
|
|
}
|
|
|
|
if (isAsync) {
|
|
componentDef.async = true
|
|
componentDef.setup = async (props: any, ctx: any) => await executeSetup(props, ctx)
|
|
} else {
|
|
componentDef.setup = executeSetup
|
|
}
|
|
|
|
return componentDef
|
|
}
|
|
|
|
// ============================================
|
|
// RENDER COMPONENT
|
|
// ============================================
|
|
|
|
const renderedContainers: Map<string, HTMLElement> = new Map()
|
|
|
|
export function renderInlineComponent(
|
|
definition: VueComponentDefinition,
|
|
target: HTMLElement,
|
|
props: Record<string, any> = {},
|
|
append: boolean = false
|
|
): { unmount: () => void } {
|
|
const scopeId = generateScopeId(definition.id)
|
|
|
|
const container = document.createElement('div')
|
|
container.id = `inline-${definition.id}`
|
|
container.className = 'dynamic-component-wrapper'
|
|
container.setAttribute(`data-${scopeId}`, '')
|
|
|
|
if (append) {
|
|
target.appendChild(container)
|
|
} else {
|
|
const oldContainer = renderedContainers.get(definition.id)
|
|
if (oldContainer) render(null, oldContainer)
|
|
target.innerHTML = ''
|
|
target.appendChild(container)
|
|
}
|
|
|
|
if (definition.style) {
|
|
injectScopedStyle(definition.style, scopeId, definition.id)
|
|
}
|
|
|
|
const isAsync = definition.setup ? /\bawait\b/.test(definition.setup) : false
|
|
const component = buildComponent(definition)
|
|
|
|
const vnode = isAsync
|
|
? createVNode(Suspense, null, {
|
|
default: () => createVNode(component, props),
|
|
fallback: () => createVNode('div', { class: 'loading' }, 'Loading...')
|
|
})
|
|
: createVNode(component, props)
|
|
|
|
const mainApp = (window as any).__vueApp as App | undefined
|
|
if (mainApp?._context) vnode.appContext = mainApp._context
|
|
|
|
render(vnode, container)
|
|
renderedContainers.set(definition.id, container)
|
|
|
|
return {
|
|
unmount: () => {
|
|
render(null, container)
|
|
renderedContainers.delete(definition.id)
|
|
container.remove()
|
|
document.getElementById(`style-${definition.id}`)?.remove()
|
|
}
|
|
}
|
|
}
|