From 05980797e33ffb57a57c0368ab3e8c6a0364f71a Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 6 Jun 2026 07:22:19 +0300 Subject: [PATCH] feat(app): open DOM/CSS documents --- packages/dom-css/scripts/smoke-dist.ts | 9 ++++ packages/dom-css/src/browser.ts | 46 +++++++++++++++++ packages/dom-css/src/index.ts | 8 +++ packages/dom-css/src/runtime/browser.ts | 17 +++--- src/app/document/io/create.ts | 8 +++ src/app/document/io/dom.ts | 63 +++++++++++++++++++++++ src/app/editor/session/modules.ts | 1 + src/app/shell/menu/files.ts | 10 +++- src/app/tabs/index.ts | 9 ++++ tests/e2e/dom-css/browser-runtime.spec.ts | 52 +++++++++++++++++++ 10 files changed, 212 insertions(+), 11 deletions(-) create mode 100644 src/app/document/io/dom.ts diff --git a/packages/dom-css/scripts/smoke-dist.ts b/packages/dom-css/scripts/smoke-dist.ts index 54d7836a9..0c92f576b 100644 --- a/packages/dom-css/scripts/smoke-dist.ts +++ b/packages/dom-css/scripts/smoke-dist.ts @@ -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') } diff --git a/packages/dom-css/src/browser.ts b/packages/dom-css/src/browser.ts index d9ba262e1..9433df91c 100644 --- a/packages/dom-css/src/browser.ts +++ b/packages/dom-css/src/browser.ts @@ -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, 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 { + 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 { + const document = await browserHTMLToDesignDocument(html, options) + return designDocumentToSceneGraph(document, options) +} + +export async function browserTailwindHTMLToDesignDocument( + html: string, + candidates: string | Iterable, + options: BrowserTailwindHTMLToDesignDocumentOptions = {} +): Promise { + const cssText = await compileTailwindCSS(candidates, options) + return browserHTMLToDesignDocument(html, { ...options, cssText }) +} + +export async function browserTailwindHTMLToSceneGraph( + html: string, + candidates: string | Iterable, + options: BrowserTailwindHTMLToSceneGraphOptions = {} +): Promise { + const document = await browserTailwindHTMLToDesignDocument(html, candidates, options) + return designDocumentToSceneGraph(document, options) +} + export async function browserJSXToDesignDocument( input: JSXChild, options: BrowserToDesignDocumentOptions = {} diff --git a/packages/dom-css/src/index.ts b/packages/dom-css/src/index.ts index 074e0ca9f..096a76ab8 100644 --- a/packages/dom-css/src/index.ts +++ b/packages/dom-css/src/index.ts @@ -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, diff --git a/packages/dom-css/src/runtime/browser.ts b/packages/dom-css/src/runtime/browser.ts index 49668b721..8fc6428ab 100644 --- a/packages/dom-css/src/runtime/browser.ts +++ b/packages/dom-css/src/runtime/browser.ts @@ -93,26 +93,25 @@ function styleToRecord(style: CSSStyleDeclaration): Record | 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 } } diff --git a/src/app/document/io/create.ts b/src/app/document/io/create.ts index 0a4e2626c..24e79a88d 100644 --- a/src/app/document/io/create.ts +++ b/src/app/document/io/create.ts @@ -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 } diff --git a/src/app/document/io/dom.ts b/src/app/document/io/dom.ts new file mode 100644 index 000000000..e2821a473 --- /dev/null +++ b/src/app/document/io/dom.ts @@ -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 +} + +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 } +} diff --git a/src/app/editor/session/modules.ts b/src/app/editor/session/modules.ts index a90bb37dd..8bda7db2e 100644 --- a/src/app/editor/session/modules.ts +++ b/src/app/editor/session/modules.ts @@ -69,6 +69,7 @@ export function createEditorStoreModules( ...pen, ...vectorEdit, openFigFile: documentIO.openFigFile, + openDOMFile: documentIO.openDOMFile, setViewportSize: documentIO.setViewportSize, fitCurrentPageToViewport: documentIO.fitCurrentPageToViewport, saveFigFile: documentIO.saveFigFile, diff --git a/src/app/shell/menu/files.ts b/src/app/shell/menu/files.ts index 50b6ea025..e2a2fd8e6 100644 --- a/src/app/shell/menu/files.ts +++ b/src/app/shell/menu/files.ts @@ -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 { export async function chooseTauriOpenPath(): Promise { 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'] } } diff --git a/src/app/tabs/index.ts b/src/app/tabs/index.ts index 4a8291b25..12e403a7a 100644 --- a/src/app/tabs/index.ts +++ b/src/app/tabs/index.ts @@ -91,6 +91,10 @@ function yieldToUI(): Promise { }) } +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 diff --git a/tests/e2e/dom-css/browser-runtime.spec.ts b/tests/e2e/dom-css/browser-runtime.spec.ts index f4ce9ed9d..de9264e13 100644 --- a/tests/e2e/dom-css/browser-runtime.spec.ts +++ b/tests/e2e/dom-css/browser-runtime.spec.ts @@ -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('
') + } + + 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, + '

OpenPencil

', + 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)