diff --git a/services/web/frontend/js/features/source-editor/commands/typst-ranges.ts b/services/web/frontend/js/features/source-editor/commands/typst-ranges.ts
new file mode 100644
index 0000000000..fd50a46aeb
--- /dev/null
+++ b/services/web/frontend/js/features/source-editor/commands/typst-ranges.ts
@@ -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)
+)
diff --git a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx
index 925685393a..955fb5efb8 100644
--- a/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx
+++ b/services/web/frontend/js/features/source-editor/components/toolbar/toolbar-items.tsx
@@ -216,6 +216,34 @@ export const ToolbarItems: FC<{
icon="format_italic"
shortcut={isMac ? '⌘I' : 'Ctrl+I'}
/>
+