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
Example deck
${EXAMPLE_SECTION}
` let globalSlide = '' const slides: Slide[] = [ { code: `\n

test

` } ] const editor = createEditor() 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 presentButton = document.querySelector('.present-button')! let currentSlide = 0 let draggedSlide: number | null = null let globalSlideElement: HTMLElement | null = null const slideElements = new Map() const thumbnailRequests = new Map() 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 ``, 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('.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 { 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 `
` 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 = `
${inner}
` 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( items: T[], limit: number, map: (item: T, index: number) => Promise ): Promise { 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('.slide-in-list') slideButton?.setAttribute('aria-label', `Slide ${index + 1}`) const slideNumber = slideElement.querySelector('.slide-number') if (slideNumber) { slideNumber.textContent = `${index + 1}` } slideElement .querySelector('.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('.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('.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()