diff --git a/CLAUDE.md b/CLAUDE.md index 2a03414b6..1fa0d327f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -238,6 +238,25 @@ the frontend adds the `vue` plugin and the `API_CLIENT` / `EVENT_BUS` / `Tempora Both tools handle `.ts` with no extra configuration, and the backend's oxlint config already enables the `typescript` plugin. oxlint does not type-check — run `npm run typecheck` for that. +**Never put two statements in a Vue template attribute.** `@click="doOne(); doTwo()"` builds today +and is a build error the moment the file is formatted, because `semi: false` and Vue disagree about +the same character. Vue's `transformOn` decides whether an inline handler is a statement block or an +expression from `exp.content.includes(';')` — with the semicolon it emits `$event => { … }`, +without it `$event => ( … )`. oxfmt breaks the handler across lines and drops the semicolon, so Vue +parenthesises two statements and the template fails to compile (`Error parsing JavaScript +expression: Unexpected token`). Write a named handler instead — `@click="closeAndRefresh"` — as +`EditorMarkdown.vue` and `PageRelationDialog.vue` do. + +Neither side of that is worth reconfiguring, so don't try: the `includes(';')` check has no compiler +option behind it, and the parse error is raised by the built-in `transformExpression`, which +`baseCompile` runs *before* any `nodeTransforms` you could add — and Volar runs the same compiler, +so a build-time workaround would still leave the editor showing errors. On the formatter side, +`embeddedLanguageFormatting: "off"` does leave attribute expressions alone but also stops formatting +every ` + + diff --git a/frontend/src/components/EditorMarkdown.vue b/frontend/src/components/EditorMarkdown.vue index 60733ed12..aa4bff1e8 100644 --- a/frontend/src/components/EditorMarkdown.vue +++ b/frontend/src/components/EditorMarkdown.vue @@ -29,12 +29,12 @@ t('editor.markup.insertTable') }} - + {{ t('editor.markup.insertTabset') }} - + {{ t('editor.markup.insertBlock') }} @@ -44,7 +44,7 @@ t('editor.markup.insertDiagram') }} - + {{ t('editor.markup.insertFootnote') }} @@ -276,6 +276,7 @@ import { useI18n } from 'vue-i18n' import { dialog } from '@/composables/dialog' import { notify } from '@/composables/notify' +import { blockMarkdown } from '@/helpers/blocks' import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue' import EditorEmojiMenu from '@/components/EditorEmojiMenu.vue' @@ -398,6 +399,55 @@ function insertEmoji(shortcode) { insertAtCursor({ content: `:${shortcode}:` }) } +function insertBlock() { + siteStore.$patch({ + overlay: 'BlockPicker' + }) +} + +/** + * The tabset, without going through the picker. + * + * A shortcut to picking Tabs from the block list and inserting it as it stands, so the markup is + * built from the same definition rather than written out a second time here — a change to the block's + * starter body reaches both. It still asks the server which blocks this site has: a shortcut to a + * block an administrator switched off would insert something the page cannot draw. + */ +async function insertTabset() { + try { + const blocks = (await API_CLIENT.get(`sites/${siteStore.id}/blocks`).json()) ?? [] + const tabs = blocks.find((block) => block.block === `tabs` && block.isEnabled) + if (!tabs) { + notify({ + type: 'warning', + message: t('editor.blockPicker.blockUnavailable') + }) + return + } + insertBlockClb(blockMarkdown(tabs)) + } catch (err) { + notify({ + type: 'negative', + message: t('editor.blockPicker.loadFailed'), + caption: err.message + }) + } +} + +/** + * The block the picker built, on its own lines. + * + * MDC's block syntax only opens a component when `::` starts a line, so a cursor mid-sentence breaks + * out of it first — the same rule the table follows. + */ +function insertBlockClb(markdown) { + const position = editor.getPosition() + const line = editor.getModel().getLineContent(position.lineNumber) + const before = line.slice(0, position.column - 1).trim().length > 0 ? '\n\n' : '' + const after = line.slice(position.column - 1).trim().length > 0 ? '\n\n' : '\n' + insertAtCursor({ content: `${before}${markdown}${after}` }) +} + function insertTable() { siteStore.$patch({ overlay: 'TableEditor' @@ -429,6 +479,59 @@ function insertTableClb(markdown) { * `{target="_blank"}` is markdown-it-attrs syntax, and `target` is one of the three attributes the * stored render is allowed to keep — see `renderers/markdown.js` and `models/rendering.ts`. */ +/** + * The number to give the next footnote. + * + * Markdown numbers footnotes in the order they are referenced, not by their labels, so these are + * names rather than positions — but an author reading the source expects them to count up, and two + * notes sharing a name would collapse into one. Anything the author named themselves is left alone + * and simply counted past. + */ +function nextFootnoteLabel(text) { + let highest = 0 + for (const [, label] of text.matchAll(/\[\^([^\]\s]+)\]/g)) { + if (/^\d+$/.test(label)) { + highest = Math.max(highest, Number.parseInt(label, 10)) + } + } + return String(highest + 1) +} + +/** + * A footnote: the marker where the cursor is, and the note itself at the foot of the source. + * + * Both halves in one edit, because either alone is broken — a marker with no note renders as literal + * text, and a note nothing refers to renders as nothing at all. The cursor ends on the note, since + * writing it is what the author was about to do; the marker is already where they left it. + */ +function insertFootnote() { + const model = editor.getModel() + const label = nextFootnoteLabel(model.getValue()) + const cursor = editor.getPosition() + const lastLine = model.getLineCount() + const lastLineLength = model.getLineContent(lastLine).length + // -> On a line of its own at the end, one blank line clear of whatever the page ends with + const lead = lastLineLength > 0 ? `\n\n` : `` + + editor.executeEdits('', [ + { + range: new Range(cursor.lineNumber, cursor.column, cursor.lineNumber, cursor.column), + text: `[^${label}]`, + forceMoveMarkers: true + }, + { + range: new Range(lastLine, lastLineLength + 1, lastLine, lastLineLength + 1), + text: `${lead}[^${label}]: `, + forceMoveMarkers: true + } + ]) + + const noteLine = model.getLineCount() + editor.setPosition({ lineNumber: noteLine, column: model.getLineContent(noteLine).length + 1 }) + editor.revealLineInCenterIfOutsideViewport(noteLine) + editor.focus() +} + function insertLink() { dialog({ component: LinkPickerDialog }).onOk(({ href, openInNewTab, title }) => { const selection = editor.getSelection() @@ -920,6 +1023,7 @@ onMounted(async () => { EVENT_BUS.on('insertAsset', insertAssetClb) EVENT_BUS.on('insertTable', insertTableClb) + EVENT_BUS.on('insertBlock', insertBlockClb) EVENT_BUS.on('openEditorSettings', openEditorSettings) EVENT_BUS.on('reloadEditorContent', reloadEditorContent) @@ -959,6 +1063,7 @@ onMounted(async () => { onBeforeUnmount(() => { EVENT_BUS.off('insertAsset', insertAssetClb) EVENT_BUS.off('insertTable', insertTableClb) + EVENT_BUS.off('insertBlock', insertBlockClb) EVENT_BUS.off('openEditorSettings', openEditorSettings) EVENT_BUS.off('reloadEditorContent', reloadEditorContent) pasteCaptureNode?.removeEventListener('paste', onEditorPaste, true) diff --git a/frontend/src/components/MainOverlayDialog.vue b/frontend/src/components/MainOverlayDialog.vue index 0d124ca6b..54231202d 100644 --- a/frontend/src/components/MainOverlayDialog.vue +++ b/frontend/src/components/MainOverlayDialog.vue @@ -17,6 +17,10 @@ import { useSiteStore } from '../stores/site' import LoadingGeneric from './LoadingGeneric.vue' const overlays = { + BlockPicker: defineAsyncComponent({ + loader: () => import('./BlockPickerOverlay.vue'), + loadingComponent: LoadingGeneric + }), EditorMarkdownConfig: defineAsyncComponent({ loader: () => import('./EditorMarkdownUserSettingsOverlay.vue'), loadingComponent: LoadingGeneric diff --git a/frontend/src/components/PageToc.vue b/frontend/src/components/PageToc.vue index a8429368c..cb7549beb 100644 --- a/frontend/src/components/PageToc.vue +++ b/frontend/src/components/PageToc.vue @@ -26,6 +26,7 @@