mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 02:21:48 +00:00
feat: add blocks params autocomplete + code block / table helpers
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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::`
|
||||
|
||||
@@ -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))
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user