feat: compiled-style thumbnails and Less compilation in a worker
- thumbnails: compile the project stylesheet once per rebuild and feed it into the layout sandbox, compose each tile like the runtime does (<section data-ps-slide> around the slide), and show example HTML on the global tile instead of another real slide's content. - worker: less compiles off the main thread in less-worker; a side-effect module shims window/document and disables the browser build's page auto-init, and compilation falls back inline if the worker can't start. - code-to-element: wrap styles[] entries in <style> blocks - raw CSS text in the iframe head fell into <body> and rendered as literal garbage.
This commit is contained in:
parent
14916e6416
commit
97ae327415
5 changed files with 305 additions and 25 deletions
|
|
@ -3,6 +3,7 @@ import { domToSvgDataUrl } from './dom-to-image'
|
|||
export interface CodeToElementOptions {
|
||||
title?: string
|
||||
allowScripts?: boolean
|
||||
/** Plain CSS strings — each becomes a `<style>` block in the iframe head. */
|
||||
styles?: string[]
|
||||
}
|
||||
|
||||
|
|
@ -18,7 +19,8 @@ export function codeToElement(code: string, options: CodeToElementOptions = {}):
|
|||
|
||||
iframe.title = options.title ?? 'Rendered code'
|
||||
iframe.setAttribute('sandbox', Array.from(sandbox).join(' '))
|
||||
iframe.srcdoc = `<!doctype html><html><head><meta charset="UTF-8"><style>html,body{margin:0;min-height:100%;}</style>${(options.styles ?? []).join('\n')}</head><body>${code}</body></html>`
|
||||
const headStyles = (options.styles ?? []).map((css) => `<style>${css}</style>`).join('\n')
|
||||
iframe.srcdoc = `<!doctype html><html><head><meta charset="UTF-8"><style>html,body{margin:0;min-height:100%;}</style>${headStyles}</head><body>${code}</body></html>`
|
||||
|
||||
return iframe
|
||||
}
|
||||
|
|
|
|||
164
src/main.ts
164
src/main.ts
|
|
@ -1,7 +1,13 @@
|
|||
import './style.css'
|
||||
import { createEditor } from './editor'
|
||||
import { createCodeThumbnail } from './code-to-element'
|
||||
import { compilePresentation, extractStyles } from './slides/compile'
|
||||
import {
|
||||
compilePresentation,
|
||||
extractStyles,
|
||||
slideId,
|
||||
type CompiledPresentation
|
||||
} from './slides/compile'
|
||||
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './slides/placeholders'
|
||||
import { createRuntime, type Runtime } from './slides/runtime'
|
||||
|
||||
interface Slide {
|
||||
|
|
@ -51,6 +57,46 @@ const STARTER_STYLE = `// Project styles. This is ordinary Less: variables, nest
|
|||
}
|
||||
`
|
||||
|
||||
// Shown in the global-slide tile while the global slide is empty: an example
|
||||
// frame around a sample slide, so the tile explains what the global slide is
|
||||
// for instead of rendering a blank box. Template mode mounts this same sample
|
||||
// in the author's own frame.
|
||||
const EXAMPLE_SECTION = `<section data-ps-slide="${slideId(0)}" data-ps-state="current"><h1>Example slide</h1><p>The global slide wraps every slide like this.</p></section>`
|
||||
|
||||
const EXAMPLE_FRAME = `<style>
|
||||
.ps-example-frame {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
background: #161a22;
|
||||
color: #dbe4f0;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
.ps-example-frame header {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 650;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.ps-example-frame .ps-example-viewport {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ps-example-frame footer {
|
||||
font-size: 1.25rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
<div class="ps-example-frame">
|
||||
<header>Example deck</header>
|
||||
<div class="ps-example-viewport">${EXAMPLE_SECTION}</div>
|
||||
<footer>Global slide — wraps every slide</footer>
|
||||
</div>`
|
||||
|
||||
let globalSlide = ''
|
||||
|
||||
const slides: Slide[] = [
|
||||
|
|
@ -71,6 +117,12 @@ let globalSlideElement: HTMLElement | null = null
|
|||
const slideElements = new Map<Slide, HTMLElement>()
|
||||
const thumbnailRequests = new Map<number, number>()
|
||||
let thumbnailDebounceTimer: number | undefined
|
||||
// The last good compile: thumbnails read the stylesheet from here, so a tile
|
||||
// is rendered exactly as the presentation renders it.
|
||||
let lastResult: CompiledPresentation | null = null
|
||||
// Bumped by every full list rebuild; async thumbnail builders check it so a
|
||||
// stale build can never commit over a newer one.
|
||||
let listGeneration = 0
|
||||
|
||||
// The compiled stylesheet is the runtime's business: it lands at the global
|
||||
// slide's `<?= css ?>`, or in a `data-ps-slides` sheet in the head when the
|
||||
|
|
@ -157,6 +209,15 @@ async function rebuildPreview(): Promise<void> {
|
|||
if (currentSlide >= 0) {
|
||||
runtime.goTo(currentSlide)
|
||||
}
|
||||
|
||||
// Tiles embed the stylesheet (and, in template mode, the frame), so a
|
||||
// change to either makes every tile stale. The first compile builds
|
||||
// the list itself; render() only builds it when that compile failed.
|
||||
const previous = lastResult
|
||||
lastResult = result
|
||||
if (!previous || previous.css !== result.css || previous.template !== result.template) {
|
||||
void setSlideList()
|
||||
}
|
||||
} catch (error) {
|
||||
// Half-typed Less should not blank the preview: keep the last good
|
||||
// output on screen and report what broke.
|
||||
|
|
@ -164,6 +225,44 @@ async function rebuildPreview(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a tile exactly as the runtime composes the real thing: the slide in
|
||||
* its `<section data-ps-slide>` with state `current` (so the compiled state
|
||||
* rules show the settled look), inside the global template when there is one,
|
||||
* with the shared markup prepended when there is not. The global tile mounts
|
||||
* an example slide in the frame instead — it must not show another real
|
||||
* slide's content, which would go stale whenever that slide is edited.
|
||||
*/
|
||||
function buildThumbnailHtml(index: number, code: string): string {
|
||||
const global = extractStyles(globalSlide).html
|
||||
const isTemplate = HTML_PLACEHOLDER.test(global)
|
||||
|
||||
if (index === -1) {
|
||||
if (!global.trim()) {
|
||||
return EXAMPLE_FRAME
|
||||
}
|
||||
if (isTemplate) {
|
||||
return global
|
||||
.split(HTML_PLACEHOLDER)
|
||||
.join(EXAMPLE_SECTION)
|
||||
.split(CSS_PLACEHOLDER)
|
||||
.join('')
|
||||
}
|
||||
return global
|
||||
}
|
||||
|
||||
const extracted = extractStyles(code).html
|
||||
let inner = extracted
|
||||
if (!isTemplate && global) {
|
||||
inner = `${global}\n${extracted}`
|
||||
}
|
||||
const section = `<section data-ps-slide="${slideId(index)}" data-ps-state="current">${inner}</section>`
|
||||
if (isTemplate) {
|
||||
return global.split(HTML_PLACEHOLDER).join(section).split(CSS_PLACEHOLDER).join('')
|
||||
}
|
||||
return section
|
||||
}
|
||||
|
||||
async function generateSlideThumbnail(slide: Slide, i: number) {
|
||||
const slideElement = document.createElement('div')
|
||||
slideElement.classList.add('slide-list-item')
|
||||
|
|
@ -172,7 +271,6 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
|
|||
slideElement.classList.add('global-slide')
|
||||
} else {
|
||||
slideElement.draggable = true
|
||||
slideElements.set(slide, slideElement)
|
||||
}
|
||||
|
||||
const slideInList = document.createElement('button')
|
||||
|
|
@ -187,9 +285,11 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
|
|||
slideNumber.classList.add('slide-number')
|
||||
slideNumber.textContent = i === -1 ? 'G' : `${i + 1}`
|
||||
|
||||
// Style tags are the project stylesheet, never slide content: the
|
||||
// thumbnail renders markup only, exactly like the presentation does.
|
||||
const thumbnail = await createCodeThumbnail(extractStyles(slide.code).html)
|
||||
// The tile mirrors the presentation: composed markup, plus the compiled
|
||||
// stylesheet injected into the thumbnail document's head.
|
||||
const thumbnail = await createCodeThumbnail(buildThumbnailHtml(i, slide.code), {
|
||||
styles: lastResult?.css ? [lastResult.css] : []
|
||||
})
|
||||
slideInList.append(slideNumber, thumbnail)
|
||||
slideElement.append(slideInList)
|
||||
|
||||
|
|
@ -205,10 +305,6 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
|
|||
slideElement.append(deleteButton)
|
||||
}
|
||||
|
||||
if (i === -1) {
|
||||
globalSlideElement = slideElement
|
||||
}
|
||||
|
||||
if (i === currentSlide) {
|
||||
slideElement.classList.add('selected')
|
||||
}
|
||||
|
|
@ -219,9 +315,12 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
|
|||
async function refreshThumbnail(index: number, slide: Slide) {
|
||||
const request = (thumbnailRequests.get(index) ?? 0) + 1
|
||||
thumbnailRequests.set(index, request)
|
||||
const generation = listGeneration
|
||||
const oldSlideElement = index === -1 ? globalSlideElement : slideElements.get(slide)
|
||||
const slideElement = await generateSlideThumbnail(slide, index)
|
||||
if (thumbnailRequests.get(index) !== request) {
|
||||
// A newer refresh or a full rebuild superseded this one while it rendered;
|
||||
// a stale tile must never replace a fresh one.
|
||||
if (generation !== listGeneration || thumbnailRequests.get(index) !== request) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -257,15 +356,31 @@ async function mapWithConcurrency<T, R>(
|
|||
}
|
||||
|
||||
async function setSlideList() {
|
||||
const generation = ++listGeneration
|
||||
const allSlides = [{ code: globalSlide }, ...slides]
|
||||
slideList.replaceChildren()
|
||||
slideElements.clear()
|
||||
globalSlideElement = null
|
||||
|
||||
const elements = await mapWithConcurrency(allSlides, THUMBNAIL_CONCURRENCY, (slide, index) =>
|
||||
generateSlideThumbnail(slide, index - 1)
|
||||
)
|
||||
slideList.append(...elements)
|
||||
// Commit atomically: the old list stays interactive while tiles render.
|
||||
const deckUnchanged =
|
||||
allSlides.length === slides.length + 1 &&
|
||||
allSlides[0].code === globalSlide &&
|
||||
allSlides.slice(1).every((slide, offset) => slide === slides[offset])
|
||||
if (generation !== listGeneration) {
|
||||
return
|
||||
}
|
||||
if (!deckUnchanged) {
|
||||
// A slide was added, removed or moved mid-render; this snapshot is
|
||||
// stale, and no other rebuild is coming — start a fresh one.
|
||||
void setSlideList()
|
||||
return
|
||||
}
|
||||
slideList.replaceChildren(...elements)
|
||||
slideElements.clear()
|
||||
globalSlideElement = elements[0] ?? null
|
||||
elements.slice(1).forEach((element, offset) => {
|
||||
slideElements.set(allSlides[offset + 1], element)
|
||||
})
|
||||
updateSelection()
|
||||
}
|
||||
|
||||
|
|
@ -340,11 +455,18 @@ function deleteSlide(index: number) {
|
|||
addSlideButton.addEventListener('click', async () => {
|
||||
const slide = { code: '' }
|
||||
slides.push(slide)
|
||||
const slideElement = await generateSlideThumbnail(slide, slides.length - 1)
|
||||
slideList.append(slideElement)
|
||||
updateSlideOrder()
|
||||
setSlide(slides.length - 1)
|
||||
scheduleCompile()
|
||||
|
||||
const generation = listGeneration
|
||||
const slideElement = await generateSlideThumbnail(slide, slides.length - 1)
|
||||
// A full rebuild started meanwhile; its snapshot already has this slide.
|
||||
if (generation !== listGeneration) {
|
||||
return
|
||||
}
|
||||
slideElements.set(slide, slideElement)
|
||||
slideList.append(slideElement)
|
||||
updateSlideOrder()
|
||||
})
|
||||
|
||||
presentButton.addEventListener('click', () => {
|
||||
|
|
@ -420,9 +542,13 @@ editor.onDidChangeModelContent(() => {
|
|||
})
|
||||
|
||||
async function render() {
|
||||
await setSlideList()
|
||||
setSlide(currentSlide)
|
||||
await rebuildPreview()
|
||||
// A successful first compile builds the list itself; this is only the
|
||||
// fallback when even the boot compile failed.
|
||||
if (!lastResult) {
|
||||
await setSlideList()
|
||||
}
|
||||
}
|
||||
|
||||
render()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,107 @@
|
|||
import less from 'less'
|
||||
import LessWorker from './less-worker?worker'
|
||||
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './placeholders'
|
||||
|
||||
/**
|
||||
* Less runs in a worker so a heavy stylesheet never blocks the editor. The
|
||||
* worker is an optimisation, not a requirement: if it fails to start or dies
|
||||
* mid-render, everything still in flight (and every later render) falls back
|
||||
* to an on-demand `import('less')` on the main thread — which also keeps the
|
||||
* big Less bundle out of the initial page load.
|
||||
*/
|
||||
interface RenderTask {
|
||||
source: string
|
||||
resolve: (css: string) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
/** Reply the worker posts back: either the CSS or the failure message. */
|
||||
interface RenderReply {
|
||||
id: number
|
||||
css?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const renderTasks = new Map<number, RenderTask>()
|
||||
let nextRenderTask = 0
|
||||
/** `undefined` until first use, then the worker — or `null` once degraded. */
|
||||
let lessWorker: Worker | null | undefined
|
||||
|
||||
function renderInline(source: string): Promise<string> {
|
||||
// The same auto-init as in the worker: pre-set `onReady: false` (the init
|
||||
// copies `window.less` before its own defaults) so importing less does
|
||||
// not hide `<body>` or rescan the app's stylesheets just because we only
|
||||
// wanted its compiler.
|
||||
if (typeof window !== 'undefined' && !('less' in window)) {
|
||||
;(window as unknown as { less?: unknown }).less = { onReady: false }
|
||||
}
|
||||
return import('less').then(async (module) => {
|
||||
const { css } = await module.default.render(source)
|
||||
return css
|
||||
})
|
||||
}
|
||||
|
||||
/** The worker is gone: re-run everything pending on the main thread. */
|
||||
function degradeWorker(): void {
|
||||
if (!lessWorker) {
|
||||
return
|
||||
}
|
||||
lessWorker.terminate()
|
||||
lessWorker = null
|
||||
const pending = Array.from(renderTasks.values())
|
||||
renderTasks.clear()
|
||||
for (const task of pending) {
|
||||
renderInline(task.source).then(task.resolve).catch(task.reject)
|
||||
}
|
||||
}
|
||||
|
||||
function getLessWorker(): Worker | null {
|
||||
if (lessWorker !== undefined) {
|
||||
return lessWorker
|
||||
}
|
||||
try {
|
||||
const worker = new LessWorker()
|
||||
worker.addEventListener('message', (event: MessageEvent<RenderReply>) => {
|
||||
const task = renderTasks.get(event.data.id)
|
||||
if (!task) {
|
||||
return
|
||||
}
|
||||
renderTasks.delete(event.data.id)
|
||||
if (event.data.error !== undefined) {
|
||||
task.reject(new Error(event.data.error))
|
||||
} else {
|
||||
task.resolve(event.data.css ?? '')
|
||||
}
|
||||
})
|
||||
// Uncaught worker failure (bundle load, environment): drop to inline.
|
||||
worker.addEventListener('error', (event) => {
|
||||
event.preventDefault()
|
||||
degradeWorker()
|
||||
})
|
||||
lessWorker = worker
|
||||
} catch {
|
||||
lessWorker = null
|
||||
}
|
||||
return lessWorker
|
||||
}
|
||||
|
||||
function renderLess(source: string): Promise<string> {
|
||||
const worker = getLessWorker()
|
||||
if (!worker) {
|
||||
return renderInline(source)
|
||||
}
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const id = ++nextRenderTask
|
||||
renderTasks.set(id, { source, resolve, reject })
|
||||
// oxlint-disable-next-line unicorn/require-post-message-target-origin
|
||||
worker.postMessage({ id, source })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A slide's stable id. Deterministic for a given position, so recompiling the
|
||||
* project (on every keystroke) never renames a slide underneath the runtime.
|
||||
*/
|
||||
function slideId(index: number): string {
|
||||
export function slideId(index: number): string {
|
||||
return `slide-${index}`
|
||||
}
|
||||
|
||||
|
|
@ -145,15 +241,14 @@ async function compileStyles(source: string, ids: string[]): Promise<string> {
|
|||
// Fast path: without `:thisSlide` every slide resolves to the same CSS, so
|
||||
// compile once instead of once per slide.
|
||||
if (!source.includes(THIS_SLIDE)) {
|
||||
const { css } = await less.render(source)
|
||||
return css
|
||||
return renderLess(source)
|
||||
}
|
||||
|
||||
const perSlide = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const selector = slideSelector(id)
|
||||
const scoped = source.split(THIS_SLIDE).join(selector)
|
||||
const { css } = await less.render(scoped)
|
||||
const css = await renderLess(scoped)
|
||||
return `/* ${selector} */\n${css}`
|
||||
})
|
||||
)
|
||||
|
|
|
|||
43
src/slides/less-worker.ts
Normal file
43
src/slides/less-worker.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// Worker `postMessage` takes no targetOrigin; that rule only targets windows.
|
||||
/* oxlint-disable unicorn/require-post-message-target-origin */
|
||||
|
||||
// Side-effect import first: it shims `window`/`document` for less' auto-init,
|
||||
// which reads them while the less module evaluates — a static import would
|
||||
// hoist above any code in this file, hence the separate module.
|
||||
import './worker-globals'
|
||||
import less from 'less'
|
||||
|
||||
/**
|
||||
* Renders one Less source per message so the main thread never blocks on a
|
||||
* big stylesheet. Replies carry the task id back with either the CSS or the
|
||||
* error message — a Less syntax error is an ordinary reply, not a crash.
|
||||
*/
|
||||
interface RenderRequest {
|
||||
id: number
|
||||
source: string
|
||||
}
|
||||
|
||||
interface RenderReply {
|
||||
id: number
|
||||
css?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Typed narrowly on purpose: this file runs as a worker, but the program is
|
||||
// typed with the DOM libs (no WebWorker lib), so `self` here is the scope.
|
||||
const scope = self as unknown as {
|
||||
addEventListener(type: 'message', listener: (event: MessageEvent<RenderRequest>) => void): void
|
||||
postMessage(message: RenderReply): void
|
||||
}
|
||||
|
||||
scope.addEventListener('message', (event) => {
|
||||
const { id, source } = event.data
|
||||
less.render(source)
|
||||
.then(({ css }) => scope.postMessage({ id, css }))
|
||||
.catch((error: unknown) => {
|
||||
scope.postMessage({
|
||||
id,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
})
|
||||
})
|
||||
14
src/slides/worker-globals.ts
Normal file
14
src/slides/worker-globals.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// less' browser build runs a page auto-init while its module loads: it
|
||||
// reaches for `window`, copies any `window.less` config, then — unless told
|
||||
// otherwise — hides `<body>` and rescans the document's stylesheets. A worker
|
||||
// has none of that, so bind `window` to the worker scope, stub the single
|
||||
// `document` probe the init makes (null-safe downstream), and pre-set
|
||||
// `onReady: false` so init stops right after building the compiler.
|
||||
// The worker imports this for side effects before `less`, so ESM evaluation
|
||||
// order guarantees the shim exists by the time the auto-init runs.
|
||||
if (typeof window === 'undefined') {
|
||||
const worker = globalThis as unknown as Record<string, unknown>
|
||||
worker.window = worker
|
||||
worker.document = { getElementsByTagName: () => [] }
|
||||
worker.less = { onReady: false }
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue