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`.
This commit is contained in:
parent
bbfb1fce04
commit
799921e213
7 changed files with 750 additions and 10 deletions
28
index.html
28
index.html
|
|
@ -9,7 +9,28 @@
|
|||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="menubar"><b>p/slides</b></div>
|
||||
<div class="menubar">
|
||||
<b>p/slides</b>
|
||||
<div class="menubar-actions">
|
||||
<div class="buffer-tabs" role="group" aria-label="Editor buffer">
|
||||
<button
|
||||
class="buffer-tab buffer-tab-slides"
|
||||
type="button"
|
||||
aria-pressed="true"
|
||||
>
|
||||
Slides
|
||||
</button>
|
||||
<button
|
||||
class="buffer-tab buffer-tab-style"
|
||||
type="button"
|
||||
aria-pressed="false"
|
||||
>
|
||||
Styles
|
||||
</button>
|
||||
</div>
|
||||
<button class="present-button" type="button">Present</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="slidelist">
|
||||
<div class="slide-list-header">
|
||||
<span>Slides</span>
|
||||
|
|
@ -18,7 +39,10 @@
|
|||
<div class="slide-list-items"></div>
|
||||
</div>
|
||||
<div class="code"></div>
|
||||
<div class="preview"></div>
|
||||
<div class="preview">
|
||||
<div class="compile-error" role="alert" hidden></div>
|
||||
<div class="stage" tabindex="-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
188
src/main.ts
188
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<HTMLElement>('.slide-list-items')!
|
||||
const addSlideButton = document.querySelector<HTMLButtonElement>('.add-slide')!
|
||||
const stage = document.querySelector<HTMLElement>('.stage')!
|
||||
const compileError = document.querySelector<HTMLElement>('.compile-error')!
|
||||
const slidesTab = document.querySelector<HTMLButtonElement>('.buffer-tab-slides')!
|
||||
const styleTab = document.querySelector<HTMLButtonElement>('.buffer-tab-style')!
|
||||
const presentButton = document.querySelector<HTMLButtonElement>('.present-button')!
|
||||
let currentSlide = 0
|
||||
let draggedSlide: number | null = null
|
||||
let globalSlideElement: HTMLElement | null = null
|
||||
|
|
@ -24,6 +74,18 @@ const slideElements = new Map<Slide, HTMLElement>()
|
|||
const thumbnailRequests = new Map<number, number>()
|
||||
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<HTMLElement>('.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<void> {
|
||||
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()
|
||||
|
|
|
|||
87
src/slides/compile.ts
Normal file
87
src/slides/compile.ts
Normal file
|
|
@ -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<CompiledPresentation> {
|
||||
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<string> {
|
||||
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')
|
||||
}
|
||||
352
src/slides/runtime.ts
Normal file
352
src/slides/runtime.ts
Normal file
|
|
@ -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<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolves after two frames, which is when CSS has started its transitions. */
|
||||
function nextFrames(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => resolve())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function waitForImage(image: HTMLImageElement): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<string, unknown> | 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<string, unknown> | 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<void> {
|
||||
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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue