forked from jan/psslides
Binding only the `:thisSlide` token still left every untokened rule in a slide's tag compiling deck-wide, so one slide's styles kept applying to all slides. Wrap each slide's whole style block in its section selector instead, with `:thisSlide` resolving to `&` — Less hoists `@keyframes`/ `@font-face` out of the wrapper itself and keeps `@media` nested inside, so only rules end up scoped. The global block stays the shared layer and the deck still compiles exactly once.
556 lines
18 KiB
TypeScript
556 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 a slide's
|
||
// <style> tag is scoped to that slide, the global slide's styles are shared
|
||
// by every slide, and `:thisSlide` is the slide's own section — no extra CSS
|
||
// syntax beyond that.
|
||
const STARTER_STYLE = `// Project styles. This is ordinary Less: variables, nesting,
|
||
// mixins and imports all work as usual.
|
||
//
|
||
// A slide's own style tag is scoped to that slide: every rule in it applies
|
||
// to that slide alone. The global slide's styles are shared by all slides.
|
||
// :thisSlide is the slide's own section, never "the visible slide" — handy
|
||
// for state rules: :thisSlide[data-ps-state='current'] { ... }.
|
||
|
||
@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()
|