build 223: Quarto visual editor — bold, italic, headings, inline code, strikethrough
Build and Deploy Verso / deploy (push) Has been cancelled
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:
+220
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user