From 799921e21311a33f29ad7d61cdebd02356dfa5f6 Mon Sep 17 00:00:00 2001 From: navi Date: Sun, 27 Sep 2026 21:22:04 +0200 Subject: [PATCH 1/8] feat: presentation runtime with lifecycle states and :thisSlide - src/slides/compile.ts compiles a project into runtime-ready slides. :thisSlide is resolved to each slide's own selector before Less runs, so output is deterministic and never depends on which slide is visible. - src/slides/runtime.ts mounts every slide once and moves them through idle -> entering -> current -> leaving via a data-ps-state attribute; CSS owns the animation, JS only owns the lifecycle. Rapid input coalesces into a single settle instead of a queue of stale operations. Arrow/space/Home/End keys, plus history integration: back/forward step through slides and a reload restores the slide you were on. - The editor gained two buffers: HTML slides and the project Less. The preview and the presentation are the same runtime (present = fullscreen of the same stage), and broken Less keeps the last good preview while reporting the error. Adds `less` (the authoring language) and `@types/less`. --- index.html | 28 +++- package.json | 2 + src/editor.ts | 32 +++- src/main.ts | 188 +++++++++++++++++++++- src/slides/compile.ts | 87 +++++++++++ src/slides/runtime.ts | 352 ++++++++++++++++++++++++++++++++++++++++++ src/style.css | 71 +++++++++ 7 files changed, 750 insertions(+), 10 deletions(-) create mode 100644 src/slides/compile.ts create mode 100644 src/slides/runtime.ts diff --git a/index.html b/index.html index f2089b2..f609966 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,28 @@
- +
Slides @@ -18,7 +39,10 @@
-
+
+ +
+
diff --git a/package.json b/package.json index 541d3c7..f0e1710 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "preview": "vite preview" }, "devDependencies": { + "@types/less": "^3.0.8", "oxlint": "^1.85.0", "prettier": "^3.9.9", "typescript": "~6.0.2", @@ -20,6 +21,7 @@ }, "dependencies": { "emmet-monaco-es": "^5.7.0", + "less": "^4.9.1", "monaco-editor": "0.55.1" } } diff --git a/src/editor.ts b/src/editor.ts index cce20ce..0e262be 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -21,11 +21,39 @@ self.MonacoEnvironment = { const MONACO_PARENT = '.code' -export function createEditor() { - return monaco.editor.create(document.querySelector(MONACO_PARENT)!, { +/** Which buffer the single Monaco instance is currently showing. */ +export type EditorBuffer = 'slides' | 'style' + +let editorInstance: monaco.editor.IStandaloneCodeEditor | null = null +let slidesModel: monaco.editor.ITextModel | null = null +let styleModel: monaco.editor.ITextModel | null = null +let activeBuffer: EditorBuffer = 'slides' + +export function createEditor(initialStyle: string): monaco.editor.IStandaloneCodeEditor { + const editor = monaco.editor.create(document.querySelector(MONACO_PARENT)!, { value: 'test', language: 'html', theme: 'vs-dark', automaticLayout: true }) + + editorInstance = editor + slidesModel = editor.getModel() + styleModel = monaco.editor.createModel(initialStyle, 'less') + activeBuffer = 'slides' + return editor +} + +export function getEditorBuffer(): EditorBuffer { + return activeBuffer +} + +/** Swap the visible buffer. Slide text lives in its own model, so styles and + * slide markup never overwrite each other. */ +export function setEditorBuffer(buffer: EditorBuffer): void { + if (!editorInstance || !slidesModel || !styleModel || buffer === activeBuffer) { + return + } + activeBuffer = buffer + editorInstance.setModel(buffer === 'style' ? styleModel : slidesModel) } diff --git a/src/main.ts b/src/main.ts index 29e7df9..af34e55 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,56 @@ import './style.css' -import { createEditor } from './editor' +import { createEditor, getEditorBuffer, setEditorBuffer } from './editor' import { createCodeThumbnail } from './code-to-element' +import { compilePresentation } from './slides/compile' +import { createRuntime, type Runtime } from './slides/runtime' interface Slide { code: string } +// Starter stylesheet for a new project. Authors write Less here and HTML in the +// slides; `:thisSlide` compiles to each slide's own selector, so deck styles +// stay scoped without inventing any extra CSS syntax. +const DEFAULT_STYLE = `// Project styles. This is ordinary Less: variables, nesting, +// mixins and imports all work as usual. +// +// :thisSlide compiles to the selector of the slide this stylesheet is being +// compiled for, so it means "this slide", never "the visible slide". + +@bg: #101010; +@fg: #f0f0f0; +@accent: #8fc98f; + +:thisSlide { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.75rem; + padding: 3rem; + background: @bg; + color: @fg; + transition: opacity 260ms ease, transform 260ms ease; +} + +// The runtime moves slides through idle -> entering -> current -> leaving. +// CSS owns the transition itself; JavaScript only sets data-ps-state. +// idle and leaving deliberately share values so settling costs no jump. +:thisSlide[data-ps-state='idle'], +:thisSlide[data-ps-state='leaving'] { + opacity: 0; + transform: scale(0.98); +} + +:thisSlide[data-ps-state='entering'], +:thisSlide[data-ps-state='current'] { + opacity: 1; + transform: scale(1); +} +` + +let projectStyle = DEFAULT_STYLE let globalSlide = '' const slides: Slide[] = [ @@ -14,9 +59,14 @@ const slides: Slide[] = [ } ] -const editor = createEditor() +const editor = createEditor(projectStyle) const slideList = document.querySelector('.slide-list-items')! const addSlideButton = document.querySelector('.add-slide')! +const stage = document.querySelector('.stage')! +const compileError = document.querySelector('.compile-error')! +const slidesTab = document.querySelector('.buffer-tab-slides')! +const styleTab = document.querySelector('.buffer-tab-style')! +const presentButton = document.querySelector('.present-button')! let currentSlide = 0 let draggedSlide: number | null = null let globalSlideElement: HTMLElement | null = null @@ -24,6 +74,18 @@ const slideElements = new Map() const thumbnailRequests = new Map() let thumbnailDebounceTimer: number | undefined +// Project CSS lives in its own stylesheet so the preview can be swapped out +// without touching the editor's own styles. +const slideStyles = document.createElement('style') +slideStyles.setAttribute('data-ps-slides', '') +document.head.append(slideStyles) + +let runtime: Runtime | null = null +let compileDebounceTimer: number | undefined +// Set while we push text into Monaco ourselves, so programmatic changes are +// never mistaken for the author typing. +let applyingToEditor = false + function updateSelection() { slideList.querySelectorAll('.slide-list-item').forEach((element) => { const index = Number(element.dataset.slideId) @@ -31,15 +93,87 @@ function updateSelection() { }) } +function syncBufferTabs() { + const showingSlides = getEditorBuffer() === 'slides' + slidesTab.classList.toggle('active', showingSlides) + styleTab.classList.toggle('active', !showingSlides) + slidesTab.setAttribute('aria-pressed', String(showingSlides)) + styleTab.setAttribute('aria-pressed', String(!showingSlides)) +} + +function setEditorText(text: string) { + applyingToEditor = true + try { + editor.getModel()?.setValue(text) + } finally { + applyingToEditor = false + } +} + +function showSlideInEditor(index: number) { + setEditorBuffer('slides') + syncBufferTabs() + setEditorText(index === -1 ? globalSlide : (slides[index]?.code ?? '')) +} + function setSlide(index: number) { - const slide = index !== -1 ? slides[index] : { code: globalSlide } - if (!slide) { + if (index !== -1 && !slides[index]) { return } currentSlide = index - editor.getModel()?.setValue(slide.code) + showSlideInEditor(index) updateSelection() + // The global slide is shared markup, not a step of the presentation, so it + // never moves the runtime. + if (index >= 0) { + runtime?.goTo(index) + } +} + +function showCompileError(message: string) { + compileError.textContent = message + compileError.hidden = false +} + +function scheduleCompile() { + if (compileDebounceTimer !== undefined) { + window.clearTimeout(compileDebounceTimer) + } + compileDebounceTimer = window.setTimeout(() => { + compileDebounceTimer = undefined + void rebuildPreview() + }, 250) +} + +async function rebuildPreview(): Promise { + try { + const result = await compilePresentation({ + slides: slides.map((slide) => slide.code), + global: globalSlide, + style: projectStyle + }) + + slideStyles.textContent = result.css + compileError.hidden = true + compileError.textContent = '' + + if (runtime) { + runtime.setSlides(result.slides) + // Follow the slide being edited; the deck may just have grown. + if (currentSlide >= 0) { + runtime.goTo(currentSlide) + } + } else { + runtime = createRuntime(stage, result.slides, { + onCurrentChange: (index) => setSlide(index) + }) + } + } catch (error) { + // Half-typed Less should not blank the preview: keep the last good + // output on screen and report what broke. + showCompileError(error instanceof Error ? error.message : String(error)) + } } async function generateSlideThumbnail(slide: Slide, i: number) { @@ -189,7 +323,8 @@ function moveSlide(from: number, to: number) { } updateSlideOrder() - editor.getModel()?.setValue(slides[currentSlide]?.code ?? globalSlide) + showSlideInEditor(currentSlide) + scheduleCompile() } function deleteSlide(index: number) { @@ -209,6 +344,7 @@ function deleteSlide(index: number) { updateSlideOrder() setSlide(currentSlide) + scheduleCompile() } addSlideButton.addEventListener('click', async () => { @@ -218,6 +354,32 @@ addSlideButton.addEventListener('click', async () => { slideList.append(slideElement) updateSlideOrder() setSlide(slides.length - 1) + scheduleCompile() +}) + +slidesTab.addEventListener('click', () => { + showSlideInEditor(currentSlide) +}) + +styleTab.addEventListener('click', () => { + setEditorBuffer('style') + syncBufferTabs() +}) + +presentButton.addEventListener('click', () => { + stage.requestFullscreen().catch(() => { + // Browsers can refuse fullscreen; presenting inside the panel still works. + }) +}) + +document.addEventListener('fullscreenchange', () => { + if (document.fullscreenElement === stage) { + // Keys must reach the runtime, not the editor we just left behind. + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + stage.focus() + } }) slideList.addEventListener('dragstart', (event) => { @@ -252,6 +414,16 @@ slideList.addEventListener('dragend', () => { }) editor.onDidChangeModelContent(() => { + if (applyingToEditor) { + return + } + + if (getEditorBuffer() === 'style') { + projectStyle = editor.getValue() + scheduleCompile() + return + } + const code = editor.getValue() const slideIndex = currentSlide const slide = slideIndex === -1 ? { code } : slides[slideIndex] @@ -261,6 +433,8 @@ editor.onDidChangeModelContent(() => { slides[slideIndex].code = code } + scheduleCompile() + if (thumbnailDebounceTimer !== undefined) { window.clearTimeout(thumbnailDebounceTimer) } @@ -271,8 +445,10 @@ editor.onDidChangeModelContent(() => { }) async function render() { + syncBufferTabs() await setSlideList() setSlide(currentSlide) + await rebuildPreview() } render() diff --git a/src/slides/compile.ts b/src/slides/compile.ts new file mode 100644 index 0000000..c654795 --- /dev/null +++ b/src/slides/compile.ts @@ -0,0 +1,87 @@ +import less from 'less' + +/** + * 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 { + return `slide-${index}` +} + +function slideSelector(id: string): string { + return `[data-ps-slide="${id}"]` +} + +const THIS_SLIDE = ':thisSlide' + +export interface CompiledSlide { + /** Stable id, used as the value of the slide's `data-ps-slide` attribute. */ + id: string + index: number + html: string +} + +export interface CompileInput { + /** HTML source of every slide, in presentation order. */ + slides: string[] + /** HTML shared by every slide (the "global" slide in the editor). */ + global?: string + /** Project Less source. `:thisSlide` resolves to the slide being compiled. */ + style: string +} + +export interface CompiledPresentation { + slides: CompiledSlide[] + css: string +} + +function buildSlideHtml(markup: string, global: string): string { + return global ? `${global}\n${markup}` : markup +} + +/** + * Compile a project into runtime-ready data. + * + * `:thisSlide` is replaced with the slide's own selector *before* Less runs, so + * Less keeps behaving like Less: nesting, variables, mixins and imports all work + * unchanged, and the output is deterministic — it never depends on which slide + * happens to be visible. + */ +export async function compilePresentation(input: CompileInput): Promise { + const global = input.global ?? '' + const slides: CompiledSlide[] = input.slides.map((markup, index) => ({ + id: slideId(index), + index, + html: buildSlideHtml(markup, global) + })) + + const css = await compileStyles( + input.style, + slides.map((slide) => slide.id) + ) + return { slides, css } +} + +async function compileStyles(source: string, ids: string[]): Promise { + if (!source.trim()) { + return '' + } + + // 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 + } + + 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) + return `/* ${selector} */\n${css}` + }) + ) + + return perSlide.join('\n') +} diff --git a/src/slides/runtime.ts b/src/slides/runtime.ts new file mode 100644 index 0000000..af4252a --- /dev/null +++ b/src/slides/runtime.ts @@ -0,0 +1,352 @@ +import type { CompiledSlide } from './compile' + +/** + * Slide lifecycle. The runtime moves slides through these states; CSS owns how + * each transition looks. State is published as the `data-ps-state` attribute. + * + * idle -> entering -> current -> leaving -> idle + */ +export type SlideState = 'idle' | 'entering' | 'current' | 'leaving' + +export interface RuntimeSlide { + readonly id: string + readonly index: number + readonly element: HTMLElement + readonly state: SlideState +} + +export interface RuntimeOptions { + /** Listen for arrow keys / space / Home / End. Defaults to true. */ + keyboard?: boolean + /** Called whenever the current slide changes. */ + onCurrentChange?: (index: number) => void +} + +export interface Runtime { + /** Navigate directly. Out-of-range indexes clamp; repeats are ignored. */ + goTo(index: number): void + next(): void + previous(): void + /** Swap the deck in place (after recompiling) without resetting the view. */ + setSlides(slides: readonly CompiledSlide[]): void + readonly current: number + readonly slides: readonly RuntimeSlide[] + /** Remove the runtime, its listeners and its DOM. */ + destroy(): void +} + +/** Upper bound on how long a navigation may wait before it settles anyway. */ +const SETTLE_TIMEOUT_MS = 5000 +/** + * The current slide is remembered in the history entry rather than the URL, so + * back/forward steps through slides and a reload lands on the slide you were on. + */ +const HISTORY_KEY = 'psslides-slide' +/** Images in the incoming slide get this long to load before we show it. */ +const ASSET_TIMEOUT_MS = 1000 + +class SlideRecord { + readonly id: string + readonly index: number + readonly element: HTMLElement + html: string + state: SlideState = 'idle' + + constructor(slide: CompiledSlide) { + this.id = slide.id + this.index = slide.index + this.html = slide.html + this.element = document.createElement('section') + this.element.setAttribute('data-ps-slide', slide.id) + this.element.setAttribute('data-ps-state', 'idle') + this.element.innerHTML = slide.html + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => { + window.setTimeout(resolve, ms) + }) +} + +/** Resolves after two frames, which is when CSS has started its transitions. */ +function nextFrames(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => resolve()) + }) + }) +} + +function waitForImage(image: HTMLImageElement): Promise { + return new Promise((resolve) => { + if (image.complete) { + resolve() + return + } + image.addEventListener('load', () => resolve(), { once: true }) + image.addEventListener('error', () => resolve(), { once: true }) + }) +} + +/** Wait (briefly) for images in the incoming slide so it cannot pop in late. */ +async function waitForAssets(element: HTMLElement): Promise { + const pending = Array.from(element.querySelectorAll('img')).filter((image) => !image.complete) + if (pending.length === 0) { + return + } + await Promise.race([Promise.all(pending.map(waitForImage)), delay(ASSET_TIMEOUT_MS)]) +} + +/** Infinite animations (spinners) never finish, so they must not block a settle. */ +function isFiniteAnimation(animation: Animation): boolean { + return animation.effect?.getTiming().iterations !== Infinity +} + +async function waitForAnimations(elements: readonly HTMLElement[]): Promise { + // Transitions are scheduled but not started until the next frame. + await nextFrames() + const animations = elements + .flatMap((element) => element.getAnimations({ subtree: true })) + .filter(isFiniteAnimation) + if (animations.length === 0) { + return + } + await Promise.race([ + Promise.all(animations.map((animation) => animation.finished.catch(() => undefined))), + delay(SETTLE_TIMEOUT_MS) + ]) +} + +/** Slide index carried by the current history entry, if it has one. */ +function storedIndex(): number | null { + const value = (window.history.state as Record | null)?.[HISTORY_KEY] + return typeof value === 'number' && Number.isInteger(value) ? value : null +} + +/** Record the slide in the history entry: push a new one, or amend this one. */ +function writeIndex(index: number, push: boolean): void { + const state = { + ...(window.history.state as Record | null), + [HISTORY_KEY]: index + } + if (push) { + window.history.pushState(state, '') + } else { + window.history.replaceState(state, '') + } +} + +function ignoresKeyboard(event: KeyboardEvent): boolean { + const target = event.target + if (!(target instanceof HTMLElement)) { + return false + } + // Never steal keys from the editor, form controls or focused controls. + return target.closest('input, textarea, select, button, a, [contenteditable]') !== null +} + +/** + * Mount every slide in the DOM at once and drive them through their lifecycle. + * + * The runtime never rebuilds the scene during navigation: all slides stay + * mounted, and only `data-ps-state` changes. It deliberately has no animation + * engine, no event bus and no knowledge of editing or Less — CSS describes how + * slides look, this moves them between states. + */ +export function createRuntime( + container: HTMLElement, + deck: readonly CompiledSlide[], + options: RuntimeOptions = {} +): Runtime { + const records: SlideRecord[] = deck.map((slide) => new SlideRecord(slide)) + const restored = storedIndex() + let current = + restored === null ? 0 : Math.min(Math.max(restored, 0), Math.max(records.length - 1, 0)) + let generation = 0 + let destroyed = false + + container.setAttribute('data-psslides-runtime', '') + + function setState(record: SlideRecord, state: SlideState): void { + if (record.state === state) { + return + } + record.state = state + record.element.setAttribute('data-ps-state', state) + } + + /** Every navigation ends by normalising: target current, everything else idle. */ + async function settle(generationAtCall: number, target: SlideRecord): Promise { + const involved = records.filter((record) => record.state !== 'idle') + await Promise.all([ + waitForAssets(target.element), + waitForAnimations(involved.map((record) => record.element)) + ]) + // A newer navigation took over while we waited; it owns the settle now. + if (destroyed || generationAtCall !== generation) { + return + } + for (const record of records) { + setState(record, record === target ? 'current' : 'idle') + } + } + + function goTo(index: number): void { + moveTo(index, false) + } + + /** `fromHistory` skips pushing a new entry (back/forward already made it). */ + function moveTo(index: number, fromHistory: boolean): void { + if (destroyed || records.length === 0) { + return + } + const target = Math.min(Math.max(index, 0), records.length - 1) + if (target === current) { + return + } + + // Anything on screen (or still mid-flight) starts leaving at once. + // Rapid navigation therefore never queues up: one generation, one settle. + generation++ + for (const record of records) { + if (record.state !== 'idle') { + setState(record, 'leaving') + } + } + const targetRecord = records[target] + setState(targetRecord, 'entering') + current = target + if (!fromHistory) { + writeIndex(target, true) + } + options.onCurrentChange?.(target) + + void settle(generation, targetRecord) + } + + function setSlides(deckUpdate: readonly CompiledSlide[]): void { + if (destroyed) { + return + } + + const previousRecords = records.slice() + const nextRecords: SlideRecord[] = deckUpdate.map((slide, index) => { + const existing = previousRecords[index] + if (existing && existing.id === slide.id) { + if (existing.html !== slide.html) { + existing.element.innerHTML = slide.html + existing.html = slide.html + } + return existing + } + return new SlideRecord(slide) + }) + + const structureChanged = + nextRecords.length !== previousRecords.length || + nextRecords.some((record, index) => record !== previousRecords[index]) + records.splice(0, records.length, ...nextRecords) + + if (records.length > 0 && current > records.length - 1) { + current = records.length - 1 + writeIndex(current, false) + } + + if (structureChanged) { + // Rebuilding the DOM restarts transitions, so drop in-flight work and + // settle into an explicit state instead of leaving a half-transition. + generation++ + container.replaceChildren(...records.map((record) => record.element)) + for (const record of records) { + setState(record, record.index === current ? 'current' : 'idle') + } + } + } + + function next(): void { + goTo(current + 1) + } + + function previous(): void { + goTo(current - 1) + } + + function onKeyDown(event: KeyboardEvent): void { + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return + if (ignoresKeyboard(event)) return + + switch (event.key) { + case 'ArrowRight': + case 'ArrowDown': + case 'PageDown': + case ' ': + case 'Spacebar': + next() + break + case 'ArrowLeft': + case 'ArrowUp': + case 'PageUp': + previous() + break + case 'Home': + goTo(0) + break + case 'End': + goTo(records.length - 1) + break + default: + return + } + event.preventDefault() + } + + // Initialise: the restored (or first) slide is current, the rest idle. + writeIndex(current, false) + records.forEach((record, index) => { + if (index === current) { + record.state = 'current' + record.element.setAttribute('data-ps-state', 'current') + } + container.append(record.element) + }) + + function onPopState(): void { + const index = storedIndex() + if (index !== null) { + moveTo(index, true) + } + } + + const keyboard = options.keyboard !== false + if (keyboard) { + window.addEventListener('keydown', onKeyDown) + } + window.addEventListener('popstate', onPopState) + + return { + goTo, + next, + previous, + setSlides, + get current() { + return current + }, + get slides() { + return records + }, + destroy() { + if (destroyed) { + return + } + destroyed = true + generation++ + if (keyboard) { + window.removeEventListener('keydown', onKeyDown) + } + window.removeEventListener('popstate', onPopState) + container.replaceChildren() + container.removeAttribute('data-psslides-runtime') + } + } +} diff --git a/src/style.css b/src/style.css index fbc461e..e620f63 100644 --- a/src/style.css +++ b/src/style.css @@ -105,6 +105,7 @@ body { .preview { grid-area: preview; + position: relative; min-width: 0; display: flex; align-items: center; @@ -113,6 +114,76 @@ body { background-color: var(--bg); } +/* The presentation surface. The runtime mounts every slide inside it. */ +.stage { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + background-color: var(--bg-sunken); +} + +.stage:focus { + outline: none; +} + +.stage:fullscreen { + width: 100vw; + height: 100vh; +} + +.compile-error { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 1; + max-height: 40%; + overflow: auto; + padding: 0.5rem 0.75rem; + background-color: #7a1f1f; + color: #ffffff; + font-family: monospace; + font-size: 0.8rem; + white-space: pre-wrap; +} + +.menubar-actions { + display: flex; + gap: 0.5rem; + align-items: center; + margin-left: auto; +} + +.buffer-tabs { + display: flex; + gap: 0.25rem; +} + +.buffer-tab, +.present-button { + padding: 0.25rem 0.7rem; + border: 1px solid rgba(255, 255, 255, 0.55); + background: transparent; + color: inherit; + font: inherit; + cursor: pointer; + border-radius: 3px; +} + +.buffer-tab.active { + background: var(--accent-text); + color: var(--accent); +} + +.buffer-tab:hover, +.buffer-tab:focus-visible, +.present-button:hover, +.present-button:focus-visible { + background: rgba(255, 255, 255, 0.2); + outline: none; +} + .slide-list-item { position: relative; display: grid; From 2282b13325743f0f827243d7e76295c60062fdff Mon Sep 17 00:00:00 2001 From: navi Date: Sun, 27 Sep 2026 21:58:26 +0200 Subject: [PATCH 2/8] feat: author styles in a \n

test

` } ] -const editor = createEditor(projectStyle) +const editor = createEditor() const slideList = document.querySelector('.slide-list-items')! const addSlideButton = document.querySelector('.add-slide')! const stage = document.querySelector('.stage')! const compileError = document.querySelector('.compile-error')! -const slidesTab = document.querySelector('.buffer-tab-slides')! -const styleTab = document.querySelector('.buffer-tab-style')! const presentButton = document.querySelector('.present-button')! let currentSlide = 0 let draggedSlide: number | null = null @@ -93,14 +91,6 @@ function updateSelection() { }) } -function syncBufferTabs() { - const showingSlides = getEditorBuffer() === 'slides' - slidesTab.classList.toggle('active', showingSlides) - styleTab.classList.toggle('active', !showingSlides) - slidesTab.setAttribute('aria-pressed', String(showingSlides)) - styleTab.setAttribute('aria-pressed', String(!showingSlides)) -} - function setEditorText(text: string) { applyingToEditor = true try { @@ -111,8 +101,6 @@ function setEditorText(text: string) { } function showSlideInEditor(index: number) { - setEditorBuffer('slides') - syncBufferTabs() setEditorText(index === -1 ? globalSlide : (slides[index]?.code ?? '')) } @@ -150,8 +138,7 @@ async function rebuildPreview(): Promise { try { const result = await compilePresentation({ slides: slides.map((slide) => slide.code), - global: globalSlide, - style: projectStyle + global: globalSlide }) slideStyles.textContent = result.css @@ -199,7 +186,9 @@ async function generateSlideThumbnail(slide: Slide, i: number) { slideNumber.classList.add('slide-number') slideNumber.textContent = i === -1 ? 'G' : `${i + 1}` - const thumbnail = await createCodeThumbnail(slide.code) + // 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) slideInList.append(slideNumber, thumbnail) slideElement.append(slideInList) @@ -357,15 +346,6 @@ addSlideButton.addEventListener('click', async () => { scheduleCompile() }) -slidesTab.addEventListener('click', () => { - showSlideInEditor(currentSlide) -}) - -styleTab.addEventListener('click', () => { - setEditorBuffer('style') - syncBufferTabs() -}) - presentButton.addEventListener('click', () => { stage.requestFullscreen().catch(() => { // Browsers can refuse fullscreen; presenting inside the panel still works. @@ -418,12 +398,6 @@ editor.onDidChangeModelContent(() => { return } - if (getEditorBuffer() === 'style') { - projectStyle = editor.getValue() - scheduleCompile() - return - } - const code = editor.getValue() const slideIndex = currentSlide const slide = slideIndex === -1 ? { code } : slides[slideIndex] @@ -445,7 +419,6 @@ editor.onDidChangeModelContent(() => { }) async function render() { - syncBufferTabs() await setSlideList() setSlide(currentSlide) await rebuildPreview() diff --git a/src/slides/compile.ts b/src/slides/compile.ts index c654795..3dcd312 100644 --- a/src/slides/compile.ts +++ b/src/slides/compile.ts @@ -26,8 +26,6 @@ export interface CompileInput { slides: string[] /** HTML shared by every slide (the "global" slide in the editor). */ global?: string - /** Project Less source. `:thisSlide` resolves to the slide being compiled. */ - style: string } export interface CompiledPresentation { @@ -35,6 +33,28 @@ export interface CompiledPresentation { css: string } +/** + * Pull `${(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}
+
Global slide — wraps every slide
+
` + 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 } +} From a05c860c6e169cf2eb19258a9b75b04542ed5d68 Mon Sep 17 00:00:00 2001 From: navi Date: Mon, 28 Sep 2026 14:29:37 +0200 Subject: [PATCH 6/8] fix: bind :thisSlide per style block instead of per compile pass Every slide's :thisSlide rules leaked into every other slide: the whole joined source was recompiled once per slide with all occurrences rebound, so each pass attached every slide's scoped rules to that pass's slide and the concatenated output applied them all to everyone. Bind the token while each block still belongs to one slide (global block -> any slide) and compile the project once - also drops the O(n) duplicated CSS output. --- src/slides/compile.ts | 69 +++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 39 deletions(-) diff --git a/src/slides/compile.ts b/src/slides/compile.ts index 4720d4c..37f51a1 100644 --- a/src/slides/compile.ts +++ b/src/slides/compile.ts @@ -109,6 +109,9 @@ function slideSelector(id: string): string { return `[data-ps-slide="${id}"]` } +/** What `:thisSlide` means inside the global slide's styles: any slide. */ +const ANY_SLIDE_SELECTOR = '[data-ps-slide]' + const THIS_SLIDE = ':thisSlide' // `` is a bogus comment to the HTML parser, so a placeholder would not @@ -206,52 +209,40 @@ function isGlobalTemplate(global: string): boolean { * Compile a project into runtime-ready data. * * Every `