build 222: Typst visual editor — bold, italic, headings, inline code
Build and Deploy Verso / deploy (push) Has been cancelled
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:
+170
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
// ── 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()
|
||||
|
||||
@@ -20,6 +20,7 @@ import { listItemMarker } from './list-item-marker'
|
||||
import { pasteHtml } from './paste-html'
|
||||
import { commandTooltip } from '../command-tooltip'
|
||||
import { tableGeneratorTheme } from './table-generator'
|
||||
import { typstDecorations } from './typst-decorations'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import { PreviewPath } from '../../../../../../types/preview-path'
|
||||
|
||||
@@ -183,6 +184,7 @@ const extension = (options: Options) => [
|
||||
atomicDecorations(options),
|
||||
visualEditorExtensions.map(extension => extension(options)),
|
||||
markDecorations, // NOTE: must be after atomicDecorations, so that mark decorations wrap inline widgets
|
||||
typstDecorations,
|
||||
visualKeymap,
|
||||
commandTooltip,
|
||||
scrollJumpAdjuster,
|
||||
|
||||
Reference in New Issue
Block a user