1
0
Fork 0
forked from jan/psslides
psslides/src/main.ts
navi 97ae327415 feat: compiled-style thumbnails and Less compilation in a worker
- thumbnails: compile the project stylesheet once per rebuild and feed it
  into the layout sandbox, compose each tile like the runtime does
  (<section data-ps-slide> around the slide), and show example HTML on
  the global tile instead of another real slide's content.
- worker: less compiles off the main thread in less-worker; a side-effect
  module shims window/document and disables the browser build's page
  auto-init, and compilation falls back inline if the worker can't start.
- code-to-element: wrap styles[] entries in <style> blocks - raw CSS text
  in the iframe head fell into <body> and rendered as literal garbage.
2026-09-28 13:05:42 +02:00

554 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import './style.css'
import { createEditor } from './editor'
import { createCodeThumbnail } from './code-to-element'
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 {
code: string
}
// The starter slide opens with a <style> tag. Less written inside any <style>
// tag in the deck becomes the project stylesheet, and `:thisSlide` compiles to
// each slide's own selector, so deck styles stay scoped without inventing any
// extra CSS syntax.
const STARTER_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);
}
`
// Shown in the global-slide tile while the global slide is empty: an example
// frame around a sample slide, so the tile explains what the global slide is
// for instead of rendering a blank box. Template mode mounts this same sample
// in the author's own frame.
const EXAMPLE_SECTION = `<section data-ps-slide="${slideId(0)}" data-ps-state="current"><h1>Example slide</h1><p>The global slide wraps every slide like this.</p></section>`
const EXAMPLE_FRAME = `<style>
.ps-example-frame {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 1rem;
padding: 2rem;
background: #161a22;
color: #dbe4f0;
font-family: system-ui, sans-serif;
}
.ps-example-frame header {
font-size: 1.75rem;
font-weight: 650;
opacity: 0.85;
}
.ps-example-frame .ps-example-viewport {
position: relative;
flex: 1;
border-radius: 0.75rem;
overflow: hidden;
}
.ps-example-frame footer {
font-size: 1.25rem;
opacity: 0.55;
}
</style>
<div class="ps-example-frame">
<header>Example deck</header>
<div class="ps-example-viewport">${EXAMPLE_SECTION}</div>
<footer>Global slide — wraps every slide</footer>
</div>`
let globalSlide = ''
const slides: Slide[] = [
{
code: `<style>\n${STARTER_STYLE}\n</style>\n<h1>test</h1>`
}
]
const editor = createEditor()
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 presentButton = document.querySelector<HTMLButtonElement>('.present-button')!
let currentSlide = 0
let draggedSlide: number | null = null
let globalSlideElement: HTMLElement | null = null
const slideElements = new Map<Slide, HTMLElement>()
const thumbnailRequests = new Map<number, number>()
let thumbnailDebounceTimer: number | undefined
// The last good compile: thumbnails read the stylesheet from here, so a tile
// is rendered exactly as the presentation renders it.
let lastResult: CompiledPresentation | null = null
// Bumped by every full list rebuild; async thumbnail builders check it so a
// stale build can never commit over a newer one.
let listGeneration = 0
// The compiled stylesheet is the runtime's business: it lands at the global
// slide's `<?= css ?>`, or in a `data-ps-slides` sheet in the head when the
// scene has no injection point.
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)
element.classList.toggle('selected', index === currentSlide)
})
}
function setEditorText(text: string) {
applyingToEditor = true
try {
editor.getModel()?.setValue(text)
} finally {
applyingToEditor = false
}
}
function showSlideInEditor(index: number) {
setEditorText(index === -1 ? globalSlide : (slides[index]?.code ?? ''))
}
function setSlide(index: number) {
if (index !== -1 && !slides[index]) {
return
}
currentSlide = index
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
})
compileError.hidden = true
compileError.textContent = ''
if (!runtime) {
runtime = createRuntime(stage, result.slides, {
template: result.template,
onCurrentChange: (index) => setSlide(index)
})
// A reload restores the slide you were on (history state): open
// the editor and the selection there too.
setSlide(runtime.current)
}
runtime.setTemplate(result.template)
runtime.setSlides(result.slides)
runtime.setCss(result.css)
// Follow the slide being edited; the deck may just have grown.
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.
showCompileError(error instanceof Error ? error.message : String(error))
}
}
/**
* Compose a tile exactly as the runtime composes the real thing: the slide in
* its `<section data-ps-slide>` with state `current` (so the compiled state
* rules show the settled look), inside the global template when there is one,
* with the shared markup prepended when there is not. The global tile mounts
* an example slide in the frame instead — it must not show another real
* slide's content, which would go stale whenever that slide is edited.
*/
function buildThumbnailHtml(index: number, code: string): string {
const global = extractStyles(globalSlide).html
const isTemplate = HTML_PLACEHOLDER.test(global)
if (index === -1) {
if (!global.trim()) {
return EXAMPLE_FRAME
}
if (isTemplate) {
return global
.split(HTML_PLACEHOLDER)
.join(EXAMPLE_SECTION)
.split(CSS_PLACEHOLDER)
.join('')
}
return global
}
const extracted = extractStyles(code).html
let inner = extracted
if (!isTemplate && global) {
inner = `${global}\n${extracted}`
}
const section = `<section data-ps-slide="${slideId(index)}" data-ps-state="current">${inner}</section>`
if (isTemplate) {
return global.split(HTML_PLACEHOLDER).join(section).split(CSS_PLACEHOLDER).join('')
}
return section
}
async function generateSlideThumbnail(slide: Slide, i: number) {
const slideElement = document.createElement('div')
slideElement.classList.add('slide-list-item')
slideElement.dataset.slideId = i.toString()
if (i === -1) {
slideElement.classList.add('global-slide')
} else {
slideElement.draggable = true
}
const slideInList = document.createElement('button')
slideInList.type = 'button'
slideInList.classList.add('slide-in-list')
slideInList.setAttribute('aria-label', i === -1 ? 'Global slide' : `Slide ${i + 1}`)
slideInList.addEventListener('click', () => {
setSlide(Number(slideElement.dataset.slideId))
})
const slideNumber = document.createElement('span')
slideNumber.classList.add('slide-number')
slideNumber.textContent = i === -1 ? 'G' : `${i + 1}`
// 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)
if (i !== -1) {
const deleteButton = document.createElement('button')
deleteButton.type = 'button'
deleteButton.classList.add('delete-slide')
deleteButton.setAttribute('aria-label', `Delete slide ${i + 1}`)
deleteButton.textContent = '×'
deleteButton.addEventListener('click', () => {
deleteSlide(Number(slideElement.dataset.slideId))
})
slideElement.append(deleteButton)
}
if (i === currentSlide) {
slideElement.classList.add('selected')
}
return slideElement
}
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)
// 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
}
oldSlideElement?.replaceWith(slideElement)
if (index === -1) {
globalSlideElement = slideElement
} else {
slideElements.set(slide, slideElement)
}
updateSelection()
}
const THUMBNAIL_CONCURRENCY = 6
async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
map: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results: R[] = []
let nextIndex = 0
async function worker() {
while (nextIndex < items.length) {
const index = nextIndex++
// oxlint-disable-next-line no-await-in-loop -- each worker claims one index at a time; the await is what bounds concurrency
results[index] = await map(items[index], index)
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
return results
}
async function setSlideList() {
const generation = ++listGeneration
const allSlides = [{ code: globalSlide }, ...slides]
const elements = await mapWithConcurrency(allSlides, THUMBNAIL_CONCURRENCY, (slide, index) =>
generateSlideThumbnail(slide, index - 1)
)
// 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()
}
function updateSlideOrder() {
if (globalSlideElement) {
globalSlideElement.dataset.slideId = '-1'
slideList.append(globalSlideElement)
}
for (const [index, slide] of slides.entries()) {
const slideElement = slideElements.get(slide)
if (!slideElement) {
continue
}
slideElement.dataset.slideId = index.toString()
const slideButton = slideElement.querySelector<HTMLButtonElement>('.slide-in-list')
slideButton?.setAttribute('aria-label', `Slide ${index + 1}`)
const slideNumber = slideElement.querySelector<HTMLElement>('.slide-number')
if (slideNumber) {
slideNumber.textContent = `${index + 1}`
}
slideElement
.querySelector<HTMLButtonElement>('.delete-slide')
?.setAttribute('aria-label', `Delete slide ${index + 1}`)
slideList.append(slideElement)
}
updateSelection()
}
function moveSlide(from: number, to: number) {
if (from === to || from < 0 || to < 0) {
return
}
const [movedSlide] = slides.splice(from, 1)
slides.splice(to, 0, movedSlide)
if (currentSlide === from) {
currentSlide = to
} else if (from < currentSlide && to >= currentSlide) {
currentSlide--
} else if (from > currentSlide && to <= currentSlide) {
currentSlide++
}
updateSlideOrder()
showSlideInEditor(currentSlide)
scheduleCompile()
}
function deleteSlide(index: number) {
if (index < 0 || slides.length <= 1) {
return
}
const [deletedSlide] = slides.splice(index, 1)
slideElements.get(deletedSlide)?.remove()
slideElements.delete(deletedSlide)
if (index < currentSlide) {
currentSlide--
} else if (index === currentSlide) {
currentSlide = Math.min(currentSlide, slides.length - 1)
}
updateSlideOrder()
setSlide(currentSlide)
scheduleCompile()
}
addSlideButton.addEventListener('click', async () => {
const slide = { code: '' }
slides.push(slide)
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', () => {
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) => {
const slideElement = (event.target as HTMLElement).closest<HTMLElement>('.slide-list-item')
const index = slideElement ? Number(slideElement.dataset.slideId) : -1
if (index < 0) {
event.preventDefault()
return
}
draggedSlide = index
event.dataTransfer?.setData('text/plain', index.toString())
})
slideList.addEventListener('dragover', (event) => {
if (draggedSlide !== null) {
event.preventDefault()
}
})
slideList.addEventListener('drop', (event) => {
event.preventDefault()
const target = (event.target as HTMLElement).closest<HTMLElement>('.slide-list-item')
const targetIndex = target ? Number(target.dataset.slideId) : -1
if (draggedSlide !== null && targetIndex >= 0) {
moveSlide(draggedSlide, targetIndex)
}
draggedSlide = null
})
slideList.addEventListener('dragend', () => {
draggedSlide = null
})
editor.onDidChangeModelContent(() => {
if (applyingToEditor) {
return
}
const code = editor.getValue()
const slideIndex = currentSlide
const slide = slideIndex === -1 ? { code } : slides[slideIndex]
if (slideIndex === -1) {
globalSlide = code
} else {
slides[slideIndex].code = code
}
scheduleCompile()
if (thumbnailDebounceTimer !== undefined) {
window.clearTimeout(thumbnailDebounceTimer)
}
thumbnailDebounceTimer = window.setTimeout(() => {
thumbnailDebounceTimer = undefined
void refreshThumbnail(slideIndex, slide)
}, 250)
})
async function render() {
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()