This commit is contained in:
parent
7d01ec881e
commit
fa1467e7a8
5 changed files with 596 additions and 109 deletions
205
src/editor.ts
205
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<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
301
src/html-worker.ts
Normal 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))
|
||||
}
|
||||
167
src/main.ts
167
src/main.ts
|
|
@ -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.
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,8 +299,10 @@ export function createRuntime(
|
|||
|
||||
if (records.length > 0 && current > records.length - 1) {
|
||||
current = records.length - 1
|
||||
if (!slideOptions.keepIndex) {
|
||||
writeIndex(current, false)
|
||||
}
|
||||
}
|
||||
|
||||
if (structureChanged) {
|
||||
// Rebuilding the DOM restarts transitions, so drop in-flight work and
|
||||
|
|
@ -443,7 +452,9 @@ export function createRuntime(
|
|||
goTo,
|
||||
next,
|
||||
previous,
|
||||
setSlides,
|
||||
setSlides(slides, slideOptions) {
|
||||
setSlides(slides, slideOptions)
|
||||
},
|
||||
setTemplate,
|
||||
setCss,
|
||||
get current() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue