feat(app): open DOM/CSS documents

This commit is contained in:
Danila Poyarkov 2026-06-06 07:22:19 +03:00
parent 2c66336c58
commit 05980797e3
10 changed files with 212 additions and 11 deletions

View file

@ -5,6 +5,7 @@ const browserDistPath = '../dist/browser.js'
const dist = await import(distPath)
const browserDist = await import(browserDistPath)
const browserHTMLToSceneGraph: typeof DomCSS.browserHTMLToSceneGraph = dist.browserHTMLToSceneGraph
const browserTailwindJSXToDesignDocument: typeof DomCSS.browserTailwindJSXToDesignDocument =
dist.browserTailwindJSXToDesignDocument
const compileTailwindCSS: typeof DomCSS.compileTailwindCSS = dist.compileTailwindCSS
@ -56,10 +57,18 @@ if (jsxDocument.children[0]?.type !== 'element') {
throw new Error('Expected built JSX helpers to produce DesignDOM elements')
}
if (typeof browserHTMLToSceneGraph !== 'function') {
throw new TypeError('Expected built browser HTML helper to be exported')
}
if (typeof browserTailwindJSXToDesignDocument !== 'function') {
throw new TypeError('Expected built browser JSX helper to be exported')
}
if (typeof browserDist.browserHTMLToSceneGraph !== 'function') {
throw new TypeError('Expected built browser subpath HTML helper to be exported')
}
if (typeof browserDist.browserTailwindJSXToDesignDocument !== 'function') {
throw new TypeError('Expected built browser subpath helper to be exported')
}

View file

@ -14,6 +14,17 @@ export interface BrowserToDesignDocumentOptions extends BrowserCSSRuntimeOptions
compute?: CSSComputeOptions
}
export type BrowserHTMLToDesignDocumentOptions = BrowserToDesignDocumentOptions
export interface BrowserHTMLToSceneGraphOptions
extends BrowserToDesignDocumentOptions, ToSceneGraphOptions {}
export interface BrowserTailwindHTMLToDesignDocumentOptions
extends Omit<BrowserHTMLToDesignDocumentOptions, 'cssText'>, CompileTailwindCSSOptions {}
export interface BrowserTailwindHTMLToSceneGraphOptions
extends BrowserTailwindHTMLToDesignDocumentOptions, ToSceneGraphOptions {}
export interface BrowserToSceneGraphOptions
extends BrowserToDesignDocumentOptions, ToSceneGraphOptions {}
@ -27,6 +38,41 @@ function createRuntime(options: BrowserCSSRuntimeOptions) {
return createBrowserCSSRuntime({ sandbox: 'iframe', ...options })
}
export async function browserHTMLToDesignDocument(
html: string,
options: BrowserHTMLToDesignDocumentOptions = {}
): Promise<DesignDocument> {
const runtime = createRuntime(options)
const document = runtime.parseHTML(html)
return runtime.computeStyles(document, options.cssText, options.compute)
}
export async function browserHTMLToSceneGraph(
html: string,
options: BrowserHTMLToSceneGraphOptions = {}
): Promise<SceneGraph> {
const document = await browserHTMLToDesignDocument(html, options)
return designDocumentToSceneGraph(document, options)
}
export async function browserTailwindHTMLToDesignDocument(
html: string,
candidates: string | Iterable<string>,
options: BrowserTailwindHTMLToDesignDocumentOptions = {}
): Promise<DesignDocument> {
const cssText = await compileTailwindCSS(candidates, options)
return browserHTMLToDesignDocument(html, { ...options, cssText })
}
export async function browserTailwindHTMLToSceneGraph(
html: string,
candidates: string | Iterable<string>,
options: BrowserTailwindHTMLToSceneGraphOptions = {}
): Promise<SceneGraph> {
const document = await browserTailwindHTMLToDesignDocument(html, candidates, options)
return designDocumentToSceneGraph(document, options)
}
export async function browserJSXToDesignDocument(
input: JSXChild,
options: BrowserToDesignDocumentOptions = {}

View file

@ -10,8 +10,12 @@ export { designDocumentToSceneGraph } from './to-scene-graph'
export { sceneGraphToDesignDocument } from './from-scene-graph'
export { compileTailwindCSS } from './tailwind'
export {
browserHTMLToDesignDocument,
browserHTMLToSceneGraph,
browserJSXToDesignDocument,
browserJSXToSceneGraph,
browserTailwindHTMLToDesignDocument,
browserTailwindHTMLToSceneGraph,
browserTailwindJSXToDesignDocument,
browserTailwindJSXToSceneGraph
} from './browser'
@ -45,6 +49,10 @@ export type {
TailwindJSXToSceneGraphOptions
} from './jsx/runtime'
export type {
BrowserHTMLToDesignDocumentOptions,
BrowserHTMLToSceneGraphOptions,
BrowserTailwindHTMLToDesignDocumentOptions,
BrowserTailwindHTMLToSceneGraphOptions,
BrowserTailwindToDesignDocumentOptions,
BrowserTailwindToSceneGraphOptions,
BrowserToDesignDocumentOptions,

View file

@ -93,26 +93,25 @@ function styleToRecord(style: CSSStyleDeclaration): Record<string, string> | und
}
function domNodeToDesignNode(node: Node): DesignNode | null {
const view = node.ownerDocument?.defaultView
if (!view) return null
if (node.nodeType === view.Node.TEXT_NODE) {
if (node.nodeType === 3) {
const text = node.textContent ?? ''
return text.length > 0 ? { type: 'text', text } : null
}
if (node.nodeType !== view.Node.ELEMENT_NODE || !(node instanceof view.Element)) return null
if (node.nodeType !== 1) return null
const children = Array.from(node.childNodes)
const element = node as Element
const children = Array.from(element.childNodes)
.map(domNodeToDesignNode)
.filter((child): child is DesignNode => child !== null)
const style = 'style' in element ? (element.style as CSSStyleDeclaration) : undefined
return {
type: 'element',
tagName: node.tagName.toLowerCase(),
attrs: attributesToRecord(node),
tagName: element.tagName.toLowerCase(),
attrs: attributesToRecord(element),
children,
inlineStyle: node instanceof view.HTMLElement ? styleToRecord(node.style) : undefined
inlineStyle: style ? styleToRecord(style) : undefined
}
}

View file

@ -2,6 +2,7 @@ import type { Editor, EditorState } from '@open-pencil/core/editor'
import { prefetchFigmaSchema } from '@open-pencil/core/kiwi'
import { createDocumentViewportActions, downloadBlob } from '@/app/document/io/browser'
import { createDOMOpenActions } from '@/app/document/io/dom'
import { createOpenActions, createReloadActions } from '@/app/document/io/read'
import { createDocumentSourceActions, createDocumentSourceState } from '@/app/document/io/source'
import type { ViewportSize } from '@/app/document/io/types'
@ -55,6 +56,12 @@ export function createDocumentIOActions(
setDocumentSource: sourceActions.setDocumentSource,
fitCurrentPageToViewport
})
const { openDOMFile } = createDOMOpenActions({
editor,
state,
setDocumentSource: sourceActions.setDocumentSource,
fitCurrentPageToViewport
})
return {
downloadBlob,
@ -65,6 +72,7 @@ export function createDocumentIOActions(
startWatchingCurrentFile: sourceActions.startWatchingCurrentFile,
disposeDocumentIO: sourceActions.disposeDocumentIO,
openFigFile,
openDOMFile,
saveFigFile: sourceActions.saveFigFile,
saveFigFileAs: sourceActions.saveFigFileAs
}

View file

@ -0,0 +1,63 @@
import type { Editor, EditorState } from '@open-pencil/core/editor'
import { browserHTMLToSceneGraph } from '@open-pencil/dom-css/browser'
import { yieldToUI } from '@/app/document/io/browser'
import { applyImportedDocument } from '@/app/document/io/imported-document'
import { toast } from '@/app/shell/ui'
type OpenDOMDocumentState = EditorState & {
documentName: string
loading: boolean
}
type OpenDOMFileOptions = {
editor: Editor
state: OpenDOMDocumentState
setDocumentSource: (
fileName: string,
sourceFormat: string,
handle?: FileSystemFileHandle,
path?: string
) => void
fitCurrentPageToViewport: () => Promise<void>
}
type DOMImportOptions = {
cssText?: string
handle?: FileSystemFileHandle
path?: string
}
function documentNameFor(file: File): string {
return file.name.replace(/\.(html?|xhtml)$/i, '')
}
export function createDOMOpenActions({
editor,
state,
setDocumentSource,
fitCurrentPageToViewport
}: OpenDOMFileOptions) {
async function openDOMFile(file: File, options: DOMImportOptions = {}) {
try {
state.loading = true
await yieldToUI()
const html = await file.text()
const pageName = documentNameFor(file)
const graph = await browserHTMLToSceneGraph(html, { cssText: options.cssText, pageName })
await yieldToUI()
await applyImportedDocument(editor, graph)
state.documentName = pageName
setDocumentSource(file.name, 'html', options.handle, options.path)
await fitCurrentPageToViewport()
editor.requestRender()
} catch (e) {
console.error('Failed to open DOM/CSS file:', e)
toast.error(`Failed to open DOM/CSS file: ${e instanceof Error ? e.message : String(e)}`)
} finally {
state.loading = false
}
}
return { openDOMFile }
}

View file

@ -69,6 +69,7 @@ export function createEditorStoreModules(
...pen,
...vectorEdit,
openFigFile: documentIO.openFigFile,
openDOMFile: documentIO.openDOMFile,
setViewportSize: documentIO.setViewportSize,
fitCurrentPageToViewport: documentIO.fitCurrentPageToViewport,
saveFigFile: documentIO.saveFigFile,

View file

@ -5,7 +5,11 @@ import { openFileInNewTab } from '@/app/tabs'
import { isTauri } from '@/app/tauri/env'
import { IS_BROWSER } from '@/constants'
const fileDialog = useFileDialog({ accept: '.fig,.pen', multiple: false, reset: true })
const fileDialog = useFileDialog({
accept: '.fig,.pen,.html,.htm,.xhtml',
multiple: false,
reset: true
})
fileDialog.onChange((files) => {
const file = files?.[0]
@ -31,7 +35,7 @@ export async function readTauriDesignFile(path: string): Promise<File> {
export async function chooseTauriOpenPath(): Promise<string | null> {
const { open } = await import('@tauri-apps/plugin-dialog')
const path = await open({
filters: [{ name: 'Design file', extensions: ['fig', 'pen'] }],
filters: [{ name: 'Design file', extensions: ['fig', 'pen', 'html', 'htm', 'xhtml'] }],
multiple: false
})
return typeof path === 'string' ? path : null
@ -60,6 +64,8 @@ export async function openFileDialog() {
accept: {
'application/octet-stream': ['.fig'],
'application/json': ['.pen'],
'text/html': ['.html', '.htm'],
'application/xhtml+xml': ['.xhtml'],
'text/plain': ['.pen']
}
}

View file

@ -91,6 +91,10 @@ function yieldToUI(): Promise<void> {
})
}
function isDOMImportFile(file: File): boolean {
return /\.(html?|xhtml)$/i.test(file.name)
}
export async function openFileInNewTab(
file: File,
handle?: FileSystemFileHandle,
@ -100,6 +104,11 @@ export async function openFileInNewTab(
const isUntouched =
current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo
const store = isUntouched ? current.store : createTab().store
if (isDOMImportFile(file)) {
await store.openDOMFile(file, { handle, path })
return
}
const documentName = file.name.replace(/\.[^.]+$/i, '')
store.state.documentName = documentName

View file

@ -58,6 +58,33 @@ async function browserRuntimeComputeStyles(
)
}
async function publicBrowserHTMLSceneGraph(page: Page, html: string, cssText: string) {
if (!page.url().startsWith('http://localhost:1420')) {
await page.goto('/')
await page.setContent('<main></main>')
}
return page.evaluate(
async ({ sourceHTML, css, modulePath }) => {
const { browserHTMLToSceneGraph } = await import(modulePath)
const graph = await browserHTMLToSceneGraph(sourceHTML, { cssText: css })
const pageNode = graph.getPages()[0]
const card = pageNode ? graph.getChildren(pageNode.id)[0] : undefined
return card
? {
height: card.height,
itemSpacing: card.itemSpacing,
layoutMode: card.layoutMode,
paddingLeft: card.paddingLeft,
type: card.type,
width: card.width
}
: null
},
{ sourceHTML: html, css: cssText, modulePath: DOM_CSS_BROWSER_MODULE }
)
}
async function publicBrowserSceneGraph(page: Page, classes: string[], cssText: string) {
if (!page.url().startsWith('http://localhost:1420')) {
await page.goto('/')
@ -465,6 +492,31 @@ test.describe('@open-pencil/dom-css browser CSS runtime oracle', () => {
expect(hostWidth).toBe('20px')
})
test('projects HTML through public browser helpers into scene graph', async ({ page }) => {
const css = `
.card {
display: flex;
flex-direction: column;
gap: 12px;
width: 320px;
height: 176px;
padding: 24px;
}
`
const card = await publicBrowserHTMLSceneGraph(
page,
'<article class="card"><h1>OpenPencil</h1></article>',
css
)
expect(card?.type).toBe('FRAME')
expect(card?.width).toBe(320)
expect(card?.height).toBe(176)
expect(card?.layoutMode).toBe('VERTICAL')
expect(card?.itemSpacing).toBe(12)
expect(card?.paddingLeft).toBe(24)
})
test('projects JSX through public browser helpers into scene graph', async ({ page }) => {
const css = await compileTailwindCSS(tailwindCardClasses)
const card = await publicBrowserSceneGraph(page, [...tailwindCardClasses], css)