feat: toggle bold/italic and add underline, smallcaps, link for Typst editor
Build and Deploy Verso / deploy (push) Has been cancelled

Bold (Ctrl+B) and italic (Ctrl+I) now unwrap when the cursor is already
inside a Strong/Emphasis node. Added #underline[…] and #smallcaps[…]
wrap commands (toolbar only) and #link("")[…] with Ctrl+K shortcut that
places the cursor in the URL field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-11 09:31:28 +00:00
parent 926b6f7cbb
commit 0b616436cf
4 changed files with 128 additions and 5 deletions
@@ -0,0 +1,82 @@
import { EditorSelection } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
import { syntaxTree } from '@codemirror/language'
import { matchingAncestor } from '../utils/tree-operations/ancestors'
import { type TypstFormattingNode } from '../utils/tree-operations/formatting'
import { wrapRanges } from './ranges'
/**
* Toggle Typst symmetric markup (e.g. *bold* or _italic_).
*
* If the cursor/selection is already inside a matching syntax node the
* surrounding markers are removed; otherwise the selection is wrapped.
*/
export const toggleTypstMarkup =
(marker: string, nodeTypeName: TypstFormattingNode) =>
(view: EditorView): boolean => {
if (view.state.readOnly) return false
view.dispatch(
view.state.changeByRange(range => {
const tree = syntaxTree(view.state)
const node = tree.resolveInner(range.from, -1)
const formattingNode = matchingAncestor(
node,
n => n.type.name === nodeTypeName
)
if (formattingNode) {
const mLen = marker.length
const fFrom = formattingNode.from
const fTo = formattingNode.to
// Adjust the selection for the two deletions applied simultaneously.
// Positions after fFrom shift left by mLen (opening deletion).
// Positions at or after fTo shift left by another mLen (closing deletion).
const newFrom =
range.from > fFrom ? range.from - mLen : range.from
const newTo =
range.to -
(range.to > fFrom ? mLen : 0) -
(range.to >= fTo ? mLen : 0)
return {
changes: [
{ from: fFrom, to: fFrom + mLen, insert: '' },
{ from: fTo - mLen, to: fTo, insert: '' },
],
range: range.empty
? EditorSelection.cursor(newFrom)
: EditorSelection.range(newFrom, newTo),
}
}
// Wrap: insert markers around the selection
const content = view.state.sliceDoc(range.from, range.to)
return {
changes: {
from: range.from,
to: range.to,
insert: `${marker}${content}${marker}`,
},
range: range.empty
? EditorSelection.cursor(range.from + marker.length)
: EditorSelection.range(
range.from + marker.length,
range.to + marker.length
),
}
}),
{ scrollIntoView: true }
)
return true
}
// Wraps selection in #link("")[…] and places the cursor in the URL field.
// Prefix breakdown: # l i n k ( " " ) [ = 10 chars; URL slot is at offset 7.
export const wrapTypstLink = wrapRanges(
'#link("")[',
']',
false,
range => EditorSelection.cursor(range.from - 3)
)
@@ -216,6 +216,34 @@ export const ToolbarItems: FC<{
icon="format_italic"
shortcut={isMac ? '⌘I' : 'Ctrl+I'}
/>
<ToolbarButton
id="toolbar-format-underline"
label={t('toolbar_underline')}
command={commands.wrapTypstUnderline}
icon="format_underlined"
/>
<ToolbarButton
id="toolbar-format-smallcaps"
label={t('toolbar_smallcaps')}
command={commands.wrapTypstSmallcaps}
icon="Sc"
textIcon
/>
</div>
)}
{showGroup('group-misc') && (
<div
className="ol-cm-toolbar-button-group"
data-overflow="group-misc"
aria-label={t('toolbar_insert_misc')}
>
<ToolbarButton
id="toolbar-typst-href"
label={t('toolbar_insert_link')}
command={commands.wrapTypstLink}
icon="add_link"
shortcut={isMac ? '⌘K' : 'Ctrl+K'}
/>
</div>
)}
</>
@@ -7,6 +7,10 @@ import {
} from '@codemirror/search'
import { sendMB } from '@/infrastructure/event-tracking'
import { toggleRanges, wrapRanges } from '../../commands/ranges'
import {
toggleTypstMarkup,
wrapTypstLink,
} from '../../commands/typst-ranges'
import {
ancestorListType,
toggleListForRanges,
@@ -25,8 +29,11 @@ import { sendSearchEvent } from '@/features/event-tracking/search-events'
export const toggleBold = toggleRanges('\\textbf')
export const toggleItalic = toggleRanges('\\textit')
export const toggleTypstBold = wrapRanges('*', '*')
export const toggleTypstItalic = wrapRanges('_', '_')
export const toggleTypstBold = toggleTypstMarkup('*', 'Strong')
export const toggleTypstItalic = toggleTypstMarkup('_', 'Emphasis')
export const wrapTypstUnderline = wrapRanges('#underline[', ']')
export const wrapTypstSmallcaps = wrapRanges('#smallcaps[', ']')
export { wrapTypstLink }
// TODO: apply as a snippet?
// TODO: read URL from clipboard?
@@ -1,6 +1,6 @@
import { Prec } from '@codemirror/state'
import { keymap } from '@codemirror/view'
import { wrapRanges } from '../../commands/ranges'
import { toggleTypstMarkup, wrapTypstLink } from '../../commands/typst-ranges'
export const shortcuts = () => {
return Prec.high(
@@ -9,13 +9,19 @@ export const shortcuts = () => {
key: 'Ctrl-b',
mac: 'Mod-b',
preventDefault: true,
run: wrapRanges('*', '*'),
run: toggleTypstMarkup('*', 'Strong'),
},
{
key: 'Ctrl-i',
mac: 'Mod-i',
preventDefault: true,
run: wrapRanges('_', '_'),
run: toggleTypstMarkup('_', 'Emphasis'),
},
{
key: 'Ctrl-k',
mac: 'Mod-k',
preventDefault: true,
run: wrapTypstLink,
},
])
)