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;