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

Add typst-decorations.ts ViewPlugin that runs alongside the existing
LaTeX visual decorations. For Typst files in visual mode it:
- Hides *…* markers and applies font-weight:700 to the StrongBody
- Hides _…_ markers and applies font-style:italic to the EmphBody
- Hides = prefix marks and applies heading CSS (h1–h6 font sizes)
- Hides backtick delimiters and applies monospace to RawInlineContent
Markers reappear when the cursor enters the node (selectionSet trigger).

Register CSS rules for .ol-cm-typst-{strong,emph,heading,h1-h6,raw-inline}
in visual-theme.ts. Wire the plugin into visual.ts after markDecorations.

The visual editor toggle was already available for .typ files since 'typ'
is in validRootDocExtensions — no gating change needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-13 13:10:09 +00:00
parent 0058cc17b5
commit e4dc5f3f5d
3 changed files with 199 additions and 0 deletions
@@ -0,0 +1,170 @@
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 })
}
/**
* Visual-mode decorations for Typst markup.
*
* Hides syntactic markers (*/_/= headings/backticks) when the cursor is
* outside them, and applies CSS classes for bold, italic, headings, and
* inline code. Mirrors the pattern used by markDecorations for LaTeX.
*/
export const typstDecorations = 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
switch (nodeRef.type.name) {
case 'Strong': {
// *bold text*
if (shouldDecorate(state, nFrom, nTo)) {
decorations.push(Decoration.replace({}).range(nFrom, nFrom + 1))
const body = nodeRef.node.getChild('StrongBody')
if (body) {
decorations.push(
Decoration.mark({
class: 'ol-cm-typst-strong',
inclusive: true,
}).range(body.from, body.to)
)
}
if (nTo > nFrom + 1) {
decorations.push(Decoration.replace({}).range(nTo - 1, nTo))
}
}
return false
}
case 'Emphasis': {
// _italic text_
if (shouldDecorate(state, nFrom, nTo)) {
decorations.push(Decoration.replace({}).range(nFrom, nFrom + 1))
const body = nodeRef.node.getChild('EmphBody')
if (body) {
decorations.push(
Decoration.mark({
class: 'ol-cm-typst-emph',
inclusive: true,
}).range(body.from, body.to)
)
}
if (nTo > nFrom + 1) {
decorations.push(Decoration.replace({}).range(nTo - 1, nTo))
}
}
return false
}
case 'Heading': {
// = Title / == Title / === Title …
const markNode = nodeRef.node.getChild('HeadingMark')
const titleNode = nodeRef.node.getChild('HeadingTitle')
if (markNode && titleNode) {
const markText = state.doc.sliceString(
markNode.from,
markNode.to
)
const level = (markText.match(/^=+/) ?? ['='])[0].length
const cls = `ol-cm-typst-heading ol-cm-typst-h${Math.min(level, 6)}`
if (shouldDecorate(state, markNode.from, markNode.to)) {
decorations.push(
Decoration.replace({}).range(markNode.from, markNode.to)
)
}
decorations.push(
Decoration.mark({ class: cls }).range(
titleNode.from,
titleNode.to
)
)
}
return false
}
case 'RawInline': {
// `inline code`
if (shouldDecorate(state, nFrom, nTo)) {
const content = nodeRef.node.getChild('RawInlineContent')
if (content) {
decorations.push(
Decoration.replace({}).range(nFrom, content.from)
)
decorations.push(
Decoration.mark({
class: 'ol-cm-typst-raw-inline',
}).range(content.from, content.to)
)
decorations.push(
Decoration.replace({}).range(content.to, nTo)
)
}
}
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
) {
// still parsing — just map existing decorations over any edits
this.decorations = this.decorations.map(update.changes)
} else if (
tree !== previousTree ||
update.viewportChanged ||
update.selectionSet // ← re-evaluate on cursor movement to show/hide markers
) {
previousTree = tree
this.decorations = createDecorations(update.state, tree)
}
},
}
},
{
decorations(value) {
return value.decorations
},
}
)
@@ -467,6 +467,33 @@ const mainVisualTheme = EditorView.theme({
padding: '0 0.5em', padding: '0 0.5em',
}, },
}, },
// ── Typst visual decorations ──────────────────────────────────────────────
'.ol-cm-typst-strong': {
fontWeight: 700,
},
'.ol-cm-typst-emph': {
fontStyle: 'italic',
},
'.ol-cm-typst-heading': {
fontWeight: 550,
lineHeight: '1.35',
color: 'inherit !important',
background: 'inherit !important',
},
'.ol-cm-typst-h1': { fontSize: '2em' },
'.ol-cm-typst-h2': { fontSize: '1.6em' },
'.ol-cm-typst-h3': { fontSize: '1.44em' },
'.ol-cm-typst-h4': { fontSize: '1.2em' },
'.ol-cm-typst-h5': { fontSize: '1em' },
'.ol-cm-typst-h6': { fontSize: '1em' },
'.ol-cm-typst-raw-inline': {
fontFamily: 'var(--source-font-family)',
fontSize: '0.9em',
backgroundColor: 'rgba(125, 125, 125, 0.1)',
borderRadius: '3px',
lineHeight: 1,
},
}) })
const contentWidthThemeConf = new Compartment() const contentWidthThemeConf = new Compartment()
@@ -20,6 +20,7 @@ import { listItemMarker } from './list-item-marker'
import { pasteHtml } from './paste-html' import { pasteHtml } from './paste-html'
import { commandTooltip } from '../command-tooltip' import { commandTooltip } from '../command-tooltip'
import { tableGeneratorTheme } from './table-generator' import { tableGeneratorTheme } from './table-generator'
import { typstDecorations } from './typst-decorations'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import { PreviewPath } from '../../../../../../types/preview-path' import { PreviewPath } from '../../../../../../types/preview-path'
@@ -183,6 +184,7 @@ const extension = (options: Options) => [
atomicDecorations(options), atomicDecorations(options),
visualEditorExtensions.map(extension => extension(options)), visualEditorExtensions.map(extension => extension(options)),
markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets
typstDecorations,
visualKeymap, visualKeymap,
commandTooltip, commandTooltip,
scrollJumpAdjuster, scrollJumpAdjuster,