Compare commits
No commits in common. "main" and "slide-runtime" have entirely different histories.
main
...
slide-runt
6 changed files with 109 additions and 606 deletions
|
|
@ -23,14 +23,11 @@
|
||||||
<div class="slide-list-items"></div>
|
<div class="slide-list-items"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="code"></div>
|
<div class="code"></div>
|
||||||
<div class="preview"></div>
|
|
||||||
<div class="preview">
|
<div class="preview">
|
||||||
<div class="compile-error" role="alert" hidden></div>
|
<div class="compile-error" role="alert" hidden></div>
|
||||||
<div class="slide">
|
|
||||||
<div class="stage" tabindex="-1"></div>
|
<div class="stage" tabindex="-1"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
205
src/editor.ts
205
src/editor.ts
|
|
@ -1,207 +1,10 @@
|
||||||
import * as monaco from 'monaco-editor'
|
import * as monaco from 'monaco-editor'
|
||||||
import { emmetCSS, expandAbbreviation } from 'emmet-monaco-es'
|
import { emmetCSS, emmetHTML } from 'emmet-monaco-es'
|
||||||
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
|
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
|
||||||
import cssWorker from 'monaco-editor/esm/vs/language/css/css.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'
|
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'])
|
emmetCSS(monaco, ['css', 'scss', 'less'])
|
||||||
|
|
||||||
self.MonacoEnvironment = {
|
self.MonacoEnvironment = {
|
||||||
|
|
@ -223,7 +26,7 @@ const MONACO_PARENT = '.code'
|
||||||
* the Less lives inside that tag, so there is nothing to switch between. The
|
* 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.
|
* CSS/LESS workers above still highlight, validate and complete that Less.
|
||||||
*/
|
*/
|
||||||
function createEditor(): monaco.editor.IStandaloneCodeEditor {
|
export function createEditor(): monaco.editor.IStandaloneCodeEditor {
|
||||||
return monaco.editor.create(document.querySelector(MONACO_PARENT)!, {
|
return monaco.editor.create(document.querySelector(MONACO_PARENT)!, {
|
||||||
value: '',
|
value: '',
|
||||||
language: 'html',
|
language: 'html',
|
||||||
|
|
@ -231,5 +34,3 @@ function createEditor(): monaco.editor.IStandaloneCodeEditor {
|
||||||
automaticLayout: true
|
automaticLayout: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const editor = createEditor()
|
|
||||||
|
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
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,11 +1,10 @@
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import { editor } from './editor'
|
import { createEditor } from './editor'
|
||||||
import { createCodeThumbnail } from './code-to-element'
|
import { createCodeThumbnail } from './code-to-element'
|
||||||
import {
|
import {
|
||||||
compilePresentation,
|
compilePresentation,
|
||||||
extractStyles,
|
extractStyles,
|
||||||
slideId,
|
slideId,
|
||||||
type CompileInput,
|
|
||||||
type CompiledPresentation
|
type CompiledPresentation
|
||||||
} from './slides/compile'
|
} from './slides/compile'
|
||||||
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './slides/placeholders'
|
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './slides/placeholders'
|
||||||
|
|
@ -15,32 +14,103 @@ interface Slide {
|
||||||
code: string
|
code: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slides start empty: the author writes the markup and the Less themselves.
|
// The starter slide opens with a <style> tag. Less written inside a slide's
|
||||||
// Less written inside a slide's `<style>` tag is scoped to that slide, the
|
// <style> tag is scoped to that slide, the global slide's styles are shared
|
||||||
// global slide's styles are shared by every slide, and `:thisSlide` is the
|
// by every slide, and `:thisSlide` is the slide's own section — no extra CSS
|
||||||
// slide's own section — no extra CSS syntax beyond that.
|
// 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'] { ... }.
|
||||||
|
|
||||||
// Sample markup standing in for a real slide. The global-slide tile and the
|
@bg: #101010;
|
||||||
// preview while the global slide is selected both show it, so the author can
|
@fg: #f0f0f0;
|
||||||
// see what the global slide wraps instead of a blank frame.
|
@accent: #8fc98f;
|
||||||
const EXAMPLE_CONTENT = '<h1>p/slides</h1><p>This is a paragraph.</p>'
|
|
||||||
|
|
||||||
const EXAMPLE_SECTION = `<section data-ps-slide="${slideId(0)}" data-ps-state="current">${EXAMPLE_CONTENT}</section>`
|
: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_FRAME = `${EXAMPLE_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>`
|
||||||
|
|
||||||
let globalSlide = ''
|
let globalSlide = ''
|
||||||
|
|
||||||
const slides: Slide[] = [
|
const slides: Slide[] = [
|
||||||
{
|
{
|
||||||
code: ''
|
code: `<style>\n${STARTER_STYLE}\n</style>\n<h1>test</h1>`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const editor = createEditor()
|
||||||
const slideList = document.querySelector<HTMLElement>('.slide-list-items')!
|
const slideList = document.querySelector<HTMLElement>('.slide-list-items')!
|
||||||
const addSlideButton = document.querySelector<HTMLButtonElement>('.add-slide')!
|
const addSlideButton = document.querySelector<HTMLButtonElement>('.add-slide')!
|
||||||
const stage = document.querySelector<HTMLElement>('.stage')!
|
const stage = document.querySelector<HTMLElement>('.stage')!
|
||||||
const slide = document.querySelector<HTMLElement>('.slide')!
|
|
||||||
const compileError = document.querySelector<HTMLElement>('.compile-error')!
|
const compileError = document.querySelector<HTMLElement>('.compile-error')!
|
||||||
const presentButton = document.querySelector<HTMLButtonElement>('.present-button')!
|
const presentButton = document.querySelector<HTMLButtonElement>('.present-button')!
|
||||||
let currentSlide = 0
|
let currentSlide = 0
|
||||||
|
|
@ -49,9 +119,8 @@ let globalSlideElement: HTMLElement | null = null
|
||||||
const slideElements = new Map<Slide, HTMLElement>()
|
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
|
||||||
// The last good compile of the deck: the preview shows it, and thumbnails read
|
// The last good compile: thumbnails read the stylesheet from here, so a tile
|
||||||
// the stylesheet from here, so a tile is rendered exactly as the presentation
|
// is rendered exactly as the presentation renders it.
|
||||||
// renders it.
|
|
||||||
let lastResult: CompiledPresentation | null = null
|
let lastResult: CompiledPresentation | null = null
|
||||||
// Bumped by every full list rebuild; async thumbnail builders check it so a
|
// Bumped by every full list rebuild; async thumbnail builders check it so a
|
||||||
// stale build can never commit over a newer one.
|
// stale build can never commit over a newer one.
|
||||||
|
|
@ -91,21 +160,9 @@ function setSlide(index: number) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const wasGlobal = currentSlide === -1
|
|
||||||
currentSlide = index
|
currentSlide = index
|
||||||
slide.classList.toggle('global', index === -1)
|
|
||||||
showSlideInEditor(index)
|
showSlideInEditor(index)
|
||||||
updateSelection()
|
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
|
// The global slide is shared markup, not a step of the presentation, so it
|
||||||
// never moves the runtime.
|
// never moves the runtime.
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
|
|
@ -128,40 +185,12 @@ function scheduleCompile() {
|
||||||
}, 250)
|
}, 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> {
|
async function rebuildPreview(): Promise<void> {
|
||||||
const showingGlobal = currentSlide === -1
|
|
||||||
try {
|
try {
|
||||||
const result = await compilePresentation(deckInput())
|
const result = await compilePresentation({
|
||||||
const example = showingGlobal ? await compilePresentation(exampleInput()) : null
|
slides: slides.map((slide) => slide.code),
|
||||||
|
global: globalSlide
|
||||||
|
})
|
||||||
|
|
||||||
compileError.hidden = true
|
compileError.hidden = true
|
||||||
compileError.textContent = ''
|
compileError.textContent = ''
|
||||||
|
|
@ -175,9 +204,13 @@ async function rebuildPreview(): Promise<void> {
|
||||||
// the editor and the selection there too.
|
// the editor and the selection there too.
|
||||||
setSlide(runtime.current)
|
setSlide(runtime.current)
|
||||||
}
|
}
|
||||||
// This pass's own output is what the preview shows: the global slide
|
runtime.setTemplate(result.template)
|
||||||
// gets the stand-in, everything else the deck.
|
runtime.setSlides(result.slides)
|
||||||
showCompiled(currentSlide === -1 ? (example ?? result) : result)
|
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
|
// Tiles embed the stylesheet (and, in template mode, the frame), so a
|
||||||
// change to either makes every tile stale. The first compile builds
|
// change to either makes every tile stale. The first compile builds
|
||||||
|
|
@ -217,9 +250,7 @@ function buildThumbnailHtml(index: number, code: string): string {
|
||||||
.split(CSS_PLACEHOLDER)
|
.split(CSS_PLACEHOLDER)
|
||||||
.join('')
|
.join('')
|
||||||
}
|
}
|
||||||
// Shared markup: it wraps every slide, so show it around the example
|
return global
|
||||||
// rather than on its own, which would render no slide at all.
|
|
||||||
return `${global}\n${EXAMPLE_CONTENT}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const extracted = extractStyles(code).html
|
const extracted = extractStyles(code).html
|
||||||
|
|
@ -441,7 +472,7 @@ addSlideButton.addEventListener('click', async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
presentButton.addEventListener('click', () => {
|
presentButton.addEventListener('click', () => {
|
||||||
slide.requestFullscreen().catch(() => {
|
stage.requestFullscreen().catch(() => {
|
||||||
// Browsers can refuse fullscreen; presenting inside the panel still works.
|
// Browsers can refuse fullscreen; presenting inside the panel still works.
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { CompiledSlide } from './compile'
|
import type { CompiledSlide } from './compile'
|
||||||
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './placeholders'
|
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './placeholders'
|
||||||
import { editor } from '../editor'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slide lifecycle. The runtime moves slides through these states; CSS owns how
|
* Slide lifecycle. The runtime moves slides through these states; CSS owns how
|
||||||
|
|
@ -35,13 +34,8 @@ export interface Runtime {
|
||||||
goTo(index: number): void
|
goTo(index: number): void
|
||||||
next(): void
|
next(): void
|
||||||
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
|
||||||
* `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.
|
* Set the global slide; a template re-renders the scene around the slides.
|
||||||
* Order-independent: a scene re-render re-applies the stored stylesheet.
|
* Order-independent: a scene re-render re-applies the stored stylesheet.
|
||||||
|
|
@ -179,7 +173,6 @@ function writeIndex(index: number, push: boolean): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ignoresKeyboard(event: KeyboardEvent): boolean {
|
function ignoresKeyboard(event: KeyboardEvent): boolean {
|
||||||
if(editor.hasTextFocus()) return true;
|
|
||||||
const target = event.target
|
const target = event.target
|
||||||
if (!(target instanceof HTMLElement)) {
|
if (!(target instanceof HTMLElement)) {
|
||||||
return false
|
return false
|
||||||
|
|
@ -274,7 +267,7 @@ export function createRuntime(
|
||||||
void settle(generation, targetRecord)
|
void settle(generation, targetRecord)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSlides(deckUpdate: readonly CompiledSlide[], slideOptions: { keepIndex?: boolean } = {}): void {
|
function setSlides(deckUpdate: readonly CompiledSlide[]): void {
|
||||||
if (destroyed) {
|
if (destroyed) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -299,10 +292,8 @@ export function createRuntime(
|
||||||
|
|
||||||
if (records.length > 0 && current > records.length - 1) {
|
if (records.length > 0 && current > records.length - 1) {
|
||||||
current = records.length - 1
|
current = records.length - 1
|
||||||
if (!slideOptions.keepIndex) {
|
|
||||||
writeIndex(current, false)
|
writeIndex(current, false)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (structureChanged) {
|
if (structureChanged) {
|
||||||
// Rebuilding the DOM restarts transitions, so drop in-flight work and
|
// Rebuilding the DOM restarts transitions, so drop in-flight work and
|
||||||
|
|
@ -452,9 +443,7 @@ export function createRuntime(
|
||||||
goTo,
|
goTo,
|
||||||
next,
|
next,
|
||||||
previous,
|
previous,
|
||||||
setSlides(slides, slideOptions) {
|
setSlides,
|
||||||
setSlides(slides, slideOptions)
|
|
||||||
},
|
|
||||||
setTemplate,
|
setTemplate,
|
||||||
setCss,
|
setCss,
|
||||||
get current() {
|
get current() {
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,7 @@ body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background-color: var(--bg-sunken);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage:focus {
|
.stage:focus {
|
||||||
|
|
@ -256,18 +257,3 @@ body {
|
||||||
.delete-slide:focus-visible {
|
.delete-slide:focus-visible {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide {
|
|
||||||
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