feat: add blocks params autocomplete + code block / table helpers

This commit is contained in:
NGPixel
2026-10-10 23:25:33 -04:00
parent 52532cc2e6
commit cf411e543f
14 changed files with 1563 additions and 101 deletions
+10
View File
@@ -2351,6 +2351,12 @@
"editor.blog.sorts.oldest": "Oldest first",
"editor.blog.title": "Blog",
"editor.ckeditor.stats": "{chars} chars, {words} words",
"editor.codeBlock.attributes.linesHighlight": "Highlighted Lines",
"editor.codeBlock.attributes.linesHighlightHint": "The lines to highlight, as the gutter numbers them: single lines and ranges, e.g. 1,3,5-8.",
"editor.codeBlock.attributes.linesStart": "First Line Number",
"editor.codeBlock.attributes.linesStartHint": "The number the gutter counts from, for a snippet taken from further down a file. 1 when not given.",
"editor.codeBlock.attributes.title": "Title",
"editor.codeBlock.attributes.titleHint": "Shown in a bar above the block, e.g. the name of the file the code comes from.",
"editor.codeBlock.filter": "Filter languages...",
"editor.codeBlock.noResults": "No language matches that.",
"editor.collab.disconnected": "Live collaboration is unavailable. Your changes are kept locally and can still be saved.",
@@ -2413,11 +2419,15 @@
"editor.localeRel.title": "Locale Relations",
"editor.markup.abbreviationDefinition": "Definition",
"editor.markup.abbreviationTerm": "TERM",
"editor.markup.addTableColumn": "Add Column",
"editor.markup.addTableRow": "Add Row",
"editor.markup.admonitionDanger": "Caution Admonition",
"editor.markup.admonitionImportant": "Important Admonition",
"editor.markup.admonitionInfo": "Note Admonition",
"editor.markup.admonitionSuccess": "Tip Admonition",
"editor.markup.admonitionWarning": "Warning Admonition",
"editor.markup.blockPropDefault": "Default: {value}",
"editor.markup.blockPropRequired": "Required",
"editor.markup.blockquote": "Blockquote",
"editor.markup.blockquoteAdmonitions": "Blockquote / Admonition",
"editor.markup.blockquoteError": "Error Blockquote",
@@ -299,6 +299,7 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import { useBlockCompletions } from '@/composables/blockCompletions'
import { bindCollabEditor, startCollabSession, stopCollabSession } from '@/composables/collab'
import { dialog } from '@/composables/dialog'
import { useImagePropertiesLens } from '@/composables/imagePropertiesLens'
@@ -308,7 +309,10 @@ import { isVisible } from '@/helpers/anchors'
import { assetPath } from '@/helpers/assets'
import { blockAsciidoc } from '@/helpers/blocks'
import {
blockAttributeContext,
blockNameContext,
blockOpeningLine,
blockSnippet,
blockValues,
findBlockContent,
findBlocks,
@@ -444,6 +448,20 @@ const imageLens = useImagePropertiesLens({
writeImage
})
/*
A block's name after `[block-`, and its parameters in the attribute line. The hyphen is what ends
`[block-`; a comma begins the next attribute, a space after it the same, and a quote a value.
*/
const blockCompletions = useBlockCompletions({
getEditor: () => editor,
getBlocks: () => siteBlocks,
languageId: ASCIIDOC_LANGUAGE_ID,
triggerCharacters: ['-', ',', ' ', '"'],
nameContext: blockNameContext,
attributeContext: blockAttributeContext,
blockSnippet
})
const state = reactive({
// -> Read once, as a DEFAULT rather than a binding: past this the pane is the author's to open and
// close, and a bound one would slam it shut the moment a window was dragged narrower mid-edit
@@ -1458,6 +1476,9 @@ onMounted(async () => {
}
})
// -> Block names and parameters as they are typed -- see `useBlockCompletions`
blockCompletions.register()
// -> "Image Properties" over every image in the page -- see `useImagePropertiesLens`
imageLens.register()
@@ -1652,6 +1673,7 @@ onBeforeUnmount(() => {
monacoRef.value?.removeEventListener('drop', onEditorDrop)
// -> Registered against the language, not this editor, so nothing else takes it down
blockLensProvider?.dispose()
blockCompletions.dispose()
imageLens.dispose()
// -> Before the editor goes: the binding is holding the model, and leaving the room is what takes
// this author's avatar out of everyone else's header
@@ -76,15 +76,12 @@
import { computed, nextTick, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import hljs from 'highlight.js'
import { ALL_LANGUAGES, COMMON_LANGUAGES } from '@/helpers/codeLanguages'
/**
* Picks the language for a fenced code block.
*
* The list is whatever highlight.js has registered — asked at runtime rather than kept as a copy here,
* so it cannot drift from what the renderer will actually highlight. Each entry carries the id that
* goes on the fence and the name hljs calls it; the filter matches either, plus the aliases, so `md`
* finds Markdown and `js` finds JavaScript.
* Picks the language for a fenced code block, from the list in `helpers/codeLanguages.js`. The filter
* matches an entry's id, its name or one of its aliases, so `md` finds Markdown and `js` finds
* JavaScript.
*/
// PROPS
@@ -108,40 +105,6 @@ const emit = defineEmits(['select'])
const { t } = useI18n()
/**
* The five worth having at the top. Labelled here rather than taking hljs's own names, which for these
* read as `Plain text`, `HTML, XML` and `Bash` — precise, and not what someone scanning a shortlist is
* looking for.
*
* Two of the ids are not what the list below would show either:
* - `sh` and `md` are aliases rather than registered ids, so they are absent from that list; hljs
* resolves them to Bash and Markdown, which is what highlights the block.
* - Plain text has no id at all. A bare fence is how markdown says "no language", and it is what the
* renderer already treats as unhighlighted — so there is nothing to put after the backticks. The
* menu shows a dash where the others show their id.
*/
const COMMON_LANGUAGES = [
{ id: '', label: 'Plain Text' },
{ id: 'json', label: 'JSON' },
{ id: 'md', label: 'Markdown' },
{ id: 'sh', label: 'Bash Shell' },
{ id: 'xml', label: 'XML' }
]
/** Every registered language, by display name. Built once: the set cannot change at runtime. */
const ALL_LANGUAGES = hljs
.listLanguages()
.map((id) => {
const definition = hljs.getLanguage(id)
return {
id,
label: definition?.name ?? id,
// -> Searched but never shown: `sh` and `zsh` are how someone looks for Bash
aliases: definition?.aliases ?? []
}
})
.sort((a, b) => a.label.localeCompare(b.label))
// REFS
const menuRef = ref(null)
+107 -13
View File
@@ -341,23 +341,29 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import { useBlockCompletions } from '@/composables/blockCompletions'
import { bindCollabEditor, startCollabSession, stopCollabSession } from '@/composables/collab'
import { dialog } from '@/composables/dialog'
import { useFenceCompletions } from '@/composables/fenceCompletions'
import { useImagePropertiesLens } from '@/composables/imagePropertiesLens'
import { notify } from '@/composables/notify'
import { useMinWidth } from '@/composables/screen'
import { useTableCellNavigation } from '@/composables/tableCellNavigation'
import { isVisible } from '@/helpers/anchors'
import { assetPath } from '@/helpers/assets'
import { blockMarkdown } from '@/helpers/blocks'
import {
blockAttributeContext,
blockNameContext,
blockOpeningLine,
blockSnippet,
blockValues,
findBlockContent,
findBlocks,
writeBlockContent
} from '@/helpers/markdownBlocks'
import { findImages, imageValues, writeImage } from '@/helpers/markdownImages'
import { findEditableTables } from '@/helpers/markdownTable'
import { findEditableTables, tableRowToAdd, tableWithColumnAdded } from '@/helpers/markdownTable'
import { scrollBehavior } from '@/helpers/motion'
import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue'
@@ -502,6 +508,29 @@ const imageLens = useImagePropertiesLens({
writeImage
})
/*
A block's name after `::block-`, and its parameters inside its braces. The hyphen is what ends
`::block-`; a space begins the next attribute, a brace the first, and a quote a value.
*/
const blockCompletions = useBlockCompletions({
getEditor: () => editor,
getBlocks: () => siteBlocks,
languageId: 'markdown',
triggerCharacters: ['-', ' ', '{', '"'],
nameContext: blockNameContext,
attributeContext: blockAttributeContext,
blockSnippet
})
// -> A code fence's `title`, `linesStart` and `linesHighlight`, after its language
const fenceCompletions = useFenceCompletions({ getEditor: () => editor })
// -> Tab and Shift+Tab between a table's cells, and Tab past the last one adds a row
const tableCellNavigation = useTableCellNavigation({
getEditor: () => editor,
addRow: (line) => addTableRow(line)
})
const state = reactive({
/*
Read once, as a DEFAULT rather than a binding: past this first value the pane is the author's to open
@@ -709,8 +738,7 @@ function insertTable() {
* of the click is what keeps the range and the source it hands over describing the same thing.
*/
function editTable(line) {
const tables = findEditableTables(editor.getModel().getValue())
const table = tables.find((entry) => entry.startLine <= line && line <= entry.endLine)
const table = tableAt(line)
if (!table) {
return
}
@@ -724,6 +752,53 @@ function editTable(line) {
})
}
/**
* The table under a lens, found again at the moment of the click -- see `editTable` for why.
*/
function tableAt(line) {
return findEditableTables(editor.getModel().getValue()).find(
(entry) => entry.startLine <= line && line <= entry.endLine
)
}
/**
* An empty row, from the "Add Row" lens: under the row the caret is in, or at the end of the table when
* the caret is anywhere else -- in the header, or not in the table at all. Clicking a lens does not move
* the caret, so where it is is where the author was writing. It lands in the new row's first cell.
*/
function addTableRow(line) {
const table = tableAt(line)
if (!table) {
return
}
const row = tableRowToAdd(table, editor.getPosition()?.lineNumber ?? null)
const model = editor.getModel()
const end = model.getLineMaxColumn(row.afterLine)
editor.executeEdits('table', [
{ range: new Range(row.afterLine, end, row.afterLine, end), text: `\n${row.text}` }
])
editor.setPosition(new Position(row.afterLine + 1, row.column))
editor.focus()
}
/** An empty column at the end of the table, from the "Add Column" lens, with the caret in its header. */
function addTableColumn(line) {
const table = tableAt(line)
if (!table) {
return
}
const result = tableWithColumnAdded(table)
const model = editor.getModel()
editor.executeEdits('table', [
{
range: new Range(result.startLine, 1, result.endLine, model.getLineMaxColumn(result.endLine)),
text: result.text
}
])
editor.setPosition(new Position(result.caret.line, result.caret.column))
editor.focus()
}
/** The block as this site describes it, or undefined for one it does not list. */
function blockDefinition(name) {
return siteBlocks.find((block) => block.block === name)
@@ -1624,19 +1699,30 @@ onMounted(async () => {
every lens twice.
*/
const editTableCommand = editor.addCommand(0, (_accessor, line) => editTable(line))
const addTableRowCommand = editor.addCommand(0, (_accessor, line) => addTableRow(line))
const addTableColumnCommand = editor.addCommand(0, (_accessor, line) => addTableColumn(line))
tableLensProvider = monaco.languages.registerCodeLensProvider('markdown', {
provideCodeLenses(model) {
return {
lenses: findEditableTables(model.getValue()).map((table) => ({
range: new Range(table.startLine, 1, table.startLine, 1),
command: {
id: editTableCommand,
title: t('editor.markup.editTable'),
arguments: [table.startLine]
}
})),
dispose() {}
/*
"Add Row" and "Add Column" beside it, from the same provider so the three always come in the
same order. They edit the source in place rather than through the overlay -- see
`tableRowToAdd` -- and are offered over the same tables, since a table the overlay cannot hold
is one these could not be trusted to read either.
*/
const lenses = []
for (const table of findEditableTables(model.getValue())) {
const range = new Range(table.startLine, 1, table.startLine, 1)
const lens = (id, title) => ({
range,
command: { id, title, arguments: [table.startLine] }
})
lenses.push(
lens(editTableCommand, t('editor.markup.editTable')),
lens(addTableRowCommand, t('editor.markup.addTableRow')),
lens(addTableColumnCommand, t('editor.markup.addTableColumn'))
)
}
return { lenses, dispose() {} }
}
})
@@ -1694,6 +1780,11 @@ onMounted(async () => {
}
})
// -> Block names and parameters, and code fence attributes, as they are typed
blockCompletions.register()
fenceCompletions.register()
tableCellNavigation.register()
// -> "Image Properties" over every image in the page -- see `useImagePropertiesLens`
imageLens.register()
@@ -1913,6 +2004,9 @@ onBeforeUnmount(() => {
// -> Registered against the markdown language, not this editor, so nothing else takes it down
tableLensProvider?.dispose()
blockLensProvider?.dispose()
blockCompletions.dispose()
fenceCompletions.dispose()
tableCellNavigation.dispose()
imageLens.dispose()
// -> Before the editor goes: the binding is holding the model, and leaving the room is what takes
// this author's avatar out of everyone else's header
@@ -0,0 +1,221 @@
import { useI18n } from 'vue-i18n'
import * as monaco from 'monaco-editor'
import { Range } from 'monaco-editor'
/**
* Completion of a block's name and of its parameters, for a source editor built on Monaco -- the
* Markdown and the AsciiDoc editor both offer it.
*
* Everything here is the same for both: which blocks and props are offered, how they are described,
* in what order, and what picking one writes. What differs is only how a block is spelled, so each
* editor hands in its own three functions for that:
*
* - `nameContext(text, line, column)`: whether the caret is in a block's NAME, as
* `{ prefix, startColumn, endColumn, bare }` or null. `prefix` is what the line holds before the
* name (`::block-`, `[block-`), `startColumn`/`endColumn` span the name to replace, and `bare` says
* the line holds nothing else yet -- in which case `endColumn` is the end of the line, since the
* whole line is replaced by a whole block.
* - `attributeContext(text, line, column)`: whether the caret is in a block's attribute list, as
* `{ block, written, kind: 'name', startColumn, insertPrefix? }`,
* `{ block, written, kind: 'value', name, quoted, startColumn }` or null. `written` is the props
* already given, and `insertPrefix` anything a name has to be preceded by where it is typed.
* - `blockSnippet(definition, nameContext)`: a whole new block, as a Monaco snippet.
*
* Answered from the definitions the editor already reads for its "Edit Block Parameters" lens, so a
* block the site does not list gets nothing.
*
* Call it during `setup`. Then `register()` once the editor exists, and `dispose()` with the editor.
*
* @param {object} opts
* @param {() => object} opts.getEditor The Monaco editor, once there is one.
* @param {() => Array<object>} opts.getBlocks The blocks this site has, as the API describes them.
* @param {string} opts.languageId What the provider is registered against.
* @param {string[]} opts.triggerCharacters The characters that open the list without Ctrl+Space.
* @param {(text: string, line: number, column: number) => object|null} opts.nameContext
* @param {(text: string, line: number, column: number) => object|null} opts.attributeContext
* @param {(definition: object, context: object) => string} opts.blockSnippet
*/
export function useBlockCompletions({
getEditor,
getBlocks,
languageId,
triggerCharacters,
nameContext,
attributeContext,
blockSnippet
}) {
const { t } = useI18n()
/**
* The provider, which is registered against the language rather than this editor -- so it has to be
* disposed with the component, or a second visit to the editor would offer everything twice.
*/
let provider = null
function register() {
provider = monaco.languages.registerCompletionItemProvider(languageId, {
triggerCharacters,
provideCompletionItems
})
}
function dispose() {
provider?.dispose()
provider = null
}
/**
* The block's name first, then its attribute list. Any other model of the same language on the page
* gets nothing: the provider is per-language, and only this editor's model is the page.
*/
function provideCompletionItems(model, position) {
const none = { suggestions: [] }
if (model !== getEditor()?.getModel()) {
return none
}
const text = model.getValue()
const name = nameContext(text, position.lineNumber, position.column)
if (name) {
return { suggestions: nameSuggestions(name, position) }
}
const context = attributeContext(text, position.lineNumber, position.column)
const definition = context && getBlocks().find((block) => block.block === context.block)
if (!definition?.props?.length) {
return none
}
const range = new Range(
position.lineNumber,
context.startColumn,
position.lineNumber,
position.column
)
return {
suggestions:
context.kind === 'value'
? valueSuggestions(definition, context, range)
: propSuggestions(definition, context, range)
}
}
/**
* The blocks the name could be: the ones this site has switched on.
*
* On a line holding nothing past the name, picking one writes the whole block as the picker would —
* its starter body and closing delimiter, a tab stop in each required prop, and the caret left in the
* attribute list for the parameter suggestions to take over. Anywhere else the block is already there
* and only its name is replaced.
*
* A child block is left out, as the picker leaves it out: `block-tab` outside a tabset draws nothing,
* and nothing about its definition says which block it belongs inside.
*/
function nameSuggestions(context, position) {
const range = new Range(
position.lineNumber,
context.bare ? 1 : context.startColumn,
position.lineNumber,
context.endColumn
)
return getBlocks()
.filter((definition) => definition.isEnabled && !definition.isChild)
.map((definition) => {
const firstRequired = (definition.props ?? []).find((prop) => prop.required)
return {
label: { label: definition.block, description: definition.name },
kind: monaco.languages.CompletionItemKind.Module,
detail: definition.name,
documentation: definition.description,
...(context.bare
? {
// -> The range starts at the line, so what is typed is matched against the line
filterText: `${context.prefix}${definition.block}`,
insertText: blockSnippet(definition, context),
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
command: firstRequired && opensValueList(firstRequired)
}
: { insertText: definition.block }),
range
}
})
}
/**
* The props the list does not give yet. Required ones first, then in the order the block declares
* them. Inserted with the quotes and the caret between them, and where there is a list to pick the
* value from, that list opens straight away.
*/
function propSuggestions(definition, context, range) {
return definition.props
.map((prop, index) => ({ prop, index }))
.filter(({ prop }) => !context.written.has(prop.name))
.map(({ prop, index }) => ({
label: { label: prop.name, description: prop.label },
kind: monaco.languages.CompletionItemKind.Property,
detail: prop.label,
documentation: [
prop.hint,
prop.required ? t('editor.markup.blockPropRequired') : null,
(prop.default ?? '') !== ''
? t('editor.markup.blockPropDefault', { value: String(prop.default) })
: null
]
.filter(Boolean)
.join('\n\n'),
filterText: prop.name,
insertText: `${context.insertPrefix ?? ''}${prop.name}="$1"`,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
sortText: `${prop.required ? 0 : 1}${String(index).padStart(4, '0')}`,
command: opensValueList(prop),
range
}))
}
/** A select's options or a boolean's two words, inside the value of the prop the caret is in. */
function valueSuggestions(definition, context, range) {
const prop = definition.props.find((entry) => entry.name === context.name)
return (prop ? propChoices(prop) : []).map((choice, index) => ({
label: {
label: choice.value,
description: choice.label !== choice.value ? choice.label : undefined
},
kind: monaco.languages.CompletionItemKind.EnumMember,
insertText: context.quoted ? choice.value : `"${choice.value}"`,
sortText: String(index).padStart(4, '0'),
range
}))
}
return { register, dispose }
}
/**
* What a prop's value can be completed with: a select's options, or a boolean's two words. Anything
* else is free text, with nothing to offer.
*
* A select option whose value is empty is left out. It means "write no attribute" — Normal on a tab —
* and completing to `header=""` is not a way of saying that.
*/
function propChoices(prop) {
if (prop.type === 'boolean') {
return [
{ label: 'true', value: 'true' },
{ label: 'false', value: 'false' }
]
}
if (prop.type !== 'select') {
return []
}
return (prop.options ?? [])
.map((option) =>
typeof option === 'object'
? { label: String(option.label ?? option.value), value: String(option.value) }
: { label: String(option), value: String(option) }
)
.filter((option) => option.value !== '')
}
/** The command that opens the list again once a prop is written, for one whose value has a list. */
function opensValueList(prop) {
return propChoices(prop).length > 0
? { id: 'editor.action.triggerSuggest', title: '' }
: undefined
}
@@ -0,0 +1,203 @@
import { useI18n } from 'vue-i18n'
import * as monaco from 'monaco-editor'
import { Range, Selection } from 'monaco-editor'
import { ALL_LANGUAGES, COMMON_LANGUAGES } from '@/helpers/codeLanguages'
import {
FENCE_ATTRIBUTES,
fenceAttributeContext,
fenceJustOpened,
fenceLanguageContext
} from '@/helpers/markdownFences'
/** The label and the hint of each attribute, by name. */
const STRINGS = {
title: ['editor.codeBlock.attributes.title', 'editor.codeBlock.attributes.titleHint'],
linesStart: [
'editor.codeBlock.attributes.linesStart',
'editor.codeBlock.attributes.linesStartHint'
],
linesHighlight: [
'editor.codeBlock.attributes.linesHighlight',
'editor.codeBlock.attributes.linesHighlightHint'
]
}
/**
* Help with writing a fenced code block, for the Markdown editor:
*
* - Typing the third backtick of an opening fence puts the closing one on the line below, leaves the
* caret where it was and opens the list of languages. Escape puts the list away for a block with
* no language.
* - The language is completed from the list the toolbar's code block menu offers.
* - After the language, a space offers the optional attributes -- `title`, `linesStart` and
* `linesHighlight` -- that the fence rule in `renderers/markdown.js` reads.
*
* Call it during `setup`. Then `register()` once the editor exists, and `dispose()` with the editor.
*
* @param {object} opts
* @param {() => object} opts.getEditor The Monaco editor, once there is one.
*/
export function useFenceCompletions({ getEditor }) {
const { t } = useI18n()
/**
* The provider, which is registered against the language rather than this editor -- so it has to be
* disposed with the component, or a second visit to the editor would offer everything twice. And the
* editor's own content listener, which goes with it.
*/
let provider = null
let listener = null
function register() {
provider = monaco.languages.registerCompletionItemProvider('markdown', {
triggerCharacters: [' '],
provideCompletionItems
})
listener = getEditor().onDidChangeModelContent(onContentChange)
}
function dispose() {
provider?.dispose()
provider = null
listener?.dispose()
listener = null
}
/**
* A backtick typed here, by this author: the one change, at this editor's caret.
*
* Monaco has no event for a character being typed, so this is read off the content change, which
* also carries an undo, a paste, a reload and -- in a collaborative session -- every other author's
* keystrokes. Undo, redo and reload say so on the event; somebody else's backtick is told apart by
* where the caret is, which is checked once the change has settled, since the caret moves after the
* content does. Deferred for that reason, and because editing from inside a content listener would
* be an edit landing in the middle of another.
*/
function onContentChange(event) {
if (event.isUndoing || event.isRedoing || event.isFlush || event.changes.length !== 1) {
return
}
const [change] = event.changes
if (change.text !== '`') {
return
}
const line = change.range.startLineNumber
const column = change.range.startColumn + 1
setTimeout(() => closeFence(line, column), 0)
}
/**
* The closing fence and the language list, for a line that has just become an opening fence.
*
* The closing fence is added only where nothing already closes this one -- see `fenceJustOpened` --
* and as an undo step of its own, so Ctrl+Z takes it back and leaves the backticks the author typed.
* The list opens either way: retyping a block's opening fence is as good a time to pick its language
* as writing a new one.
*/
function closeFence(line, column) {
const editor = getEditor()
const model = editor?.getModel()
const selections = editor?.getSelections() ?? []
if (!model || !editor.hasTextFocus() || selections.length !== 1) {
return
}
const [selection] = selections
if (
!selection.isEmpty() ||
selection.positionLineNumber !== line ||
selection.positionColumn !== column
) {
return
}
const opened = fenceJustOpened(model.getValue(), line)
if (!opened) {
return
}
if (opened.unclosed) {
editor.pushUndoStop()
editor.executeEdits(
'fence',
[{ range: new Range(line, column, line, column), text: `\n${opened.indent}\`\`\`` }],
[new Selection(line, column, line, column)]
)
editor.pushUndoStop()
}
editor.trigger('fence', 'editor.action.triggerSuggest', {})
}
/** The fence's language while it is being typed, and its attributes once it has been. */
function provideCompletionItems(model, position) {
const none = { suggestions: [] }
if (model !== getEditor()?.getModel()) {
return none
}
const text = model.getValue()
const language = fenceLanguageContext(text, position.lineNumber, position.column)
if (language) {
return {
suggestions: languageSuggestions(
new Range(
position.lineNumber,
language.startColumn,
position.lineNumber,
language.endColumn
)
)
}
}
const context = fenceAttributeContext(text, position.lineNumber, position.column)
if (!context) {
return none
}
return {
suggestions: attributeSuggestions(
context,
new Range(position.lineNumber, context.startColumn, position.lineNumber, position.column)
)
}
}
/**
* Every language, with the toolbar menu's shortlist at the top -- less Plain Text, which is no
* language at all and is what Escape leaves. Matched on the aliases as well as the id, so `zsh` finds
* Bash; what is written is the id.
*/
function languageSuggestions(range) {
const entry = (language, group, index) => ({
label: { label: language.id, description: language.label },
kind: monaco.languages.CompletionItemKind.Value,
filterText: [language.id, ...(language.aliases ?? [])].join(' '),
insertText: language.id,
sortText: `${group}${String(index).padStart(4, '0')}`,
range
})
return [
...COMMON_LANGUAGES.filter((language) => language.id).map((language, index) =>
entry(language, 0, index)
),
...ALL_LANGUAGES.map((language, index) => entry(language, 1, index))
]
}
/**
* The attributes the fence does not give yet, in the order the syntax documents them, each inserted
* with its quotes and the caret between them. Compared in lower case, as the renderer reads them.
*/
function attributeSuggestions(context, range) {
return FENCE_ATTRIBUTES.filter((name) => !context.written.has(name.toLowerCase())).map(
(name, index) => ({
label: { label: name, description: t(STRINGS[name][0]) },
kind: monaco.languages.CompletionItemKind.Property,
detail: t(STRINGS[name][0]),
documentation: t(STRINGS[name][1]),
insertText: `${name}="$1"`,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
sortText: String(index).padStart(4, '0'),
range
})
)
}
return { register, dispose }
}
@@ -0,0 +1,111 @@
import * as monaco from 'monaco-editor'
import { Selection } from 'monaco-editor'
import { findEditableTables, isCellRow, tableCellMove } from '@/helpers/markdownTable'
/**
* Tab and Shift+Tab between the cells of a table, for the Markdown editor. Where they go is
* `tableCellMove`'s answer; past the last cell, Tab adds a row the way the "Add Row" lens does.
*
* Bound only while the caret is in one of a table's rows of cells, through a context key kept up to
* date as the caret moves -- so everywhere else, the delimiter row included, Tab is Monaco's own and
* indents. It also stands aside wherever Tab already means something: an open suggestion list, a snippet
* being filled in (picking a block writes one), an inline suggestion, Tab-moves-focus mode, and several
* cursors at once. The tables are the ones the lenses are offered over, which are the ones whose cells
* can be read back reliably.
*
* Call it during `setup`. Then `register()` once the editor exists, and `dispose()` with the editor.
*
* @param {object} opts
* @param {() => object} opts.getEditor The Monaco editor, once there is one.
* @param {(line: number) => void} opts.addRow Adds a row to the table at a line and moves into it.
*/
export function useTableCellNavigation({ getEditor, addRow }) {
let inCell = null
let disposables = []
/** The tables, read once per version of the text rather than once per caret move. */
let cache = { version: -1, tables: [] }
function register() {
const editor = getEditor()
inCell = editor.createContextKey('markdownTableCell', false)
const precondition = [
'markdownTableCell',
'!suggestWidgetVisible',
'!inSnippetMode',
'!inlineSuggestionVisible',
'!editorTabMovesFocus',
'!editorReadonly'
].join(' && ')
disposables = [
editor.onDidChangeCursorSelection(update),
editor.onDidChangeModelContent(update),
editor.addAction({
id: 'markdown.extension.table.nextCell',
label: 'Table: Next Cell',
keybindings: [monaco.KeyCode.Tab],
precondition,
run: () => move(1)
}),
editor.addAction({
id: 'markdown.extension.table.previousCell',
label: 'Table: Previous Cell',
keybindings: [monaco.KeyMod.Shift | monaco.KeyCode.Tab],
precondition,
run: () => move(-1)
})
]
}
function dispose() {
for (const disposable of disposables) {
disposable.dispose()
}
disposables = []
}
/** The table holding a line, if it is one the lenses are offered over. */
function tableAt(line) {
const model = getEditor().getModel()
if (cache.version !== model.getVersionId()) {
cache = { version: model.getVersionId(), tables: findEditableTables(model.getValue()) }
}
return cache.tables.find((table) => table.startLine <= line && line <= table.endLine)
}
/**
* Whether Tab is ours: one caret, or one selection on a single line -- since Tab selects a cell's
* text, the next Tab starts from a selection -- in a row of cells.
*/
function update() {
const selections = getEditor().getSelections() ?? []
const [selection] = selections
const table =
selections.length === 1 && selection.startLineNumber === selection.endLineNumber
? tableAt(selection.startLineNumber)
: null
inCell.set(Boolean(table) && isCellRow(table, selection.startLineNumber))
}
/**
* The move itself, from where the selection starts -- which for a cell's text Tab selected is the
* start of that text, so it reads as the cell it is in. Shift+Tab from the first cell of the table
* goes nowhere, rather than falling back to an outdent that would edit the row.
*/
function move(direction) {
const editor = getEditor()
const selection = editor.getSelection()
const table = tableAt(selection.startLineNumber)
const target =
table && tableCellMove(table, selection.startLineNumber, selection.startColumn, direction)
if (target === 'addRow') {
addRow(table.startLine)
} else if (target) {
const range = new Selection(target.line, target.startColumn, target.line, target.endColumn)
editor.setSelection(range)
editor.revealRangeInCenterIfOutsideViewport(range)
}
}
return { register, dispose }
}
+200 -19
View File
@@ -1,4 +1,4 @@
import { asciidocQuoteValue, blockAttributes } from '@/helpers/blocks'
import { asciidocQuoteValue, blockAsciidoc, blockAttributes, snippetText } from '@/helpers/blocks'
/**
* The blocks a page written in AsciiDoc already carries, read back and rewritten.
@@ -110,28 +110,16 @@ function closes(line, delimiter) {
}
/**
* Every block in the source, in the order they appear. Line numbers are 1-based, to be handed
* straight to the editor.
* The index of every line that is not inside a verbatim block, nor one of its delimiters, in order.
*
* `line` is the ATTRIBUTE line -- the one a lens is drawn over and the one `blockOpeningLine`
* rewrites -- and `delimiter` is what the block was opened with, which is what its own closing line
* has to match. `endLine` is that closing line, or the end of the source for a block still being
* written.
* Nothing inside a verbatim block is markup, so a `[block-x]` written in a code sample is a code
* sample.
*
* A block written inside a verbatim block is a code sample and not a block, so those are skipped.
* Nesting needs no tracking of its own: an attribute line stands on its own whatever it is written
* inside, and the first line matching a block's own delimiter is always its close -- AsciiDoc does
* not allow a delimited block to nest inside another of the SAME kind, which is why they alternate.
*
* @param {string} text The page source.
* @returns {Array<{ block: string, line: number, endLine: number, delimiter: string,
* attributes: Array }>}
* @param {string[]} lines The page source, split into lines.
* @returns {Generator<number>}
*/
export function findBlocks(text) {
const lines = text.split('\n')
const blocks = []
function* linesOutsideVerbatim(lines) {
let verbatim = null
for (let index = 0; index < lines.length; index++) {
const line = lines[index]
if (verbatim) {
@@ -145,7 +133,49 @@ export function findBlocks(text) {
verbatim = fence[1]
continue
}
yield index
}
}
/**
* Whether a line is outside every verbatim block.
*
* @param {string[]} lines The page source, split into lines.
* @param {number} index 0-based.
*/
function isOutsideVerbatim(lines, index) {
for (const outside of linesOutsideVerbatim(lines)) {
if (outside >= index) {
return outside === index
}
}
return false
}
/**
* Every block in the source, in the order they appear. Line numbers are 1-based, to be handed
* straight to the editor.
*
* `line` is the ATTRIBUTE line -- the one a lens is drawn over and the one `blockOpeningLine`
* rewrites -- and `delimiter` is what the block was opened with, which is what its own closing line
* has to match. `endLine` is that closing line, or the end of the source for a block still being
* written.
*
* Blocks inside a verbatim block are skipped (see `linesOutsideVerbatim`). Nesting needs no tracking
* of its own: an attribute line stands on its own whatever it is written
* inside, and the first line matching a block's own delimiter is always its close -- AsciiDoc does
* not allow a delimited block to nest inside another of the SAME kind, which is why they alternate.
*
* @param {string} text The page source.
* @returns {Array<{ block: string, line: number, endLine: number, delimiter: string,
* attributes: Array }>}
*/
export function findBlocks(text) {
const lines = text.split('\n')
const blocks = []
for (const index of linesOutsideVerbatim(lines)) {
const line = lines[index]
const opening = OPENING.exec(line)
/*
The delimiter is read off the NEXT line rather than assumed, because it is what closes the
@@ -182,6 +212,157 @@ function closingLineOf(lines, delimiterIndex, delimiter) {
return lines.length
}
/** An attribute line up to the end of the block's name, which may be partly typed or not at all. */
const OPENING_NAME = /^\[block-([a-z\d-]*)/
/**
* Whether the caret is in a block's NAME, for the editor to offer the blocks it could be.
*
* The same answer as the markdown side's, for `useBlockCompletions`. `bare` allows for the closing
* bracket, which the editor puts in as soon as the opening one is typed: `[block-cou]` is a line
* holding nothing yet, and picking a block replaces all of it.
*
* @param {string} text The page source.
* @param {number} line 1-based.
* @param {number} column 1-based, as Monaco counts it.
* @returns {{ prefix: string, startColumn: number, endColumn: number, bare: boolean } | null}
*/
export function blockNameContext(text, line, column) {
const lines = text.split('\n')
const source = lines[line - 1] ?? ''
const opening = OPENING_NAME.exec(source)
if (!opening) {
return null
}
const start = '[block-'.length
const end = opening[0].length
if (column - 1 < start || column - 1 > end || !isOutsideVerbatim(lines, line - 1)) {
return null
}
const bare = ['', ']'].includes(source.slice(end).trim())
return {
prefix: '[block-',
startColumn: start + 1,
endColumn: (bare ? source.length : end) + 1,
bare
}
}
/**
* A whole new block as a Monaco snippet: what picking a block from the name list writes, when the line
* holds nothing else yet.
*
* The same markup as `blockAsciidoc` with somewhere to type. Each REQUIRED prop is written with an
* empty value and a tab stop in it, and the caret ends up after them, before the closing bracket --
* where a comma opens the list of the rest.
*
* @param {{ block: string, props?: Array }} definition The block as the API describes it.
* @returns {string}
*/
export function blockSnippet(definition) {
const attributes = (definition.props ?? [])
.filter((prop) => prop.required)
.map((prop, index) => `, ${prop.name}="$${index + 1}"`)
.join('')
// -> Everything below the attribute line, which is the same whatever the attributes are
const markup = blockAsciidoc(definition)
const body = markup.slice(markup.indexOf('\n'))
return `[block-${definition.block}${attributes}$0]${snippetText(body)}`
}
/** An attribute line up to the end of the block's name. */
const OPENING_STYLE = /^\[block-([a-z\d-]+)/
/**
* Where the caret sits in a block's attribute list, for the editor to offer what can be written there.
*
* The same answer as the markdown side's, for `useBlockCompletions`, for AsciiDoc's spelling of it:
* every prop follows a COMMA. So a name goes after a comma -- straight after it, in which case
* `insertPrefix` puts the space in that `blockOpeningLine` would have written -- and a value after
* `name=`, quoted or not. Anywhere else is null: in the name, before the first comma, past the closing
* bracket, in a `.role` or `#id`, or after a closed value with no comma yet.
*
* @param {string} text The page source.
* @param {number} line 1-based.
* @param {number} column 1-based, as Monaco counts it.
* @returns {{ block: string, written: Set<string>, kind: 'name', startColumn: number,
* insertPrefix: string }
* | { block: string, written: Set<string>, kind: 'value', name: string, quoted: boolean,
* startColumn: number }
* | null}
*/
export function blockAttributeContext(text, line, column) {
const lines = text.split('\n')
const source = lines[line - 1] ?? ''
const opening = OPENING_STYLE.exec(source)
const base = opening?.[0].length
if (!opening || column - 1 < base || !isOutsideVerbatim(lines, line - 1)) {
return null
}
const inside = source.slice(base)
const caret = column - 1 - base
let quote = null
let valueStart = 0
// -> After the last comma; null before the first one, and -1 right after a closing quote
let tokenStart = null
for (let index = 0; index < caret; index++) {
const char = inside[index]
if (quote) {
if (char === '\\') {
index++
} else if (char === quote) {
quote = null
tokenStart = -1
}
} else if (char === '"' || char === "'") {
quote = char
valueStart = index + 1
} else if (char === ']') {
return null
} else if (char === ',') {
tokenStart = index + 1
}
}
if (tokenStart === null || tokenStart < 0) {
return null
}
const leading = /^[ \t]*/.exec(inside.slice(tokenStart, caret))[0].length
const start = tokenStart + leading
const token = inside.slice(start, caret)
if (token.startsWith('.') || token.startsWith('#')) {
return null
}
const block = opening[1]
const written = new Set(
[...inside.replace(/\][ \t]*$/, '').matchAll(ATTRIBUTE)]
.filter((match) => match[2] && match.index !== start)
.map((match) => match[2])
)
const equals = token.indexOf('=')
if (equals < 0) {
return quote
? null
: {
block,
written,
kind: 'name',
startColumn: base + start + 1,
insertPrefix: leading === 0 ? ' ' : ''
}
}
const padding = /^[ \t]*/.exec(token.slice(equals + 1))[0].length
return {
block,
written,
kind: 'value',
name: token.slice(0, equals).trim(),
quoted: Boolean(quote),
startColumn: base + (quote ? valueStart : start + equals + 1 + padding) + 1
}
}
/**
* Every tabset in the source, in order, as the line range of each of its panels.
*
+22 -6
View File
@@ -53,6 +53,26 @@ export function blockAttributes(block, values = {}, quote = mdcQuoteValue) {
return written.map((prop) => `${prop.name}=${quote(values[prop.name])}`)
}
/**
* The colons a block is fenced with. One holding blocks of its own is fenced with three rather than
* two, since against a two-colon fence the first `::` inside it would read as the end of this one.
*
* @param {{ template?: string }} block A block as the API describes it.
* @returns {string}
*/
export function blockFence(block) {
return /^::/m.test(block.template ?? '') ? ':::' : '::'
}
/**
* Text to put in a Monaco snippet as it stands: `$`, `}` and `\` are its syntax, and are escaped.
*
* For a block's starter body, which the editors' block-name completion writes as part of a snippet.
*/
export function snippetText(value) {
return value.replace(/[\\$}]/g, '\\$&')
}
/**
* A block, opening and closing lines included.
*
@@ -64,13 +84,9 @@ export function blockMarkdown(block, values = {}) {
const attributes = blockAttributes(block, values).join(' ')
const suffix = attributes ? `{${attributes}}` : ''
/*
A block that comes with a body to start from writes it between the two lines. One holding blocks
of its own is fenced with three colons rather than two, since against a two-colon fence the first
`::` inside it would read as the end of this one.
*/
// -> A block that comes with a body to start from writes it between the two lines
if (block.template) {
const fence = /^::/m.test(block.template) ? ':::' : '::'
const fence = blockFence(block)
return `${fence}block-${block.block}${suffix}\n${block.template}\n${fence}`
}
return `::block-${block.block}${suffix}\n::`
+44
View File
@@ -0,0 +1,44 @@
import hljs from 'highlight.js'
/**
* The languages a fenced code block can be given, as the editors offer them -- the toolbar's code
* block menu and the markdown editor's suggestions after a typed fence.
*
* The list is whatever highlight.js has registered, asked at runtime rather than kept as a copy, so it
* cannot drift from what the renderer will actually highlight. Each entry carries the id that goes on
* the fence and the name hljs calls it.
*/
/**
* The five worth having at the top. Labelled here rather than taking hljs's own names, which for these
* read as `Plain text`, `HTML, XML` and `Bash` — precise, and not what someone scanning a shortlist is
* looking for.
*
* Two of the ids are not what the list below would show either:
* - `sh` and `md` are aliases rather than registered ids, so they are absent from that list; hljs
* resolves them to Bash and Markdown, which is what highlights the block.
* - Plain text has no id at all. A bare fence is how markdown says "no language", and it is what the
* renderer already treats as unhighlighted — so there is nothing to put after the backticks. The
* menu shows a dash where the others show their id.
*/
export const COMMON_LANGUAGES = [
{ id: '', label: 'Plain Text' },
{ id: 'json', label: 'JSON' },
{ id: 'md', label: 'Markdown' },
{ id: 'sh', label: 'Bash Shell' },
{ id: 'xml', label: 'XML' }
]
/** Every registered language, by display name. Built once: the set cannot change at runtime. */
export const ALL_LANGUAGES = hljs
.listLanguages()
.map((id) => {
const definition = hljs.getLanguage(id)
return {
id,
label: definition?.name ?? id,
// -> Searched but never shown: `sh` and `zsh` are how someone looks for Bash
aliases: definition?.aliases ?? []
}
})
.sort((a, b) => a.label.localeCompare(b.label))
+158 -21
View File
@@ -1,4 +1,5 @@
import { blockAttributes } from '@/helpers/blocks'
import { blockAttributes, blockFence, snippetText } from '@/helpers/blocks'
import { FENCE, isOutsideFences, linesOutsideFences } from '@/helpers/markdownFences'
/**
* The blocks already in a page's source, read back and rewritten.
@@ -15,9 +16,6 @@ import { blockAttributes } from '@/helpers/blocks'
* offered for: a block that declares a `contentEditor` and holds a single fenced source.
*/
/** The opening or closing line of a fenced block, indented up to the three spaces markdown allows. */
const FENCE = /^ {0,3}(`{3,}|~{3,})/
/** The same line, with the info string after it — which for these is the language the body is in. */
const FENCE_OPENING = /^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`]*)/
@@ -67,9 +65,8 @@ function parseAttributes(source) {
* Every block in the source, in the order they appear. Line numbers are 1-based, to be handed
* straight to the editor.
*
* A block inside a fenced code block is a code sample and not a block, so those are skipped — the
* same reading `findEditableTables` takes of the same lines. Nesting needs no tracking of its own:
* every opening line stands on its own, whatever it is written inside.
* Blocks inside a fenced code block are skipped (see `linesOutsideFences`). Nesting needs no tracking
* of its own: every opening line stands on its own, whatever it is written inside.
*
* @param {string} text The page source.
* @returns {Array<{ block: string, line: number, fence: string, attributes: Array }>}
@@ -77,21 +74,8 @@ function parseAttributes(source) {
export function findBlocks(text) {
const lines = text.split('\n')
const blocks = []
let fence = null
for (let index = 0; index < lines.length; index++) {
const edge = FENCE.exec(lines[index])
if (fence) {
if (edge && edge[1][0] === fence[0] && edge[1].length >= fence.length) {
fence = null
}
continue
}
if (edge) {
fence = edge[1]
continue
}
for (const index of linesOutsideFences(lines)) {
const opening = OPENING.exec(lines[index])
if (opening) {
blocks.push({
@@ -105,6 +89,159 @@ export function findBlocks(text) {
return blocks
}
/** A block's opening line up to the end of its name, which may be partly typed or not typed at all. */
const OPENING_NAME = /^(:{2,})block-([a-z0-9-]*)/
/**
* Whether the caret is in a block's NAME, for the editor to offer the blocks it could be.
*
* `prefix` is the line up to the name. `startColumn` and `endColumn` span the whole name, the part after the caret included, so that picking
* a block replaces the name rather than splicing into it. `bare` says the line holds nothing past the
* name — a block being started rather than one being renamed, which is what decides whether picking one
* writes a whole block or only its name.
*
* @param {string} text The page source.
* @param {number} line 1-based.
* @param {number} column 1-based, as Monaco counts it.
* @returns {{ fence: string, prefix: string, startColumn: number, endColumn: number, bare: boolean }
* | null}
*/
export function blockNameContext(text, line, column) {
const lines = text.split('\n')
const source = lines[line - 1] ?? ''
const opening = OPENING_NAME.exec(source)
if (!opening) {
return null
}
const start = opening[1].length + 'block-'.length
const end = opening[0].length
if (column - 1 < start || column - 1 > end || !isOutsideFences(lines, line - 1)) {
return null
}
return {
fence: opening[1],
prefix: source.slice(0, start),
startColumn: start + 1,
endColumn: end + 1,
bare: source.slice(end).trim() === ''
}
}
/**
* A whole new block as a Monaco snippet: what picking a block from the name list writes, when the line
* holds nothing else yet.
*
* The same markup as `blockMarkdown` with somewhere to type. Each REQUIRED prop is written with an empty
* value and a tab stop in it — the block cannot draw without them — while the rest are left for the
* parameter list, which is where the caret ends up: inside the braces after them, or at the end of the
* name where there are none. The fence is whichever is longer of the one typed and the one the block
* needs, since an author who typed three colons did so to fit it inside something.
*
* @param {{ block: string, props?: Array, template?: string }} definition The block as the API describes it.
* @param {{ fence: string }} context Where it is being typed, from `blockNameContext`.
* @returns {string}
*/
export function blockSnippet(definition, context) {
const attributes = (definition.props ?? [])
.filter((prop) => prop.required)
.map((prop, index) => `${prop.name}="$${index + 1}"`)
.join(' ')
const needed = blockFence(definition)
const fence = context.fence.length > needed.length ? context.fence : needed
const body = definition.template ? `\n${snippetText(definition.template)}` : ''
return `${fence}block-${definition.block}${attributes ? `{${attributes}$0}` : '$0'}${body}\n${fence}`
}
/**
* A block's opening line up to the brace its attributes start at.
*
* Looser than `OPENING` on purpose: that one wants the whole line, closing brace included, while this
* is asked about a line still being typed — where the brace may not be closed yet.
*/
const OPENING_BRACE = /^:{2,}block-([a-z0-9-]+)[ \t]*\{/
/**
* Where the caret sits in a block's attribute list, for the editor to offer what can be written there.
*
* Two places are worth answering for. Where a NAME goes — straight after the brace, or after the
* whitespace that ends the attribute before — and inside a VALUE, after `name=`, quoted or not. Anywhere
* else is null: outside the braces, past the closing one, in a `.class` or `#id`, or butted up against
* the quote that closes the previous value, where a name would run on from it.
*
* `written` is the props the list already gives, less the one under the caret, so a name is not
* offered twice — and a name being retyped is still offered.
*
* @param {string} text The page source.
* @param {number} line 1-based.
* @param {number} column 1-based, as Monaco counts it: the caret is before the character here.
* @returns {{ block: string, written: Set<string>, kind: 'name', startColumn: number }
* | { block: string, written: Set<string>, kind: 'value', name: string, quoted: boolean,
* startColumn: number }
* | null} `startColumn` is where what is being typed began, for the range to replace.
*/
export function blockAttributeContext(text, line, column) {
const lines = text.split('\n')
const source = lines[line - 1] ?? ''
const opening = OPENING_BRACE.exec(source)
const base = opening?.[0].length
if (!opening || column - 1 < base) {
return null
}
// -> A block written inside a fenced code block is a code sample
if (!isOutsideFences(lines, line - 1)) {
return null
}
const inside = source.slice(base)
const caret = column - 1 - base
let quote = null
let valueStart = 0
// -> Where the token under the caret began, or -1 right after a closing quote
let tokenStart = 0
for (let index = 0; index < caret; index++) {
const char = inside[index]
if (quote) {
if (char === quote) {
quote = null
tokenStart = -1
}
} else if (char === '"' || char === "'") {
quote = char
valueStart = index + 1
} else if (char === '}') {
return null
} else if (char === ' ' || char === '\t') {
tokenStart = index + 1
}
}
if (tokenStart < 0) {
return null
}
const block = opening[1]
const written = new Set(
[...inside.replace(/\}[ \t]*$/, '').matchAll(ATTRIBUTE)]
.filter((match) => !match[1] && match.index !== tokenStart)
.map((match) => match[2])
)
const token = inside.slice(tokenStart, caret)
const equals = token.indexOf('=')
if (token.startsWith('.') || token.startsWith('#')) {
return null
}
if (equals < 0) {
return quote ? null : { block, written, kind: 'name', startColumn: base + tokenStart + 1 }
}
return {
block,
written,
kind: 'value',
name: token.slice(0, equals),
quoted: Boolean(quote),
startColumn: base + (quote ? valueStart : tokenStart + equals + 1) + 1
}
}
/**
* What the form should open on: the block's props, filled in from what the page gave them.
*
+223
View File
@@ -0,0 +1,223 @@
import { DIAGRAM_LANGUAGES, parseFenceAttributes } from '@/renderers/shared'
/**
* Fenced code blocks in a page's markdown source, as the editor reads them.
*
* Which lines a fence covers is asked by the block helpers -- a block written inside a code sample is
* a code sample -- and by the editor's help with writing a fence: closing one as it is opened, and
* completing its language and attributes. So the reading is kept here once.
*/
/** The opening or closing line of a fenced block, indented up to the three spaces markdown allows. */
export const FENCE = /^ {0,3}(`{3,}|~{3,})/
/**
* What each line of the source is to the fences in it: `open`, `close`, `inside` or `outside`.
*
* A closing fence is the same character as the one that opened and at least as long; anything else
* that looks like a fence while one is open is a line of its code.
*
* @param {string[]} lines The page source, split into lines.
* @returns {Generator<'open'|'close'|'inside'|'outside'>} One per line, in order.
*/
function* fenceRoles(lines) {
let fence = null
for (const line of lines) {
const edge = FENCE.exec(line)
if (fence) {
if (edge && edge[1][0] === fence[0] && edge[1].length >= fence.length) {
fence = null
yield 'close'
} else {
yield 'inside'
}
} else if (edge) {
fence = edge[1]
yield 'open'
} else {
yield 'outside'
}
}
}
/** The role of one line, read off everything above it. */
function fenceRoleOf(lines, index) {
let current = 0
for (const role of fenceRoles(lines)) {
if (current === index) {
return role
}
current++
}
return 'outside'
}
/**
* The index of every line that is not inside a fenced code block, nor one of its fences, in order.
*
* @param {string[]} lines The page source, split into lines.
* @returns {Generator<number>}
*/
export function* linesOutsideFences(lines) {
let index = 0
for (const role of fenceRoles(lines)) {
if (role === 'outside') {
yield index
}
index++
}
}
/**
* Whether a line is outside every fenced code block.
*
* @param {string[]} lines The page source, split into lines.
* @param {number} index 0-based.
*/
export function isOutsideFences(lines, index) {
return fenceRoleOf(lines, index) === 'outside'
}
/**
* Whether the fences in these lines all pair up, read from the top as if nothing were open above them.
*
* @param {string[]} lines
*/
function fencesBalanced(lines) {
let open = false
for (const role of fenceRoles(lines)) {
if (role === 'open') {
open = true
} else if (role === 'close') {
open = false
}
}
return !open
}
/** A line that is three backticks and nothing else, indented as far as markdown allows. */
const BARE_BACKTICK_FENCE = /^( {0,3})```$/
/**
* Whether a line has just become the OPENING of a code block -- three backticks and nothing else -- and
* so whether the editor should close it and offer a language.
*
* `unclosed` says nothing below closes it, and is decided by whether the fences BELOW this line pair up
* among themselves. They do when the author is starting a new block above existing ones, each of which
* has its own closing fence; they do not when the author is retyping the opening of a block that
* already has one, whose closing fence is then the odd one out below. Only the first wants another
* closing fence -- in the second, adding one would cut the block in two.
*
* @param {string} text The page source.
* @param {number} line 1-based.
* @returns {{ indent: string, unclosed: boolean } | null}
*/
export function fenceJustOpened(text, line) {
const lines = text.split('\n')
const opening = BARE_BACKTICK_FENCE.exec(lines[line - 1] ?? '')
if (!opening || fenceRoleOf(lines, line - 1) !== 'open') {
return null
}
return { indent: opening[1], unclosed: fencesBalanced(lines.slice(line)) }
}
/** An opening fence and the language after it, which may be partly typed or not typed at all. */
const FENCE_LANGUAGE = /^( {0,3}(?:`{3,}|~{3,}))([^\s`]*)/
/**
* Whether the caret is in a fence's LANGUAGE, for the editor to offer the languages it could be.
*
* Only on an opening fence. `startColumn` and `endColumn` span the whole language word, the part after
* the caret included, so that picking one replaces it rather than splicing into it.
*
* @param {string} text The page source.
* @param {number} line 1-based.
* @param {number} column 1-based, as Monaco counts it.
* @returns {{ startColumn: number, endColumn: number } | null}
*/
export function fenceLanguageContext(text, line, column) {
const lines = text.split('\n')
const opening = FENCE_LANGUAGE.exec(lines[line - 1] ?? '')
if (!opening) {
return null
}
const start = opening[1].length
const end = opening[0].length
if (column - 1 < start || column - 1 > end || fenceRoleOf(lines, line - 1) !== 'open') {
return null
}
return { startColumn: start + 1, endColumn: end + 1 }
}
/**
* The attributes a fence may carry after its language, in the order they are offered. Spelled as the
* renderer documents them; it reads them case-insensitively. See `parseFenceAttributes`.
*/
export const FENCE_ATTRIBUTES = ['title', 'linesStart', 'linesHighlight']
/** An opening fence, its language, and the whitespace that ends the language. */
const FENCE_INFO = /^( {0,3}(?:`{3,}|~{3,}))([^\s`]+)[ \t]/
/**
* Whether the caret is where a fence attribute's NAME goes, for the editor to offer the ones not given.
*
* Only on an OPENING fence and only after its language: markdown-it takes the first word of the info
* string as the language whatever it says, so `title="x"` written with no language before it would be
* read as a language called that. A diagram fence is left out too, since it is a source for a block to
* draw and the renderer ignores all three there. And only where a name goes -- after whitespace, not
* inside a value or butted up against the quote that closes one. None of the three has a fixed set of
* values to offer.
*
* `written` holds the attributes the line already gives, in lower case as the renderer reads them, less
* the one under the caret.
*
* @param {string} text The page source.
* @param {number} line 1-based.
* @param {number} column 1-based, as Monaco counts it.
* @returns {{ written: Set<string>, startColumn: number } | null}
*/
export function fenceAttributeContext(text, line, column) {
const lines = text.split('\n')
const source = lines[line - 1] ?? ''
const opening = FENCE_INFO.exec(source)
if (!opening || DIAGRAM_LANGUAGES.includes(opening[2])) {
return null
}
const base = opening[1].length + opening[2].length
if (column - 1 <= base || fenceRoleOf(lines, line - 1) !== 'open') {
return null
}
const inside = source.slice(base)
const caret = column - 1 - base
let quote = null
// -> Where the token under the caret began, or -1 right after a closing quote
let tokenStart = 0
for (let index = 0; index < caret; index++) {
const char = inside[index]
if (quote) {
if (char === '\\') {
index++
} else if (char === quote) {
quote = null
tokenStart = -1
}
} else if (char === '"' || char === "'") {
quote = char
} else if (char === ' ' || char === '\t') {
tokenStart = index + 1
}
}
const token = tokenStart < 0 ? '' : inside.slice(tokenStart, caret)
if (quote || tokenStart < 0 || token.includes('=')) {
return null
}
// -> The line without the token under the caret, which may be a name being retyped
const tokenEnd = caret + /^\S*/.exec(inside.slice(caret))[0].length
const rest = `${inside.slice(0, tokenStart)} ${inside.slice(tokenEnd)}`
return {
written: new Set(Object.keys(parseFenceAttributes(rest))),
startColumn: base + tokenStart + 1
}
}
+231
View File
@@ -26,6 +26,12 @@ export const ALIGNMENTS = ['left', 'center', 'right']
/** A delimiter row's cell, and nothing else: dashes, with a colon at either end or both. */
const DELIMITER_CELL = /^:?-+:?$/
/**
* A delimiter cell of a compact column: three characters, with at most one space either side. Only asked
* of a cell already known to be a delimiter cell, so three of `-` and `:` is enough to say it.
*/
const COMPACT_DELIMITER_CELL = /^ ?[-:]{3} ?$/
/** The opening or closing line of a fenced block, indented up to the three spaces markdown allows. */
const FENCE = /^ {0,3}(`{3,}|~{3,})/
@@ -315,3 +321,228 @@ export function findEditableTables(text) {
return tables
}
/**
* A table's lines as the two quick actions below need them: which line is the delimiter row, and where
* the rows end -- before the `{.class}` line, if the table has one, which is not a row.
*
* @param {string} source A table's source, from `findEditableTables`.
*/
function tableLayout(source) {
const lines = source.split('\n')
return {
lines,
delimiterAt: parseDelimiters(lines[0]) ? 0 : 1,
lastRow: ATTRS_LINE.test(lines.at(-1) ?? '') ? lines.length - 2 : lines.length - 1,
columns: parseTable(source).align.length
}
}
/**
* An empty row for the "Add Row" lens, and where it goes: under the row the caret is in, or under the
* last row when the caret is in the header, the delimiter row, or not in the table at all.
*
* Edited in place rather than through `buildTable`, which would rewrite every row and state alignments
* the author left out. The new row is built cell by cell off the delimiter row, with its pipes and
* indentation, and each cell is as wide as the delimiter cell above it -- unless that one is the three
* characters a compact column is written with (`---`, `:-:`, with or without a space either side), in
* which case the column is not padded and its new cell is two spaces, the empty cell compact tables
* write. A column the delimiter row is short of -- see `parseTable` -- is added the compact way too.
*
* @param {{ startLine: number, source: string }} table From `findEditableTables`.
* @param {number|null} caretLine 1-based, or null for no caret in the editor.
* @returns {{ afterLine: number, text: string, column: number }} The row, the 1-based line it goes
* under, and the 1-based column of its first cell.
*/
export function tableRowToAdd(table, caretLine) {
const { lines, delimiterAt, lastRow, columns } = tableLayout(table.source)
const offset = (caretLine ?? 0) - table.startLine
const after = offset > delimiterAt && offset <= lastRow ? offset : lastRow
const delimiter = lines[delimiterAt].trimEnd()
const indent = delimiter.slice(0, delimiter.length - delimiter.trimStart().length)
const leading = delimiter.trimStart().startsWith('|')
const raw = rawCells(delimiter)
// -> Without a leading pipe the indentation is the first cell's, and is not part of its width
if (!leading) {
raw[0] = raw[0].slice(indent.length)
}
const cells = raw.map((cell) =>
COMPACT_DELIMITER_CELL.test(cell) ? ' ' : cell.replaceAll(/[-:]/g, ' ')
)
cells.push(...Array(Math.max(0, columns - cells.length)).fill(' '))
/*
Two things change for a table written without outer pipes. The row is closed with one, since an
empty last cell with nothing after it is no cell at all. And the first cell is cut to one space: it
is all whitespace at the start of the line, and four of it would make the row an indented code
block.
*/
if (!leading) {
cells[0] = ' '
}
const text = `${indent}${leading ? '|' : ''}${cells.join('|')}|`
// -> One space into the first cell, or as far as it goes in a table written `|-|`
const first = text.indexOf('|')
const next = text.indexOf('|', first + 1)
const column = leading ? Math.min(first + 2, next < 0 ? text.length : next) : indent.length
return { afterLine: table.startLine + after, text, column: column + 1 }
}
/**
* Every row of a table with one empty column added at the end, for the "Add Column" lens.
*
* In place, for the reason `tableRowToAdd` gives, and compact: ` --- ` in the delimiter row and an
* empty cell of two spaces in every other, which is the shape `tableRowToAdd` reads a compact column
* as. A row with fewer cells than the table -- which markdown allows -- is filled out first, the same
* way, or the new cell would land in somebody else's column. And
* every row ends in a pipe afterwards: without one, an empty last cell is not a cell at all, since a
* trailing pipe is read as closing the row rather than as opening another.
*
* @param {{ startLine: number, source: string }} table From `findEditableTables`.
* @returns {{ startLine: number, endLine: number, text: string, caret: { line: number, column: number } }}
* The rows to replace, 1-based and inclusive, what replaces them, and where the caret goes:
* the new cell of the first row that is not the delimiter.
*/
export function tableWithColumnAdded(table) {
const { lines, delimiterAt, lastRow, columns } = tableLayout(table.source)
const rows = lines.slice(0, lastRow + 1).map((line, index) => {
const blank = index === delimiterAt ? ' --- |' : ' |'
const added = Math.max(0, columns - rawCells(line).length) + 1
const trimmed = line.trimEnd()
const closed = trimmed.endsWith('|') && !trimmed.endsWith('\\|')
return `${closed ? trimmed : `${trimmed} |`}${blank.repeat(added)}`
})
const caretRow = delimiterAt === 0 ? 1 : 0
return {
startLine: table.startLine,
endLine: table.startLine + lastRow,
text: rows.join('\n'),
// -> One space into the new cell, which each row ends with as ` |`
caret: { line: table.startLine + caretRow, column: rows[caretRow].length - 1 }
}
}
/**
* Where each cell of one row sits, 0-based with `end` exclusive: between its pipes (`start`, `end`) and
* where its text is once the padding is left out (`textStart`, `textEnd`).
*
* The same reading as `rawCells` -- an escaped `\|` is part of a cell, and the outer pipes leave no
* cell of their own -- but positions rather than strings, which is what moving the caret needs.
*
* @param {string} line One row of a table.
* @returns {Array<{ start: number, end: number, textStart: number, textEnd: number }>}
*/
function cellSpans(line) {
const bounds = [-1]
for (let index = 0; index < line.length; index++) {
if (line[index] === '\\' && line[index + 1] === '|') {
index++
} else if (line[index] === '|') {
bounds.push(index)
}
}
bounds.push(line.length)
const spans = bounds.slice(0, -1).map((bound, index) => {
const start = bound + 1
const end = bounds[index + 1]
const text = line.slice(start, end)
const textStart = start + text.length - text.trimStart().length
return { start, end, textStart, textEnd: textStart + text.trim().length }
})
const trimmed = line.trim()
const blank = (span) => span && span.textStart === span.textEnd
if (trimmed.startsWith('|') && blank(spans[0])) {
spans.shift()
}
if (trimmed.endsWith('|') && !trimmed.endsWith('\\|') && blank(spans.at(-1))) {
spans.pop()
}
return spans
}
/**
* The indexes of a table's rows that hold cells: every line of it but the delimiter row and the
* `{.class}` line.
*/
function cellRows(source) {
const { lines, delimiterAt, lastRow } = tableLayout(source)
return {
lines,
rows: Array.from({ length: lastRow + 1 }, (_, index) => index).filter(
(index) => index !== delimiterAt
)
}
}
/**
* Whether a line of the source is one of the table's rows of cells -- the header or a body row -- which
* is where Tab moves between cells rather than indenting.
*
* @param {{ startLine: number, source: string }} table From `findEditableTables`.
* @param {number} line 1-based.
*/
export function isCellRow(table, line) {
return cellRows(table.source).rows.includes(line - table.startLine)
}
/**
* Where Tab (`direction` 1) or Shift+Tab (-1) takes the caret from a cell: the next cell along the row,
* or the first cell of the next row from the end of one -- and the other way round. The delimiter row is
* never a stop.
*
* The cell the caret is in is the one whose closing pipe it has not passed, so a caret in the padding
* or against a pipe belongs to the cell on its left, and one in the indentation before a row to its
* first cell. A row written short of cells is simply left sooner.
*
* What it answers is a range to select: the cell's text, so that typing replaces it the way it does in a
* spreadsheet, or a caret one space into a cell that is empty. Past the last cell of the table it
* answers `'addRow'`, for the editor to add one and move into it; before the first, null.
*
* @param {{ startLine: number, source: string }} table From `findEditableTables`.
* @param {number} line 1-based.
* @param {number} column 1-based.
* @param {1|-1} direction
* @returns {{ line: number, startColumn: number, endColumn: number } | 'addRow' | null}
*/
export function tableCellMove(table, line, column, direction) {
const { lines, rows } = cellRows(table.source)
const at = rows.indexOf(line - table.startLine)
if (at < 0) {
return null
}
const target = (row, span) => {
if (!span) {
return null
}
if (span.textEnd > span.textStart) {
return {
line: table.startLine + row,
startColumn: span.textStart + 1,
endColumn: span.textEnd + 1
}
}
const caret = Math.min(span.start + 1, span.end) + 1
return { line: table.startLine + row, startColumn: caret, endColumn: caret }
}
const spans = cellSpans(lines[rows[at]])
const found = spans.findIndex((span) => column - 1 <= span.end)
const cell = found < 0 ? spans.length - 1 : found
if (direction > 0) {
if (cell + 1 < spans.length) {
return target(rows[at], spans[cell + 1])
}
if (at + 1 < rows.length) {
return target(rows[at + 1], cellSpans(lines[rows[at + 1]])[0])
}
return 'addRow'
}
if (cell > 0) {
return target(rows[at], spans[cell - 1])
}
if (at > 0) {
return target(rows[at - 1], cellSpans(lines[rows[at - 1]]).at(-1))
}
return null
}
+7 -1
View File
@@ -228,6 +228,12 @@ function lineRows(lineCount, lineStart, highlights) {
return `<span aria-hidden="true" class="line-numbers-rows">${rows.join('')}</span>`
}
/**
* The fence languages that are a diagram's source rather than code: left for the block holding them to
* draw, and given none of a code block's furniture.
*/
export const DIAGRAM_LANGUAGES = ['kroki', 'mermaid', 'plantuml']
/**
* One fence, as the markup it is drawn as -- always rooted at a `<pre>`, whatever it holds.
*
@@ -238,7 +244,7 @@ function lineRows(lineCount, lineStart, highlights) {
* no gutter, no title bar and no lines to mark.
*/
export function codeBlock(str, lang, attributes) {
if (['kroki', 'mermaid', 'plantuml'].includes(lang)) {
if (DIAGRAM_LANGUAGES.includes(lang)) {
/*
Left as source, deliberately: a diagram is drawn by the block whose body it is —
`block-diagram` for mermaid, `block-plantuml` and `block-kroki` for the others — and each