build 223: Quarto visual editor — bold, italic, headings, inline code, strikethrough
Build and Deploy Verso / deploy (push) Has been cancelled

Add quarto-decorations.ts ViewPlugin for .qmd/.md files in visual mode:
- ATXHeading1-6: hide # prefix, apply font-size per level (2em → 1em)
- StrongEmphasis: hide ** markers, apply font-weight:700
- Emphasis: hide * or _ markers, apply font-style:italic
- Strikethrough: hide ~~ markers, apply text-decoration:line-through
- InlineCode: hide backtick markers, apply monospace + subtle bg
Markers reappear when cursor enters the node (selectionSet trigger).
Uses getChildren('EmphasisMark'/'StrikethroughMark'/'CodeMark') to locate
delimiters generically, handling both single- and double-backtick inline code.

CSS rules (.ol-cm-md-{strong,emph,strikethrough,inline-code,heading,h1-h6})
added to visual-theme.ts. Plugin wired into visual.ts after typstDecorations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-13 13:27:34 +00:00
parent e4dc5f3f5d
commit 37ed70c7e9
3 changed files with 252 additions and 0 deletions
@@ -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<string, string> = {
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<Decoration>[] = []
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
},
}
)
@@ -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,
@@ -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,