diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/quarto-decorations.ts b/services/web/frontend/js/features/source-editor/extensions/visual/quarto-decorations.ts new file mode 100644 index 0000000000..464fe55e1e --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/quarto-decorations.ts @@ -0,0 +1,220 @@ +import { + Decoration, + DecorationSet, + ViewPlugin, + ViewUpdate, +} from '@codemirror/view' +import { EditorState, Range } from '@codemirror/state' +import { syntaxTree } from '@codemirror/language' +import { Tree } from '@lezer/common' +import { selectionIntersects } from './selection' + +/** + * Returns true when the node range should have its markers hidden: + * either the document is read-only, or the cursor is not inside the node. + */ +function shouldDecorate( + state: EditorState, + from: number, + to: number +): boolean { + return state.readOnly || !selectionIntersects(state.selection, { from, to }) +} + +// ATX heading node names → heading level string +const ATX_HEADING_LEVEL: Record = { + ATXHeading1: '1', + ATXHeading2: '2', + ATXHeading3: '3', + ATXHeading4: '4', + ATXHeading5: '5', + ATXHeading6: '6', +} + +/** + * Visual-mode decorations for Quarto/Markdown documents (.qmd, .md). + * + * Hides syntactic markers (#/*/_ etc.) when the cursor is outside them and + * applies CSS classes for headings, bold, italic, strikethrough, and inline + * code. Mirrors the pattern used by typstDecorations for Typst files. + * + * Only covers ATX headings (# … ####### …) — Setext headings are skipped. + */ +export const quartoDecorations = ViewPlugin.define( + view => { + const createDecorations = ( + state: EditorState, + tree: Tree + ): DecorationSet => { + const decorations: Range[] = [] + + for (const { from, to } of view.visibleRanges) { + tree.iterate({ + from, + to, + enter(nodeRef) { + const { from: nFrom, to: nTo } = nodeRef + const level = ATX_HEADING_LEVEL[nodeRef.type.name] + + // ── ATX headings: # Title, ## Title, … ───────────────────────── + if (level !== undefined) { + const mark = nodeRef.node.getChild('HeaderMark') + if (mark) { + const cls = `ol-cm-md-heading ol-cm-md-h${level}` + if (shouldDecorate(state, nFrom, nTo)) { + // Hide the `# ` prefix (mark + optional trailing space) + const afterMark = state.doc.sliceString(mark.to, mark.to + 1) + const contentFrom = + afterMark === ' ' ? mark.to + 1 : mark.to + decorations.push( + Decoration.replace({}).range(nFrom, contentFrom) + ) + if (contentFrom < nTo) { + decorations.push( + Decoration.mark({ class: cls }).range(contentFrom, nTo) + ) + } + } + } + return false + } + + switch (nodeRef.type.name) { + // ── Bold: **text** or __text__ ───────────────────────────────── + case 'StrongEmphasis': { + if (shouldDecorate(state, nFrom, nTo)) { + const marks = nodeRef.node.getChildren('EmphasisMark') + const first = marks[0] + const last = marks[marks.length - 1] + if (first && last && first !== last) { + decorations.push( + Decoration.replace({}).range(first.from, first.to) + ) + if (last.from > first.to) { + decorations.push( + Decoration.mark({ + class: 'ol-cm-md-strong', + inclusive: true, + }).range(first.to, last.from) + ) + } + decorations.push( + Decoration.replace({}).range(last.from, last.to) + ) + } + } + return false + } + + // ── Italic: *text* or _text_ ──────────────────────────────────── + case 'Emphasis': { + if (shouldDecorate(state, nFrom, nTo)) { + const marks = nodeRef.node.getChildren('EmphasisMark') + const first = marks[0] + const last = marks[marks.length - 1] + if (first && last && first !== last) { + decorations.push( + Decoration.replace({}).range(first.from, first.to) + ) + if (last.from > first.to) { + decorations.push( + Decoration.mark({ + class: 'ol-cm-md-emph', + inclusive: true, + }).range(first.to, last.from) + ) + } + decorations.push( + Decoration.replace({}).range(last.from, last.to) + ) + } + } + return false + } + + // ── Strikethrough: ~~text~~ ───────────────────────────────────── + case 'Strikethrough': { + if (shouldDecorate(state, nFrom, nTo)) { + const marks = nodeRef.node.getChildren('StrikethroughMark') + const first = marks[0] + const last = marks[marks.length - 1] + if (first && last && first !== last) { + decorations.push( + Decoration.replace({}).range(first.from, first.to) + ) + if (last.from > first.to) { + decorations.push( + Decoration.mark({ + class: 'ol-cm-md-strikethrough', + inclusive: true, + }).range(first.to, last.from) + ) + } + decorations.push( + Decoration.replace({}).range(last.from, last.to) + ) + } + } + return false + } + + // ── Inline code: `code` ───────────────────────────────────────── + case 'InlineCode': { + if (shouldDecorate(state, nFrom, nTo)) { + const marks = nodeRef.node.getChildren('CodeMark') + const first = marks[0] + const last = marks[marks.length - 1] + if (first && last && first !== last) { + decorations.push( + Decoration.replace({}).range(first.from, first.to) + ) + if (last.from > first.to) { + decorations.push( + Decoration.mark({ + class: 'ol-cm-md-inline-code', + }).range(first.to, last.from) + ) + } + decorations.push( + Decoration.replace({}).range(last.from, last.to) + ) + } + } + return false + } + } + }, + }) + } + + return Decoration.set(decorations, true) + } + + let previousTree = syntaxTree(view.state) + + return { + decorations: createDecorations(view.state, previousTree), + update(update: ViewUpdate) { + const tree = syntaxTree(update.state) + if ( + tree.type === previousTree.type && + tree.length < update.view.viewport.to + ) { + this.decorations = this.decorations.map(update.changes) + } else if ( + tree !== previousTree || + update.viewportChanged || + update.selectionSet + ) { + previousTree = tree + this.decorations = createDecorations(update.state, tree) + } + }, + } + }, + { + decorations(value) { + return value.decorations + }, + } +) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts index b093a5cbee..d4b222f039 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts @@ -468,6 +468,36 @@ const mainVisualTheme = EditorView.theme({ }, }, + // ── Quarto / Markdown visual decorations ───────────────────────────────── + '.ol-cm-md-strong': { + fontWeight: 700, + }, + '.ol-cm-md-emph': { + fontStyle: 'italic', + }, + '.ol-cm-md-strikethrough': { + textDecoration: 'line-through', + }, + '.ol-cm-md-inline-code': { + fontFamily: 'var(--source-font-family)', + fontSize: '0.9em', + backgroundColor: 'rgba(125, 125, 125, 0.1)', + borderRadius: '3px', + lineHeight: 1, + }, + '.ol-cm-md-heading': { + fontWeight: 550, + lineHeight: '1.35', + color: 'inherit !important', + background: 'inherit !important', + }, + '.ol-cm-md-h1': { fontSize: '2em' }, + '.ol-cm-md-h2': { fontSize: '1.6em' }, + '.ol-cm-md-h3': { fontSize: '1.44em' }, + '.ol-cm-md-h4': { fontSize: '1.2em' }, + '.ol-cm-md-h5': { fontSize: '1em' }, + '.ol-cm-md-h6': { fontSize: '1em' }, + // ── Typst visual decorations ────────────────────────────────────────────── '.ol-cm-typst-strong': { fontWeight: 700, diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts index 8859553fcd..7ed252b6e2 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts @@ -21,6 +21,7 @@ import { pasteHtml } from './paste-html' import { commandTooltip } from '../command-tooltip' import { tableGeneratorTheme } from './table-generator' import { typstDecorations } from './typst-decorations' +import { quartoDecorations } from './quarto-decorations' import { debugConsole } from '@/utils/debugging' import { PreviewPath } from '../../../../../../types/preview-path' @@ -185,6 +186,7 @@ const extension = (options: Options) => [ visualEditorExtensions.map(extension => extension(options)), markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets typstDecorations, + quartoDecorations, visualKeymap, commandTooltip, scrollJumpAdjuster,