forked from jan/psslides
- 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.
554 lines
18 KiB
TypeScript
554 lines
18 KiB
TypeScript
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()
|