1
0
Fork 0
forked from jan/psslides
This commit is contained in:
jan 2026-10-01 14:50:27 +02:00
parent 7d01ec881e
commit fa1467e7a8
5 changed files with 596 additions and 109 deletions

View file

@ -1,10 +1,207 @@
import * as monaco from 'monaco-editor'
import { emmetCSS, emmetHTML } from 'emmet-monaco-es'
import { emmetCSS, expandAbbreviation } from 'emmet-monaco-es'
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
emmetHTML(monaco, ['html'])
/**
* Emmet, wired by hand — see the note on the provider below.
*/
/**
* Characters Emmet accepts in an abbreviation. `<` is excluded: it opens a tag
* or a placeholder, never an abbreviation. `>` is included — it is Emmet's
* child combinator (`ul>li*3`).
*/
const ABBREVIATION_CHAR = /[\w!@#$%^&*()[\]{}:;'"~=+./>\\-]/
const CLOSERS: Record<string, string> = { '{': '}', '(': ')', '[': ']' }
/** Matches a `<style>`/`</style>` open or close tag, for depth counting. */
const STYLE_TAG = /<\/?style\b[^>]*>/gi
/** Same trigger set the library used, so the widget appears at the same moments. */
const TRIGGER_CHARACTERS = [
'!',
'.',
'}',
':',
'*',
'$',
']',
'/',
'>',
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9'
]
/**
* Whether the cursor sits inside a tag, an attribute, or a placeholder.
*
* Read off the line rather than the tokenizer — that decision is the one the
* library got wrong. Scanned only as far as the cursor, so a last `<` beating
* the last `>` means "inside": an unfinished tag, or a `<?= html ?>`.
*/
function insideTag(line: string, column: number): boolean {
const before = line.slice(0, column - 1)
return before.lastIndexOf('<') > before.lastIndexOf('>')
}
/**
* Whether the cursor is inside a `<style>` element, where the text is Less and
* an HTML abbreviation would only produce noise — `margin` would become
* `<margin></margin>`, and `10px;` makes Emmet throw.
*/
function insideStyleElement(model: monaco.editor.ITextModel, position: monaco.Position): boolean {
const before = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column
})
let depth = 0
for (const match of before.matchAll(STYLE_TAG)) {
depth += match[0][1] === '/' ? -1 : 1
}
return depth > 0
}
/**
* The abbreviation ending at the cursor, and the column it starts in.
*
* `column` is 1-based, as Monaco counts: the characters before the cursor are
* `line[0 .. column - 2]`, hence the scan starting a character back from it.
* Bracketed runs are kept whole so `p{hello world}` and `a[href="x"]` survive,
* the way the library's own scanner handled them.
*/
function extractAbbreviation(
line: string,
column: number
): { text: string; startColumn: number } | null {
if (insideTag(line, column)) {
return null
}
const closers: string[] = []
let index = column - 2
while (index >= 0) {
const character = line[index]
if (character === ' ' || character === '\t') {
// Legal inside `{…}`, a separator anywhere else.
if (closers.length === 0) {
break
}
} else if (character === '}' || character === ')' || character === ']') {
closers.push(character)
} else if (character === '{' || character === '(' || character === '[') {
if (closers.length === 0) {
break
}
if (closers[closers.length - 1] === CLOSERS[character]) {
closers.pop()
}
} else if (closers.length === 0 && !ABBREVIATION_CHAR.test(character)) {
break
}
index--
}
// A stray `}` with no opener behind it lets the scan walk back over the
// indentation, so leading whitespace is dropped rather than matched.
let start = index + 1
while (start < column - 1 && (line[start] === ' ' || line[start] === '\t')) {
start++
}
// The cursor may sit in text while the scan has walked back into a tag —
// `class="parent">` is reachable from the end of `<div class="parent">`.
// The candidate has to be outside a tag at *both* ends.
if (insideTag(line, start + 1)) {
return null
}
const text = line.slice(start, column - 1)
return text.length > 0 ? { text, startColumn: start + 1 } : null
}
/**
* Emmet, wired by hand.
*
* `emmetHTML` decides where Emmet may expand by asking the tokenizer for the
* state of the line *before* the cursor — `getBeginState(lineNumber - 1)`
* against a 1-based API. That is wrong twice over. It returns `null` on line 1
* and the library clones the result unconditionally, so the first expansion
* throws `Cannot read properties of null (reading 'clone')`; and below a
* `<style>` block it hands the HTML tokenizer the CSS state from inside the
* block, so nothing expands anywhere after it — which is why the global slide
* went dead while ordinary slides worked. Its `tokenizer: 'standard'` escape
* hatch is no help on this Monaco: it calls `model.tokenization.getLineTokens`,
* minified to `.n` in 0.55, so the library's feature-detect fails and quietly
* falls back to the same broken path.
*
* Expansion itself is sound and exported, so only the location check moves here:
* read the abbreviation back off the line, refuse inside a tag, expand. The
* result is inserted as plain text — `expandAbbreviation` normalises its own
* `${…}` tabstops away, so snippet mode would buy nothing and would mangle
* author text containing `$1`.
*/
monaco.languages.registerCompletionItemProvider('html', {
triggerCharacters: TRIGGER_CHARACTERS,
provideCompletionItems(model, position) {
if (insideStyleElement(model, position)) {
return undefined
}
const found = extractAbbreviation(
model.getLineContent(position.lineNumber),
position.column
)
if (!found) {
return undefined
}
let expanded: string
try {
expanded = expandAbbreviation(found.text, { type: 'markup', syntax: 'html' })
} catch {
// A bad abbreviation must never take completion — or the editor —
// down with it.
return undefined
}
// Expanding to the abbreviation itself, or to nothing, is just noise.
if (!expanded || expanded === found.text) {
return undefined
}
return {
suggestions: [
{
label: found.text,
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: expanded,
range: new monaco.Range(
position.lineNumber,
found.startColumn,
position.lineNumber,
position.column
)
}
]
}
}
})
emmetCSS(monaco, ['css', 'scss', 'less'])
self.MonacoEnvironment = {
@ -26,7 +223,7 @@ const MONACO_PARENT = '.code'
* the Less lives inside that tag, so there is nothing to switch between. The
* CSS/LESS workers above still highlight, validate and complete that Less.
*/
export function createEditor(): monaco.editor.IStandaloneCodeEditor {
function createEditor(): monaco.editor.IStandaloneCodeEditor {
return monaco.editor.create(document.querySelector(MONACO_PARENT)!, {
value: '',
language: 'html',
@ -34,3 +231,5 @@ export function createEditor(): monaco.editor.IStandaloneCodeEditor {
automaticLayout: true
})
}
export const editor = createEditor()

301
src/html-worker.ts Normal file
View file

@ -0,0 +1,301 @@
import { initialize } from 'monaco-editor/esm/vs/common/initialize.js'
import {
getLanguageService,
newHTMLDataProvider
} from 'monaco-editor/esm/external/vscode-html-languageservice/lib/esm/htmlLanguageService.js'
import { getLESSLanguageService } from 'monaco-editor/esm/external/vscode-css-languageservice/lib/esm/cssLanguageService.js'
import { TextDocument } from 'monaco-editor/esm/external/vscode-languageserver-textdocument/lib/esm/main.js'
/**
* The HTML worker, with Less wired into `<style>`.
*
* Monaco's own HTML worker never gives you Less completions. Upstream's
* `embeddedSupport` — the `getDefaultEmbeddedSupport` that VS Code's
* `vscode-html-language-features` extension uses — is absent from the copy
* monaco vendors, and so is the `document.stylesheets` model it walks; the
* `setCompletionParticipants` hook survives but nothing behind it does. So this
* does the dispatch itself: a position inside a `<style>` element goes to the
* Less language service, everything else to HTML as before.
*
* Always Less, never CSS, whatever the tag says. That matches the app: every
* `<style>` block is compiled by the Less compiler regardless of its attributes
* (`extractStyles` only ever hands the tag's text to Less), so a `lang="css"`
* hint would only be a lie.
*
* The virtual document holds just the stylesheet's text, and every coordinate
* crosses between the two documents through `offsetAt`/`positionAt` rather than
* hand-rolled line arithmetic — a `<style>` that opens mid-line is the case that
* breaks naive line maths.
*/
interface Position {
line: number
character: number
}
interface Range {
start: Position
end: Position
}
/** The text between a `<style>` element's tags, as offsets into the document. */
interface StyleBlock {
start: number
end: number
}
const STYLE_OPEN = /<style\b[^>]*>/gi
const STYLE_CLOSE = /<\/style\s*>/gi
/** Every `<style>` element's content range, in the order they appear. */
function findStyleBlocks(text: string): StyleBlock[] {
const blocks: StyleBlock[] = []
STYLE_OPEN.lastIndex = 0
for (let open = STYLE_OPEN.exec(text); open; open = STYLE_OPEN.exec(text)) {
const contentStart = open.index + open[0].length
STYLE_CLOSE.lastIndex = contentStart
const close = STYLE_CLOSE.exec(text)
if (!close) {
break
}
blocks.push({ start: contentStart, end: close.index })
// Continue past the close tag so a following block is still found.
STYLE_OPEN.lastIndex = close.index + close[0].length
}
return blocks
}
/** The stylesheet the position is in, or null when it is in ordinary markup. */
function styleBlockAt(blocks: StyleBlock[], offset: number): StyleBlock | null {
for (const block of blocks) {
if (offset >= block.start && offset <= block.end) {
return block
}
}
return null
}
/**
* A range from the stylesheet, in the HTML document's coordinates.
*
* Both documents do their own arithmetic: `lessDocument.offsetAt` gives the
* offset within the stylesheet text, `document.positionAt` turns the shifted
* offset back into a line/character in the HTML buffer.
*/
function toDocumentRange(document, lessDocument, block: StyleBlock, range: Range): Range {
return {
start: document.positionAt(block.start + lessDocument.offsetAt(range.start)),
end: document.positionAt(block.start + lessDocument.offsetAt(range.end))
}
}
class PsSlidesHtmlWorker {
private readonly ctx
private readonly languageSettings
private readonly languageId
private readonly htmlLanguageService
private readonly lessLanguageService
constructor(ctx, createData) {
this.ctx = ctx
this.languageSettings = createData.languageSettings
this.languageId = createData.languageId
const data = this.languageSettings.data
const customDataProviders = []
if (data?.dataProviders) {
for (const id in data.dataProviders) {
customDataProviders.push(newHTMLDataProvider(id, data.dataProviders[id]))
}
}
this.htmlLanguageService = getLanguageService({
useDefaultDataProvider: data?.useDefaultDataProvider,
customDataProviders
})
this.lessLanguageService = getLESSLanguageService()
}
async doComplete(uri, position) {
const document = this.getTextDocument(uri)
if (!document) {
return null
}
const block = styleBlockAt(findStyleBlocks(document.getText()), document.offsetAt(position))
if (block) {
return this.lessComplete(document, position, block)
}
return Promise.resolve(
this.htmlLanguageService.doComplete(
document,
position,
this.htmlLanguageService.parseHTMLDocument(document),
this.languageSettings?.suggest
)
)
}
/**
* Completions from the Less service, with every edit translated back into
* the HTML document's coordinates.
*/
lessComplete(document, position, block) {
const lessDocument = this.lessDocument(document, block)
const result = this.lessLanguageService.doComplete(
lessDocument,
lessDocument.positionAt(document.offsetAt(position) - block.start),
null,
this.languageSettings?.suggest
)
if (!result) {
return null
}
return {
isIncomplete: result.isIncomplete,
items: result.items.map((item) => ({
...item,
textEdit: item.textEdit
? {
range: toDocumentRange(document, lessDocument, block, item.textEdit.range),
newText: item.textEdit.newText
}
: undefined
}))
}
}
async doHover(uri, position) {
const document = this.getTextDocument(uri)
if (!document) {
return null
}
const block = styleBlockAt(findStyleBlocks(document.getText()), document.offsetAt(position))
if (block) {
const lessDocument = this.lessDocument(document, block)
const hover = this.lessLanguageService.doHover(
lessDocument,
lessDocument.positionAt(document.offsetAt(position) - block.start),
null,
this.languageSettings?.suggest
)
if (hover) {
return { ...hover, range: toDocumentRange(document, lessDocument, block, hover.range) }
}
return null
}
return Promise.resolve(
this.htmlLanguageService.doHover(
document,
position,
this.htmlLanguageService.parseHTMLDocument(document)
)
)
}
/** The stylesheet's own text, as a document the Less service can read. */
private lessDocument(document, block: StyleBlock) {
return TextDocument.create(
document.uri,
'less',
document.version,
document.getText().slice(block.start, block.end)
)
}
async format(uri, range, options) {
const document = this.getTextDocument(uri)
if (!document) {
return []
}
return Promise.resolve(
this.htmlLanguageService.format(document, range, {
...this.languageSettings.format,
...options
})
)
}
async findDocumentHighlights(uri, position) {
const document = this.getTextDocument(uri)
if (!document) {
return []
}
return Promise.resolve(
this.htmlLanguageService.findDocumentHighlights(
document,
position,
this.htmlLanguageService.parseHTMLDocument(document)
)
)
}
async findDocumentLinks(uri) {
const document = this.getTextDocument(uri)
if (!document) {
return []
}
return Promise.resolve(this.htmlLanguageService.findDocumentLinks(document, null))
}
async findDocumentSymbols(uri) {
const document = this.getTextDocument(uri)
if (!document) {
return []
}
return Promise.resolve(
this.htmlLanguageService.findDocumentSymbols(
document,
this.htmlLanguageService.parseHTMLDocument(document)
)
)
}
async getFoldingRanges(uri, context) {
const document = this.getTextDocument(uri)
if (!document) {
return []
}
return Promise.resolve(this.htmlLanguageService.getFoldingRanges(document, context))
}
async getSelectionRanges(uri, positions) {
const document = this.getTextDocument(uri)
if (!document) {
return []
}
return Promise.resolve(
this.htmlLanguageService.getSelectionRanges(
document,
positions,
this.htmlLanguageService.parseHTMLDocument(document)
)
)
}
async doRename(uri, position, newName) {
const document = this.getTextDocument(uri)
if (!document) {
return null
}
return Promise.resolve(
this.htmlLanguageService.doRename(
document,
position,
newName,
this.htmlLanguageService.parseHTMLDocument(document)
)
)
}
private getTextDocument(uri) {
const models = this.ctx.getMirrorModels()
for (const model of models) {
if (model.uri.toString() === uri) {
return TextDocument.create(uri, this.languageId, model.version, model.getValue())
}
}
return null
}
}
self.onmessage = () => {
initialize((ctx, createData) => new PsSlidesHtmlWorker(ctx, createData))
}

View file

@ -1,10 +1,11 @@
import './style.css'
import { createEditor } from './editor'
import { editor } from './editor'
import { createCodeThumbnail } from './code-to-element'
import {
compilePresentation,
extractStyles,
slideId,
type CompileInput,
type CompiledPresentation
} from './slides/compile'
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './slides/placeholders'
@ -14,103 +15,32 @@ 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'] { ... }.
// Slides start empty: the author writes the markup and the Less themselves.
// 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.
@bg: #101010;
@fg: #f0f0f0;
@accent: #8fc98f;
// Sample markup standing in for a real slide. The global-slide tile and the
// preview while the global slide is selected both show it, so the author can
// see what the global slide wraps instead of a blank frame.
const EXAMPLE_CONTENT = '<h1>p/slides</h1><p>This is a paragraph.</p>'
: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;
}
const EXAMPLE_SECTION = `<section data-ps-slide="${slideId(0)}" data-ps-state="current">${EXAMPLE_CONTENT}</section>`
// 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>`
const EXAMPLE_FRAME = `${EXAMPLE_SECTION}`
let globalSlide = ''
const slides: Slide[] = [
{
code: `<style>\n${STARTER_STYLE}\n</style>\n<h1>test</h1>`
code: ''
}
]
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 slide = document.querySelector<HTMLElement>('.slide')!
const compileError = document.querySelector<HTMLElement>('.compile-error')!
const presentButton = document.querySelector<HTMLButtonElement>('.present-button')!
let currentSlide = 0
@ -119,8 +49,9 @@ 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.
// The last good compile of the deck: the preview shows it, and 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.
@ -160,9 +91,21 @@ function setSlide(index: number) {
return
}
const wasGlobal = currentSlide === -1
currentSlide = index
slide.classList.toggle('global', index === -1)
showSlideInEditor(index)
updateSelection()
if (wasGlobal !== (index === -1)) {
// The two selections compile different things, so the runtime is
// holding the wrong one. Rebuild now rather than on the debounce, and
// swap the deck before moving: goTo against the other deck would clamp
// and report the clamp back through onCurrentChange.
void rebuildPreview()
return
}
// The global slide is shared markup, not a step of the presentation, so it
// never moves the runtime.
if (index >= 0) {
@ -185,12 +128,40 @@ function scheduleCompile() {
}, 250)
}
/**
* The global slide is shared markup, not a step of the presentation, so while
* it is selected the preview shows it wrapped around the example content
* instead of one of the real slides. That is a separate compile from the deck —
* and the deck's is needed regardless, because the tiles mirror it.
*/
function deckInput(): CompileInput {
return { slides: slides.map((slide) => slide.code), global: globalSlide }
}
function exampleInput(): CompileInput {
return { slides: [EXAMPLE_CONTENT], global: globalSlide }
}
/**
* Put a compile on screen. Synchronous, so the deck lands the moment its
* compile resolves.
*/
function showCompiled(result: CompiledPresentation): void {
runtime?.setTemplate(result.template)
// The global-slide preview is a one-slide stand-in: keep the real
// current slide in history so returning to the deck lands where it was.
runtime?.setSlides(result.slides, { keepIndex: currentSlide === -1 })
runtime?.setCss(result.css)
if (currentSlide >= 0) {
runtime?.goTo(currentSlide)
}
}
async function rebuildPreview(): Promise<void> {
const showingGlobal = currentSlide === -1
try {
const result = await compilePresentation({
slides: slides.map((slide) => slide.code),
global: globalSlide
})
const result = await compilePresentation(deckInput())
const example = showingGlobal ? await compilePresentation(exampleInput()) : null
compileError.hidden = true
compileError.textContent = ''
@ -204,13 +175,9 @@ async function rebuildPreview(): Promise<void> {
// 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)
}
// This pass's own output is what the preview shows: the global slide
// gets the stand-in, everything else the deck.
showCompiled(currentSlide === -1 ? (example ?? result) : result)
// Tiles embed the stylesheet (and, in template mode, the frame), so a
// change to either makes every tile stale. The first compile builds
@ -250,7 +217,9 @@ function buildThumbnailHtml(index: number, code: string): string {
.split(CSS_PLACEHOLDER)
.join('')
}
return global
// Shared markup: it wraps every slide, so show it around the example
// rather than on its own, which would render no slide at all.
return `${global}\n${EXAMPLE_CONTENT}`
}
const extracted = extractStyles(code).html
@ -472,7 +441,7 @@ addSlideButton.addEventListener('click', async () => {
})
presentButton.addEventListener('click', () => {
stage.requestFullscreen().catch(() => {
slide.requestFullscreen().catch(() => {
// Browsers can refuse fullscreen; presenting inside the panel still works.
})
})

View file

@ -1,5 +1,6 @@
import type { CompiledSlide } from './compile'
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './placeholders'
import { editor } from '../editor'
/**
* Slide lifecycle. The runtime moves slides through these states; CSS owns how
@ -34,8 +35,13 @@ export interface Runtime {
goTo(index: number): void
next(): void
previous(): void
/** Swap the deck in place (after recompiling) without resetting the view. */
setSlides(slides: readonly CompiledSlide[]): void
/**
* Swap the deck in place (after recompiling) without resetting the view.
* `keepIndex` leaves the current slide recorded in history alone when the
* new deck is shorter — the runtime still clamps what it displays, but the
* slide you were on is not overwritten with the clamp.
*/
setSlides(slides: readonly CompiledSlide[], options?: { keepIndex?: boolean }): void
/**
* Set the global slide; a template re-renders the scene around the slides.
* Order-independent: a scene re-render re-applies the stored stylesheet.
@ -173,6 +179,7 @@ function writeIndex(index: number, push: boolean): void {
}
function ignoresKeyboard(event: KeyboardEvent): boolean {
if(editor.hasTextFocus()) return true;
const target = event.target
if (!(target instanceof HTMLElement)) {
return false
@ -267,7 +274,7 @@ export function createRuntime(
void settle(generation, targetRecord)
}
function setSlides(deckUpdate: readonly CompiledSlide[]): void {
function setSlides(deckUpdate: readonly CompiledSlide[], slideOptions: { keepIndex?: boolean } = {}): void {
if (destroyed) {
return
}
@ -292,7 +299,9 @@ export function createRuntime(
if (records.length > 0 && current > records.length - 1) {
current = records.length - 1
writeIndex(current, false)
if (!slideOptions.keepIndex) {
writeIndex(current, false)
}
}
if (structureChanged) {
@ -443,7 +452,9 @@ export function createRuntime(
goTo,
next,
previous,
setSlides,
setSlides(slides, slideOptions) {
setSlides(slides, slideOptions)
},
setTemplate,
setCss,
get current() {

View file

@ -120,7 +120,6 @@ body {
width: 100%;
height: 100%;
overflow: hidden;
background-color: var(--bg-sunken);
}
.stage:focus {
@ -259,8 +258,16 @@ body {
}
.slide {
all: revert;
all: initial;
aspect-ratio: 16/9;
width: 100%;
height: auto;
background-color: white;
}
/* The global slide is shared markup, not a step of the deck: mark the surface
so it reads as different from a presented slide. */
.slide.global {
outline: 2px solid var(--accent);
outline-offset: -2px;
}