diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/typst-decorations.ts b/services/web/frontend/js/features/source-editor/extensions/visual/typst-decorations.ts new file mode 100644 index 0000000000..9fe9476664 --- /dev/null +++ b/services/web/frontend/js/features/source-editor/extensions/visual/typst-decorations.ts @@ -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[] = [] + + 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 + }, + } +) diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts index 2832cc042a..b093a5cbee 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual-theme.ts @@ -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() diff --git a/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts b/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts index 884733981e..8859553fcd 100644 --- a/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts +++ b/services/web/frontend/js/features/source-editor/extensions/visual/visual.ts @@ -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,