feat: render the global slide as a <?= html ?> / <?= css ?> template
This commit is contained in:
parent
2282b13325
commit
a7e463cb94
4 changed files with 168 additions and 16 deletions
13
src/main.ts
13
src/main.ts
|
|
@ -72,12 +72,9 @@ const slideElements = new Map<Slide, HTMLElement>()
|
||||||
const thumbnailRequests = new Map<number, number>()
|
const thumbnailRequests = new Map<number, number>()
|
||||||
let thumbnailDebounceTimer: number | undefined
|
let thumbnailDebounceTimer: number | undefined
|
||||||
|
|
||||||
// Project CSS lives in its own stylesheet so the preview can be swapped out
|
// The compiled stylesheet is the runtime's business: it lands at the global
|
||||||
// without touching the editor's own styles.
|
// slide's `<?= css ?>`, or in a `data-ps-slides` sheet in the head when the
|
||||||
const slideStyles = document.createElement('style')
|
// scene has no injection point.
|
||||||
slideStyles.setAttribute('data-ps-slides', '')
|
|
||||||
document.head.append(slideStyles)
|
|
||||||
|
|
||||||
let runtime: Runtime | null = null
|
let runtime: Runtime | null = null
|
||||||
let compileDebounceTimer: number | undefined
|
let compileDebounceTimer: number | undefined
|
||||||
// Set while we push text into Monaco ourselves, so programmatic changes are
|
// Set while we push text into Monaco ourselves, so programmatic changes are
|
||||||
|
|
@ -141,11 +138,11 @@ async function rebuildPreview(): Promise<void> {
|
||||||
global: globalSlide
|
global: globalSlide
|
||||||
})
|
})
|
||||||
|
|
||||||
slideStyles.textContent = result.css
|
|
||||||
compileError.hidden = true
|
compileError.hidden = true
|
||||||
compileError.textContent = ''
|
compileError.textContent = ''
|
||||||
|
|
||||||
if (runtime) {
|
if (runtime) {
|
||||||
|
runtime.setTemplate(result.template)
|
||||||
runtime.setSlides(result.slides)
|
runtime.setSlides(result.slides)
|
||||||
// Follow the slide being edited; the deck may just have grown.
|
// Follow the slide being edited; the deck may just have grown.
|
||||||
if (currentSlide >= 0) {
|
if (currentSlide >= 0) {
|
||||||
|
|
@ -153,9 +150,11 @@ async function rebuildPreview(): Promise<void> {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
runtime = createRuntime(stage, result.slides, {
|
runtime = createRuntime(stage, result.slides, {
|
||||||
|
template: result.template,
|
||||||
onCurrentChange: (index) => setSlide(index)
|
onCurrentChange: (index) => setSlide(index)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
runtime.setCss(result.css)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Half-typed Less should not blank the preview: keep the last good
|
// Half-typed Less should not blank the preview: keep the last good
|
||||||
// output on screen and report what broke.
|
// output on screen and report what broke.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import less from 'less'
|
import less from 'less'
|
||||||
|
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './placeholders'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A slide's stable id. Deterministic for a given position, so recompiling the
|
* A slide's stable id. Deterministic for a given position, so recompiling the
|
||||||
|
|
@ -14,6 +15,12 @@ function slideSelector(id: string): string {
|
||||||
|
|
||||||
const THIS_SLIDE = ':thisSlide'
|
const THIS_SLIDE = ':thisSlide'
|
||||||
|
|
||||||
|
// `<?= … ?>` is a bogus comment to the HTML parser, so a placeholder would not
|
||||||
|
// survive the DOM round-trip inside extractStyles. Hide the placeholders as
|
||||||
|
// plain text while parsing, put them back afterwards.
|
||||||
|
const HTML_SENTINEL = '__PSHTML__'
|
||||||
|
const CSS_SENTINEL = '__PSCSS__'
|
||||||
|
|
||||||
export interface CompiledSlide {
|
export interface CompiledSlide {
|
||||||
/** Stable id, used as the value of the slide's `data-ps-slide` attribute. */
|
/** Stable id, used as the value of the slide's `data-ps-slide` attribute. */
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -24,13 +31,22 @@ export interface CompiledSlide {
|
||||||
export interface CompileInput {
|
export interface CompileInput {
|
||||||
/** HTML source of every slide, in presentation order. */
|
/** HTML source of every slide, in presentation order. */
|
||||||
slides: string[]
|
slides: string[]
|
||||||
/** HTML shared by every slide (the "global" slide in the editor). */
|
/**
|
||||||
|
* The global slide: either markup shared by every slide, or a template
|
||||||
|
* carrying `<?= html ?>` / `<?= css ?>` for the runtime to fill in.
|
||||||
|
*/
|
||||||
global?: string
|
global?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompiledPresentation {
|
export interface CompiledPresentation {
|
||||||
slides: CompiledSlide[]
|
slides: CompiledSlide[]
|
||||||
css: string
|
css: string
|
||||||
|
/**
|
||||||
|
* The global slide, ready for the runtime. A template (`<?= html ?>`
|
||||||
|
* inside) is rendered once as the scene; anything else was already merged
|
||||||
|
* into every slide and the runtime ignores it.
|
||||||
|
*/
|
||||||
|
template: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -39,26 +55,53 @@ export interface CompiledPresentation {
|
||||||
* The markup stays HTML and the tag contents become the project's Less, so
|
* The markup stays HTML and the tag contents become the project's Less, so
|
||||||
* authors write styles right in the slide they are editing. The tags are
|
* authors write styles right in the slide they are editing. The tags are
|
||||||
* removed from the markup because their raw Less must never reach the DOM —
|
* removed from the markup because their raw Less must never reach the DOM —
|
||||||
* the compiled CSS is injected separately.
|
* the compiled CSS is injected separately, at `<?= css ?>`. A style tag that
|
||||||
|
* *is* that injection point is left alone.
|
||||||
*/
|
*/
|
||||||
export function extractStyles(markup: string): { html: string; style: string } {
|
export function extractStyles(markup: string): { html: string; style: string } {
|
||||||
if (!markup.includes('<style')) {
|
if (!markup.includes('<style')) {
|
||||||
return { html: markup, style: '' }
|
return { html: markup, style: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = new DOMParser().parseFromString(markup, 'text/html')
|
const hidden = markup
|
||||||
|
.split(HTML_PLACEHOLDER)
|
||||||
|
.join(HTML_SENTINEL)
|
||||||
|
.split(CSS_PLACEHOLDER)
|
||||||
|
.join(CSS_SENTINEL)
|
||||||
|
const parsed = new DOMParser().parseFromString(hidden, 'text/html')
|
||||||
|
|
||||||
const blocks: string[] = []
|
const blocks: string[] = []
|
||||||
for (const element of Array.from(parsed.querySelectorAll('style'))) {
|
for (const element of Array.from(parsed.querySelectorAll('style'))) {
|
||||||
blocks.push(element.textContent ?? '')
|
const source = element.textContent ?? ''
|
||||||
|
if (source.includes(CSS_SENTINEL)) {
|
||||||
|
// This tag is where the compiled CSS gets injected, not authoring.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
blocks.push(source)
|
||||||
element.remove()
|
element.remove()
|
||||||
}
|
}
|
||||||
return { html: parsed.body.innerHTML, style: blocks.join('\n') }
|
|
||||||
|
const html = parsed.body.innerHTML
|
||||||
|
.split(HTML_SENTINEL)
|
||||||
|
.join('<?= html ?>')
|
||||||
|
.split(CSS_SENTINEL)
|
||||||
|
.join('<?= css ?>')
|
||||||
|
return { html, style: blocks.join('\n') }
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSlideHtml(markup: string, global: string): string {
|
function buildSlideHtml(markup: string, global: string): string {
|
||||||
return global ? `${global}\n${markup}` : markup
|
return global ? `${global}\n${markup}` : markup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The global slide is a template when it carries `<?= html ?>`: the runtime
|
||||||
|
* renders it once and mounts the slides at the placeholder, so its markup must
|
||||||
|
* not be copied into every slide.
|
||||||
|
*/
|
||||||
|
function isGlobalTemplate(global: string): boolean {
|
||||||
|
return HTML_PLACEHOLDER.test(global)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compile a project into runtime-ready data.
|
* Compile a project into runtime-ready data.
|
||||||
*
|
*
|
||||||
|
|
@ -70,6 +113,7 @@ function buildSlideHtml(markup: string, global: string): string {
|
||||||
*/
|
*/
|
||||||
export async function compilePresentation(input: CompileInput): Promise<CompiledPresentation> {
|
export async function compilePresentation(input: CompileInput): Promise<CompiledPresentation> {
|
||||||
const global = extractStyles(input.global ?? '')
|
const global = extractStyles(input.global ?? '')
|
||||||
|
const globalIsTemplate = isGlobalTemplate(global.html)
|
||||||
const sources: string[] = [global.style]
|
const sources: string[] = [global.style]
|
||||||
|
|
||||||
const slides: CompiledSlide[] = input.slides.map((markup, index) => {
|
const slides: CompiledSlide[] = input.slides.map((markup, index) => {
|
||||||
|
|
@ -78,7 +122,7 @@ export async function compilePresentation(input: CompileInput): Promise<Compiled
|
||||||
return {
|
return {
|
||||||
id: slideId(index),
|
id: slideId(index),
|
||||||
index,
|
index,
|
||||||
html: buildSlideHtml(extracted.html, global.html)
|
html: globalIsTemplate ? extracted.html : buildSlideHtml(extracted.html, global.html)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -86,7 +130,7 @@ export async function compilePresentation(input: CompileInput): Promise<Compiled
|
||||||
sources.join('\n'),
|
sources.join('\n'),
|
||||||
slides.map((slide) => slide.id)
|
slides.map((slide) => slide.id)
|
||||||
)
|
)
|
||||||
return { slides, css }
|
return { slides, css, template: global.html }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function compileStyles(source: string, ids: string[]): Promise<string> {
|
async function compileStyles(source: string, ids: string[]): Promise<string> {
|
||||||
|
|
|
||||||
13
src/slides/placeholders.ts
Normal file
13
src/slides/placeholders.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Injection points the runtime fills in the global slide.
|
||||||
|
*
|
||||||
|
* A global slide carrying `<?= html ?>` is a template: the runtime renders it
|
||||||
|
* once as the scene, mounts the slide markup where `html` is, and injects the
|
||||||
|
* compiled stylesheet where `css` is.
|
||||||
|
*
|
||||||
|
* Shared by the compiler (which must not mistake a placeholder for Less) and
|
||||||
|
* the runtime (which replaces them) — its own module so the runtime never
|
||||||
|
* imports the Less compiler. Non-global regexes, so `.test()` is stateless.
|
||||||
|
*/
|
||||||
|
export const HTML_PLACEHOLDER = /<\?=\s*html\s*\?>/
|
||||||
|
export const CSS_PLACEHOLDER = /<\?=\s*css\s*\?>/
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { CompiledSlide } from './compile'
|
import type { CompiledSlide } from './compile'
|
||||||
|
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './placeholders'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slide lifecycle. The runtime moves slides through these states; CSS owns how
|
* Slide lifecycle. The runtime moves slides through these states; CSS owns how
|
||||||
|
|
@ -20,6 +21,12 @@ export interface RuntimeOptions {
|
||||||
keyboard?: boolean
|
keyboard?: boolean
|
||||||
/** Called whenever the current slide changes. */
|
/** Called whenever the current slide changes. */
|
||||||
onCurrentChange?: (index: number) => void
|
onCurrentChange?: (index: number) => void
|
||||||
|
/**
|
||||||
|
* The global slide. When it carries `<?= html ?>` it becomes the scene:
|
||||||
|
* rendered once, slides mounted where `html` is, the compiled stylesheet
|
||||||
|
* injected where `css` is.
|
||||||
|
*/
|
||||||
|
template?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Runtime {
|
export interface Runtime {
|
||||||
|
|
@ -29,6 +36,10 @@ export interface Runtime {
|
||||||
previous(): void
|
previous(): void
|
||||||
/** Swap the deck in place (after recompiling) without resetting the view. */
|
/** Swap the deck in place (after recompiling) without resetting the view. */
|
||||||
setSlides(slides: readonly CompiledSlide[]): void
|
setSlides(slides: readonly CompiledSlide[]): void
|
||||||
|
/** Set the global slide; a template re-renders the scene around the slides. */
|
||||||
|
setTemplate(template: string): void
|
||||||
|
/** Inject the compiled stylesheet at the scene's `<?= css ?>`, or in the head. */
|
||||||
|
setCss(css: string): void
|
||||||
readonly current: number
|
readonly current: number
|
||||||
readonly slides: readonly RuntimeSlide[]
|
readonly slides: readonly RuntimeSlide[]
|
||||||
/** Remove the runtime, its listeners and its DOM. */
|
/** Remove the runtime, its listeners and its DOM. */
|
||||||
|
|
@ -45,6 +56,23 @@ const HISTORY_KEY = 'psslides-slide'
|
||||||
/** Images in the incoming slide get this long to load before we show it. */
|
/** Images in the incoming slide get this long to load before we show it. */
|
||||||
const ASSET_TIMEOUT_MS = 1000
|
const ASSET_TIMEOUT_MS = 1000
|
||||||
|
|
||||||
|
/** Stands in for `<?= html ?>`: the element slides get mounted into. */
|
||||||
|
const MOUNT_POINT = '<div data-ps-html-mount></div>'
|
||||||
|
const MOUNT_SELECTOR = '[data-ps-html-mount]'
|
||||||
|
|
||||||
|
// A scene without `<?= css ?>` still needs the compiled CSS, and it belongs in
|
||||||
|
// exactly one place.
|
||||||
|
let fallbackSheet: HTMLStyleElement | null = null
|
||||||
|
|
||||||
|
function headSheet(): HTMLStyleElement {
|
||||||
|
if (!fallbackSheet) {
|
||||||
|
fallbackSheet = document.createElement('style')
|
||||||
|
fallbackSheet.setAttribute('data-ps-slides', '')
|
||||||
|
document.head.append(fallbackSheet)
|
||||||
|
}
|
||||||
|
return fallbackSheet
|
||||||
|
}
|
||||||
|
|
||||||
class SlideRecord {
|
class SlideRecord {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly index: number
|
readonly index: number
|
||||||
|
|
@ -165,9 +193,15 @@ export function createRuntime(
|
||||||
restored === null ? 0 : Math.min(Math.max(restored, 0), Math.max(records.length - 1, 0))
|
restored === null ? 0 : Math.min(Math.max(restored, 0), Math.max(records.length - 1, 0))
|
||||||
let generation = 0
|
let generation = 0
|
||||||
let destroyed = false
|
let destroyed = false
|
||||||
|
let template = options.template ?? ''
|
||||||
|
let mountPoint: HTMLElement = container
|
||||||
|
|
||||||
container.setAttribute('data-psslides-runtime', '')
|
container.setAttribute('data-psslides-runtime', '')
|
||||||
|
|
||||||
|
// The global slide, when it is a template, owns the scene the slides live
|
||||||
|
// in; render it first so they mount into the right place.
|
||||||
|
renderShell()
|
||||||
|
|
||||||
function setState(record: SlideRecord, state: SlideState): void {
|
function setState(record: SlideRecord, state: SlideState): void {
|
||||||
if (record.state === state) {
|
if (record.state === state) {
|
||||||
return
|
return
|
||||||
|
|
@ -257,7 +291,7 @@ export function createRuntime(
|
||||||
// Rebuilding the DOM restarts transitions, so drop in-flight work and
|
// Rebuilding the DOM restarts transitions, so drop in-flight work and
|
||||||
// settle into an explicit state instead of leaving a half-transition.
|
// settle into an explicit state instead of leaving a half-transition.
|
||||||
generation++
|
generation++
|
||||||
container.replaceChildren(...records.map((record) => record.element))
|
mountPoint.replaceChildren(...records.map((record) => record.element))
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
setState(record, record.index === current ? 'current' : 'idle')
|
setState(record, record.index === current ? 'current' : 'idle')
|
||||||
}
|
}
|
||||||
|
|
@ -308,7 +342,7 @@ export function createRuntime(
|
||||||
record.state = 'current'
|
record.state = 'current'
|
||||||
record.element.setAttribute('data-ps-state', 'current')
|
record.element.setAttribute('data-ps-state', 'current')
|
||||||
}
|
}
|
||||||
container.append(record.element)
|
mountPoint.append(record.element)
|
||||||
})
|
})
|
||||||
|
|
||||||
function onPopState(): void {
|
function onPopState(): void {
|
||||||
|
|
@ -324,11 +358,73 @@ export function createRuntime(
|
||||||
}
|
}
|
||||||
window.addEventListener('popstate', onPopState)
|
window.addEventListener('popstate', onPopState)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the global slide. A template (`<?= html ?>` inside) is rendered once
|
||||||
|
* as the scene — slides are mounted where the placeholder was, `<?= css ?>`
|
||||||
|
* is filled by setCss. Anything else leaves the slide tree alone; its
|
||||||
|
* markup was already merged into the slides by the compiler.
|
||||||
|
*/
|
||||||
|
function setTemplate(value: string): void {
|
||||||
|
if (destroyed || value === template) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
template = value
|
||||||
|
renderShell()
|
||||||
|
// The shell was rebuilt, but the slide elements themselves survive as
|
||||||
|
// objects: re-home them instead of recreating anything.
|
||||||
|
mountPoint.append(...records.map((record) => record.element))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject the compiled stylesheet. A `<style>` holding `<?= css ?>` inside
|
||||||
|
* the container takes it in place — tagged `data-ps-css` so recompiles
|
||||||
|
* keep aiming at it once the placeholder text is gone. With no such target
|
||||||
|
* the runtime keeps one stylesheet in the document head.
|
||||||
|
*/
|
||||||
|
function setCss(css: string): void {
|
||||||
|
if (destroyed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let injected = false
|
||||||
|
for (const style of Array.from(container.querySelectorAll('style'))) {
|
||||||
|
const isTarget =
|
||||||
|
style.hasAttribute('data-ps-css') || CSS_PLACEHOLDER.test(style.textContent ?? '')
|
||||||
|
if (!isTarget) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
style.setAttribute('data-ps-css', '')
|
||||||
|
style.textContent = css
|
||||||
|
injected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (injected) {
|
||||||
|
// Never let a stale head copy fight the scene's own styles.
|
||||||
|
if (fallbackSheet) {
|
||||||
|
fallbackSheet.textContent = ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
headSheet().textContent = css
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the global slide as the scene; plain markup leaves the DOM alone. */
|
||||||
|
function renderShell(): void {
|
||||||
|
mountPoint = container
|
||||||
|
if (!template || !HTML_PLACEHOLDER.test(template)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
container.innerHTML = template.split(HTML_PLACEHOLDER).join(MOUNT_POINT)
|
||||||
|
mountPoint = container.querySelector<HTMLElement>(MOUNT_SELECTOR) ?? container
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
goTo,
|
goTo,
|
||||||
next,
|
next,
|
||||||
previous,
|
previous,
|
||||||
setSlides,
|
setSlides,
|
||||||
|
setTemplate,
|
||||||
|
setCss,
|
||||||
get current() {
|
get current() {
|
||||||
return current
|
return current
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue