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> = 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 { 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 { 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 = {} 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 = new Map() export function renderInlineComponent( definition: VueComponentDefinition, target: HTMLElement, props: Record = {}, 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() } } }