diff --git a/src/editor.ts b/src/editor.ts index 5c9b840..9e2f43d 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -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 = { '{': '}', '(': ')', '[': ']' } + +/** Matches a `` 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 ``. + */ +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 ` -
-
Example deck
-
${EXAMPLE_SECTION}
-
Global slide — wraps every slide
-
` +const EXAMPLE_FRAME = `${EXAMPLE_SECTION}` let globalSlide = '' const slides: Slide[] = [ { - code: `\n

test

` + code: '' } ] -const editor = createEditor() const slideList = document.querySelector('.slide-list-items')! const addSlideButton = document.querySelector('.add-slide')! const stage = document.querySelector('.stage')! +const slide = document.querySelector('.slide')! const compileError = document.querySelector('.compile-error')! const presentButton = document.querySelector('.present-button')! let currentSlide = 0 @@ -119,8 +49,9 @@ 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. +// 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 { + 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 { // 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. }) }) diff --git a/src/slides/runtime.ts b/src/slides/runtime.ts index eb880b6..edf8dad 100644 --- a/src/slides/runtime.ts +++ b/src/slides/runtime.ts @@ -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() { diff --git a/src/style.css b/src/style.css index 6be6e83..25a6f16 100644 --- a/src/style.css +++ b/src/style.css @@ -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; } \ No newline at end of file