* feat(code): isolate Design JSX execution - Transform JSX with the existing Sucrase dependency and execute it in a disposable opaque-origin iframe worker - Block ambient network capabilities and enforce source, timeout, output, depth, and element limits - Validate that only bounded plain structured data returns to the application * feat(code): add editable Design JSX - Add a lazy CodeMirror editor with JSX syntax support, completion, diagnostics, and bounded scrolling - Convert validated sandbox output into trusted Design JSX helpers before rendering - Apply or insert JSX as one undoable graph transaction while preserving dirty drafts * feat(code): localize JSX editor actions - Add translated-message fallbacks for editing, applying, inserting, and draft state - Document the editable JSX workflow in the unreleased changelog * fix(code): satisfy typed sandbox validation - Keep the sandbox document terminator literal and preserve optional selection handling under type-aware lint * test(code): resolve sandbox probes through app aliases * fix(code): bound sandbox results before cloning - Enforce string, array, object, depth, element, and byte limits inside the disposable worker - Retain host-side validation as a second structured-data boundary * fix(code): keep JSX and HTML editing modes separate - Switch generated Tailwind output back to OpenPencil JSX before editing - Close the JSX editor when opening HTML/CSS import and avoid stacking both editors * feat(code): support authored Design JSX programs - Allow local constants, function components, arrays, conditionals, fragments, and multiple roots - Preserve replacement positions for multiple selected roots and reject mixed-parent or locked selections - Cover multi-root undo, redo, and all-or-nothing rollback * feat(code): add explicit JSX view mode - Let authors leave CodeMirror without applying a draft - Keep the editable surface and HTML/CSS importer mutually exclusive * feat(code): diagnose unknown JSX vocabulary - Warn on OpenPencil elements and properties that are absent from the canonical schema - Surface diagnostics inline through CodeMirror lint markers * test(code): accept WebKit isolation diagnostics - Cover the sandbox architecture against Playwright WebKit - Accept engine-specific wording while preserving the same unavailable-window assertion * refactor(code): consolidate Design JSX vocabulary - Drive renderer warnings, completion, and diagnostics from one supported-property schema - Recognize locally declared components and add focused schema and transform tests - Replace CodeMirror basicSetup with an explicit feature set and remove the umbrella package * fix(code): support Design JSX variable helpers * refactor(code): unify JSX sandbox validation * fix(code): account for complete sandbox output * fix(code): preserve locked JSX descendants * fix(code): preserve JSX sibling order * fix(code): recompute JSX parent layouts * feat(code): add live code previews * test(code): centralize graph assertions * fix(code): harden live preview sessions * docs: describe live code editing * fix(code): update editor accessibility labels * fix(code): use theme-aware error colors * fix(dev): stop Vite disconnect error loops * fix(code): clarify live preview status * fix(ui): avoid tooltip attribute warnings * test(code): assert semantic preview status * refactor(code): remove obsolete editor messages * fix(code): harden preview concurrency and isolation * fix(code): cancel stale reset previews
300 lines
9.4 KiB
Vue
300 lines
9.4 KiB
Vue
<script setup lang="ts">
|
|
import { useClipboard, useDebounceFn } from '@vueuse/core'
|
|
import { computed, defineAsyncComponent, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
|
|
import { tv } from 'tailwind-variants'
|
|
|
|
import { JSX_REFERENCE, selectionToJSX } from '@open-pencil/core/design-jsx'
|
|
import { useI18n, useSceneComputed } from '@open-pencil/vue'
|
|
|
|
import {
|
|
commitDesignJSXSession,
|
|
createDesignJSXEditSession,
|
|
previewDesignJSX,
|
|
resetDesignJSXPreview,
|
|
type DesignJSXEditSession
|
|
} from '@/app/code/live-preview'
|
|
import {
|
|
commitDOMCodeSession,
|
|
createDOMCodeSession,
|
|
previewDOMCode,
|
|
resetDOMCodePreview,
|
|
type DOMCodeSession
|
|
} from '@/app/code/dom-preview'
|
|
import { starterSourceFor, type CodeSource } from '@/app/code/templates'
|
|
import { useEditorStore } from '@/app/editor/active-store'
|
|
import AppSelect from '@/components/ui/AppSelect.vue'
|
|
import AppTextButton from '@/components/ui/AppTextButton.vue'
|
|
import Tip from '@/components/ui/Tip.vue'
|
|
import statusTheme from '@/theme/status'
|
|
|
|
const CodeEditor = defineAsyncComponent(() => import('@/components/code-editor/CodeEditor.vue'))
|
|
|
|
const { active = true } = defineProps<{ active?: boolean }>()
|
|
const store = useEditorStore()
|
|
const editorActive = computed(() => active)
|
|
const { dialogs } = useI18n()
|
|
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
|
|
const { copy: copyReference, copied: copiedReference } = useClipboard({ copiedDuring: 2000 })
|
|
const source = ref<CodeSource>('design-jsx')
|
|
const draft = ref('')
|
|
const baseline = ref('')
|
|
const status = ref<'idle' | 'updating' | 'updated' | 'error'>('idle')
|
|
const error = ref('')
|
|
const designSession = shallowRef<DesignJSXEditSession | null>(null)
|
|
let domSession: DOMCodeSession | null = null
|
|
let previewQueue = Promise.resolve()
|
|
let pendingPreview: Promise<void> | undefined
|
|
let commitPromise: Promise<void> | undefined
|
|
let updateVersion = 0
|
|
let disposing = false
|
|
|
|
const generatedJSX = useSceneComputed(() => {
|
|
if (!editorActive.value || source.value === 'html-css' || designSession.value) return ''
|
|
void store.state.sceneVersion
|
|
const ids = [...store.state.selectedIds]
|
|
if (ids.length === 0) return starterSourceFor(source.value)
|
|
return selectionToJSX(
|
|
ids,
|
|
store.graph,
|
|
source.value === 'tailwind-jsx' ? 'tailwind' : 'openpencil'
|
|
)
|
|
})
|
|
|
|
const sourceOptions = computed(() => [
|
|
{ value: 'design-jsx' as const, label: dialogs.value.codeSourceDesignJSX },
|
|
{ value: 'tailwind-jsx' as const, label: dialogs.value.codeSourceTailwindJSX },
|
|
{ value: 'html-css' as const, label: dialogs.value.codeSourceHTMLCSS }
|
|
])
|
|
const readOnly = computed(() => source.value === 'tailwind-jsx')
|
|
const dirty = computed(() => draft.value !== baseline.value)
|
|
const editorLabel = computed(() =>
|
|
source.value === 'html-css'
|
|
? dialogs.value.codeEditorHTMLCSSLabel
|
|
: dialogs.value.codeEditorDesignLabel
|
|
)
|
|
const statusTone = computed(() => {
|
|
if (status.value === 'error') return 'error'
|
|
if (status.value === 'updated') return 'success'
|
|
return 'neutral'
|
|
})
|
|
const statusStyles = computed(() => tv(statusTheme)({ tone: statusTone.value }))
|
|
|
|
const statusText = computed(() => {
|
|
if (status.value === 'updating') return dialogs.value.codeUpdating
|
|
if (status.value === 'error') return dialogs.value.codePreviewFailed
|
|
if (dirty.value) return dialogs.value.codeUpdatedLive
|
|
return dialogs.value.jsxUpToDate
|
|
})
|
|
|
|
function beginDesignSession(): DesignJSXEditSession | null {
|
|
if (designSession.value) return designSession.value
|
|
const result = createDesignJSXEditSession(store)
|
|
if (!result.ok) {
|
|
status.value = 'error'
|
|
error.value = result.error
|
|
return null
|
|
}
|
|
designSession.value = result.session
|
|
return result.session
|
|
}
|
|
|
|
function beginDOMSession(): DOMCodeSession {
|
|
domSession ??= createDOMCodeSession(store)
|
|
return domSession
|
|
}
|
|
|
|
async function commitCurrentSession(): Promise<void> {
|
|
if (commitPromise) return commitPromise
|
|
const operation = (async () => {
|
|
await pendingPreview
|
|
await previewQueue
|
|
updateVersion += 1
|
|
const design = designSession.value
|
|
const dom = domSession
|
|
designSession.value = null
|
|
domSession = null
|
|
if (design) commitDesignJSXSession(store, design)
|
|
if (dom) commitDOMCodeSession(store, dom)
|
|
pendingPreview = undefined
|
|
})()
|
|
commitPromise = operation
|
|
try {
|
|
await operation
|
|
} finally {
|
|
if (commitPromise === operation) commitPromise = undefined
|
|
}
|
|
}
|
|
|
|
async function runPreview(version: number): Promise<void> {
|
|
if (version !== updateVersion || readOnly.value || !draft.value.trim()) return
|
|
status.value = 'updating'
|
|
error.value = ''
|
|
let result: { ok: true } | { ok: false; error: string }
|
|
if (source.value === 'html-css') {
|
|
result = await previewDOMCode(store, beginDOMSession(), draft.value)
|
|
} else {
|
|
const session = beginDesignSession()
|
|
result = session
|
|
? await previewDesignJSX(store, session, draft.value)
|
|
: { ok: false, error: error.value }
|
|
}
|
|
if (version !== updateVersion) return
|
|
if (!result.ok) {
|
|
status.value = 'error'
|
|
error.value = result.error
|
|
return
|
|
}
|
|
status.value = 'updated'
|
|
}
|
|
|
|
const schedulePreview = useDebounceFn(
|
|
(version: number) => {
|
|
previewQueue = previewQueue.then(() => runPreview(version))
|
|
return previewQueue
|
|
},
|
|
350,
|
|
{ maxWait: 1_000 }
|
|
)
|
|
|
|
function updateDraft(value: string): void {
|
|
draft.value = value
|
|
error.value = ''
|
|
updateVersion += 1
|
|
pendingPreview = schedulePreview(updateVersion)
|
|
}
|
|
|
|
async function resetDraft(): Promise<void> {
|
|
updateVersion += 1
|
|
await pendingPreview
|
|
await previewQueue
|
|
const design = designSession.value
|
|
const dom = domSession
|
|
designSession.value = null
|
|
domSession = null
|
|
if (design) resetDesignJSXPreview(store, design)
|
|
if (dom) resetDOMCodePreview(store, dom)
|
|
pendingPreview = undefined
|
|
draft.value = baseline.value
|
|
error.value = ''
|
|
status.value = 'idle'
|
|
}
|
|
|
|
async function changeSource(next: CodeSource): Promise<void> {
|
|
if (next === source.value) return
|
|
await commitCurrentSession()
|
|
source.value = next
|
|
const initial = next === 'html-css' ? starterSourceFor(next) : generatedFor(next)
|
|
baseline.value = initial
|
|
draft.value = initial
|
|
error.value = ''
|
|
status.value = 'idle'
|
|
}
|
|
|
|
function generatedFor(next: Exclude<CodeSource, 'html-css'>): string {
|
|
const ids = [...store.state.selectedIds]
|
|
if (ids.length === 0) return starterSourceFor(next)
|
|
return selectionToJSX(ids, store.graph, next === 'tailwind-jsx' ? 'tailwind' : 'openpencil')
|
|
}
|
|
|
|
watch(
|
|
generatedJSX,
|
|
(value) => {
|
|
if (source.value === 'html-css' || designSession.value || dirty.value) return
|
|
baseline.value = value
|
|
draft.value = value
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
onBeforeUnmount(() => {
|
|
disposing = true
|
|
void commitCurrentSession()
|
|
})
|
|
|
|
watch(
|
|
() => editorActive.value,
|
|
(value) => {
|
|
if (!value && !disposing) void commitCurrentSession()
|
|
}
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<div data-test-id="code-panel-root" class="flex min-h-0 flex-1 flex-col">
|
|
<header class="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2">
|
|
<AppSelect
|
|
:model-value="source"
|
|
:options="sourceOptions"
|
|
:label="dialogs.codeSource"
|
|
data-test-id="code-panel-source"
|
|
:ui="{ trigger: 'h-7 min-w-0 flex-1 text-[11px]' }"
|
|
@update:model-value="changeSource"
|
|
/>
|
|
<Tip v-if="source !== 'html-css'" :label="dialogs.copyJSXReference">
|
|
<AppTextButton
|
|
data-test-id="code-panel-copy-ref"
|
|
:ui="{ base: 'rounded p-1.5 text-[11px] hover:bg-hover' }"
|
|
@click="copyReference(JSX_REFERENCE)"
|
|
>
|
|
<icon-lucide-check v-if="copiedReference" class="size-3 text-[var(--color-success)]" />
|
|
<icon-lucide-book-open v-else class="size-3" />
|
|
</AppTextButton>
|
|
</Tip>
|
|
</header>
|
|
|
|
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
<CodeEditor
|
|
:model-value="draft"
|
|
:language="source"
|
|
:read-only="readOnly"
|
|
:label="editorLabel"
|
|
@update:model-value="updateDraft"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
v-if="error"
|
|
role="alert"
|
|
data-test-id="code-panel-error"
|
|
class="shrink-0 border-t border-[var(--color-error-border)] bg-[var(--color-error-bg)] px-3 py-2 text-[11px] leading-snug text-[var(--color-error)]"
|
|
>
|
|
{{ error }}
|
|
</div>
|
|
|
|
<footer
|
|
class="flex shrink-0 items-center justify-between gap-2 border-t border-border px-3 py-2"
|
|
>
|
|
<span
|
|
data-test-id="code-panel-status"
|
|
class="min-w-0 truncate"
|
|
:data-tone="statusTone"
|
|
:class="statusStyles.text()"
|
|
>
|
|
{{ readOnly ? dialogs.codeGeneratedReadOnly : statusText }}
|
|
</span>
|
|
<div class="flex items-center gap-1">
|
|
<AppTextButton
|
|
v-if="dirty && !readOnly"
|
|
data-test-id="code-panel-reset"
|
|
:ui="{
|
|
base: 'flex items-center gap-1 rounded px-2 py-1 text-[11px] hover:bg-hover'
|
|
}"
|
|
@click="resetDraft"
|
|
>
|
|
<icon-lucide-rotate-ccw class="size-3" />
|
|
{{ dialogs.codeReset }}
|
|
</AppTextButton>
|
|
<AppTextButton
|
|
data-test-id="code-panel-copy"
|
|
:ui="{ base: 'flex items-center gap-1 rounded px-2 py-1 text-[11px] hover:bg-hover' }"
|
|
@click="copy(draft)"
|
|
>
|
|
<icon-lucide-check v-if="copied" class="size-3 text-[var(--color-success)]" />
|
|
<icon-lucide-copy v-else class="size-3" />
|
|
{{ copied ? dialogs.copied : dialogs.copy }}
|
|
</AppTextButton>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</template>
|