diff --git a/src/code-to-element.ts b/src/code-to-element.ts index 55408cb..8c7e66c 100644 --- a/src/code-to-element.ts +++ b/src/code-to-element.ts @@ -3,6 +3,7 @@ import { domToSvgDataUrl } from './dom-to-image' export interface CodeToElementOptions { title?: string allowScripts?: boolean + /** Plain CSS strings — each becomes a `${(options.styles ?? []).join('\n')}${code}` + const headStyles = (options.styles ?? []).map((css) => ``).join('\n') + iframe.srcdoc = `${headStyles}${code}` return iframe } diff --git a/src/main.ts b/src/main.ts index 07b0fe8..22ae158 100644 --- a/src/main.ts +++ b/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 = `

Example slide

The global slide wraps every slide like this.

` + +const EXAMPLE_FRAME = ` +
+
Example deck
+
${EXAMPLE_SECTION}
+ +
` + let globalSlide = '' const slides: Slide[] = [ @@ -71,6 +117,12 @@ let globalSlideElement: HTMLElement | null = null const slideElements = new Map() const thumbnailRequests = new Map() 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 ``, or in a `data-ps-slides` sheet in the head when the @@ -157,6 +209,15 @@ async function rebuildPreview(): Promise { 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 { } } +/** + * Compose a tile exactly as the runtime composes the real thing: the slide in + * its `
` 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 = `
${inner}
` + 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( } 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() diff --git a/src/slides/compile.ts b/src/slides/compile.ts index 87337d4..4720d4c 100644 --- a/src/slides/compile.ts +++ b/src/slides/compile.ts @@ -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() +let nextRenderTask = 0 +/** `undefined` until first use, then the worker — or `null` once degraded. */ +let lessWorker: Worker | null | undefined + +function renderInline(source: string): Promise { + // 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 `` 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) => { + 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 { + const worker = getLessWorker() + if (!worker) { + return renderInline(source) + } + return new Promise((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 { // 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}` }) ) diff --git a/src/slides/less-worker.ts b/src/slides/less-worker.ts new file mode 100644 index 0000000..01a76a5 --- /dev/null +++ b/src/slides/less-worker.ts @@ -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) => 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) + }) + }) +}) diff --git a/src/slides/worker-globals.ts b/src/slides/worker-globals.ts new file mode 100644 index 0000000..ae1adbe --- /dev/null +++ b/src/slides/worker-globals.ts @@ -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 `` 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 + worker.window = worker + worker.document = { getElementsByTagName: () => [] } + worker.less = { onReady: false } +}