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',
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user