openpencil/packages/dom-css/src/browser.ts
Danila Poyarkov 6f5638380a
feat(app): prepare documents atomically per tab (#592)
* feat(app): show atomic document loading progress

- Preserve the existing full-canvas pencil loader while adding phase, detail, accessible status, and honest determinate progress
- Keep one generation-safe load owner across FIG decoding, graph preparation, page population, fonts, fallbacks, layout, viewport fitting, and first-render fade
- Prevent nested page setup and viewport cleanup from revealing partially prepared documents
- Cover obsolete sessions, font-resolution ownership, and staged loader UI

* refactor(app): scope editor preparation per tab

- Replace the shared loading boolean with one reactive preparation snapshot and one imperative controller per editor store
- Keep Core page work progress-only and inject canvas suspension from the app boundary
- Route FIG, storage, recovery, DOM import, and page switching through reusable tab-local preparation handles
- Abort only the closing tab's operation and cover generation safety, multi-tab isolation, progress UI, and disposal

* fix(editor): commit prepared pages atomically

- Prepare population, fonts, fallbacks, and layout without changing the visible page
- Reject cancelled and stale prepared pages before committing viewport, selection, and page events
- Keep the preparation overlay until the committed scene version is presented
- Cover call order, cancellation, stale generations, and presentation acknowledgement

* fix(app): stage imported documents before commit

- Prepare imported graphs in an isolated Core editor before replacing the live document
- Share font loading while keeping live selection, graph, renderers, and history untouched during staging
- Preserve the previous graph when staging is cancelled or fails and remove the duplicate pre-font layout pass

* refactor(app): namespace preparation UI

- Move canvas and tab preparation presentations into focused subfolders with concise component names
- Share progress and phase presentation helpers across preparation surfaces
- Show tab-local preparation status without covering the active canvas for background work

* fix(app): cancel preparation work at source

- Publish typed per-store preparation lifecycle events with explicit completion, cancellation, and failure outcomes
- Propagate tab-local AbortSignals through FIG parsing, population workers, and browser font downloads
- Keep cancellable font requests outside shared in-flight caches while retaining globally completed font registrations
- Stop FIG manifest previews from replacing the live graph before atomic document commit

* fix(app): cancel storage and DOM preparation

- Propagate preparation signals through S3 downloads, byte progress, local-cache boundaries, and DOM/CSS conversion checkpoints
- Reuse merged diagnostics and localized toasts for document, storage, and presentation failures
- Replace manual font concurrency and presentation timers with es-toolkit limitAsync and withTimeout
- Guard stalled first presentation and fix the merged recovery dialog title bindings

* fix(app): stage reload and font retry

- Prepare reload graphs in isolation and preserve the current document on read, decode, font, or layout failure
- Restore page and viewport state only after atomic graph commit with cancellable reload reads
- Run font Retry as a tab-local preparation with cache reset, final layout, picture invalidation, and presentation acknowledgement
- Keep completed document pixels visible while Retry reports activity in the tab

* fix(app): enforce exclusive preparation outcomes

- Complete document, storage, recovery, and DOM preparations only after successful commit
- Keep failed and cancelled handles terminal so lifecycle events cannot report contradictory outcomes
- Preserve external AbortError identity across storage timeouts and cancel streamed readers without returning partial bytes
- Cover credential-free pre-abort, mid-stream cancellation, progress cutoff, and terminal outcome exclusivity

* feat(diagnostics): record preparation outcomes

- Persist completed, cancelled, and failed preparation lifecycles through the validated diagnostics recorder
- Store only operation kind, outcome, cancellation or failure category, terminal phase, and coarse duration bucket
- Exclude document subjects, font families, storage identities, URLs, raw durations, messages, and stack traces

* chore(app): keep browser font tests with typography split

- Remove the browser font transport test inherited from a mixed cancellation commit; the source and coverage remain on the typography branch and safety snapshot

* test(vue): assert injected render suspension

- Exercise shouldSuspendRender instead of removed Core loading state\n- Preserve the contract that rendering resumes without a version change

* test(app): complete atomic preparation contracts

- Acknowledge first presentation in headless file-open tests\n- Assert the cancellable font-loading signature at the Tauri fallback boundary

* fix(app): preserve preparation cancellation

- Stage imported graphs before mutating live tabs and propagate aborts through page, DOM, font, and storage work\n- Use the accessible progress primitive and clamp determinate values\n- Cover fallback-font cancellation and yield pending-open test polling to the task queue

* test(text): await fallback font request cancellation

Start the mocked remote font request before aborting so the test proves that the active request receives the preparation signal.
2026-08-30 12:21:49 +03:00

147 lines
5.6 KiB
TypeScript

import type { SceneGraph } from '@open-pencil/scene-graph'
import { mergeCSSText } from './css-text'
import { jsxToDesignDocumentCore, type JSXChild } from './jsx/core'
import { createBrowserCSSRuntime, type BrowserCSSRuntimeOptions } from './runtime/browser'
import type { CompileTailwindCSSOptions } from './tailwind'
import { designDocumentToSceneGraph, type ToSceneGraphOptions } from './to-scene-graph'
import type { CSSComputeOptions, DesignDocument } from './types'
export { Fragment, jsx, jsxs } from './jsx/core'
export type { JSXChild, JSXElementProps, JSXStyleInput, JSXTag } from './jsx/core'
export interface BrowserToDesignDocumentOptions extends BrowserCSSRuntimeOptions {
cssText?: string
compute?: CSSComputeOptions
signal?: AbortSignal
}
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 {}
export interface BrowserTailwindToDesignDocumentOptions
extends Omit<BrowserToDesignDocumentOptions, 'cssText'>, CompileTailwindCSSOptions {}
export interface BrowserTailwindToSceneGraphOptions
extends BrowserTailwindToDesignDocumentOptions, ToSceneGraphOptions {}
function createRuntime(options: BrowserCSSRuntimeOptions) {
return createBrowserCSSRuntime({ sandbox: 'iframe', ...options })
}
async function compileBrowserTailwindCSS(
candidates: string | Iterable<string>,
options: CompileTailwindCSSOptions
): Promise<string> {
const { compileTailwindCSS } = await import('./tailwind')
return compileTailwindCSS(candidates, options)
}
function resolveBrowserDocument(documentOverride: Document | undefined): Document {
if (documentOverride) return documentOverride
if (typeof document === 'undefined') {
throw new TypeError('Browser DOM/CSS helpers require a DOM document')
}
return document
}
function extractEmbeddedCSSText(html: string, browserDocument: Document): string | undefined {
const Parser = browserDocument.defaultView?.DOMParser
if (!Parser) throw new TypeError('Browser DOM/CSS helpers require DOMParser')
const parsed = new Parser().parseFromString(html, 'text/html')
const styles = Array.from(parsed.querySelectorAll('style'))
.map((style) => (style.textContent ? style.textContent.trim() : ''))
.filter((css): css is string => !!css)
return styles.length > 0 ? styles.join('\n') : undefined
}
export async function browserHTMLToDesignDocument(
html: string,
options: BrowserHTMLToDesignDocumentOptions = {}
): Promise<DesignDocument> {
options.signal?.throwIfAborted()
const browserDocument = resolveBrowserDocument(options.document)
const runtime = createRuntime({ ...options, document: browserDocument })
const document = runtime.parseHTML(html)
const cssText = mergeCSSText(extractEmbeddedCSSText(html, browserDocument), options.cssText)
return runtime.computeStyles(document, cssText, options.compute)
}
export async function browserHTMLToSceneGraph(
html: string,
options: BrowserHTMLToSceneGraphOptions = {}
): Promise<SceneGraph> {
const document = await browserHTMLToDesignDocument(html, options)
options.signal?.throwIfAborted()
const graph = designDocumentToSceneGraph(document, options)
options.signal?.throwIfAborted()
return graph
}
export async function browserTailwindHTMLToDesignDocument(
html: string,
candidates: string | Iterable<string>,
options: BrowserTailwindHTMLToDesignDocumentOptions = {}
): Promise<DesignDocument> {
const cssText = await compileBrowserTailwindCSS(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)
options.signal?.throwIfAborted()
const graph = designDocumentToSceneGraph(document, options)
options.signal?.throwIfAborted()
return graph
}
export async function browserJSXToDesignDocument(
input: JSXChild,
options: BrowserToDesignDocumentOptions = {}
): Promise<DesignDocument> {
const document = jsxToDesignDocumentCore(input)
const runtime = createRuntime(options)
return runtime.computeStyles(document, options.cssText, options.compute)
}
export async function browserJSXToSceneGraph(
input: JSXChild,
options: BrowserToSceneGraphOptions = {}
): Promise<SceneGraph> {
const document = await browserJSXToDesignDocument(input, options)
return designDocumentToSceneGraph(document, options)
}
export async function browserTailwindJSXToDesignDocument(
input: JSXChild,
candidates: string | Iterable<string>,
options: BrowserTailwindToDesignDocumentOptions = {}
): Promise<DesignDocument> {
const cssText = await compileBrowserTailwindCSS(candidates, options)
return browserJSXToDesignDocument(input, { ...options, cssText })
}
export async function browserTailwindJSXToSceneGraph(
input: JSXChild,
candidates: string | Iterable<string>,
options: BrowserTailwindToSceneGraphOptions = {}
): Promise<SceneGraph> {
const document = await browserTailwindJSXToDesignDocument(input, candidates, options)
return designDocumentToSceneGraph(document, options)
}