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'} /> + + + + )} + {showGroup('group-misc') && ( +
+
)} diff --git a/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts b/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts index 83650d9c4d..84e8846405 100644 --- a/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts +++ b/services/web/frontend/js/features/source-editor/extensions/toolbar/commands.ts @@ -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? diff --git a/services/web/frontend/js/features/source-editor/languages/typst/shortcuts.ts b/services/web/frontend/js/features/source-editor/languages/typst/shortcuts.ts index 3b4ca1c49b..2e9dcf2239 100644 --- a/services/web/frontend/js/features/source-editor/languages/typst/shortcuts.ts +++ b/services/web/frontend/js/features/source-editor/languages/typst/shortcuts.ts @@ -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, }, ]) )