feat(app): open DOM/CSS documents
This commit is contained in:
parent
2c66336c58
commit
05980797e3
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
63
src/app/document/io/dom.ts
Normal file
63
src/app/document/io/dom.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -69,6 +69,7 @@ export function createEditorStoreModules(
|
|||
...pen,
|
||||
...vectorEdit,
|
||||
openFigFile: documentIO.openFigFile,
|
||||
openDOMFile: documentIO.openDOMFile,
|
||||
setViewportSize: documentIO.setViewportSize,
|
||||
fitCurrentPageToViewport: documentIO.fitCurrentPageToViewport,
|
||||
saveFigFile: documentIO.saveFigFile,
|
||||
|
|
|
|||
|
|
@ -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']
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue