From 74609f74ed469e86a6e38d1dd4b0f965935b8dbe Mon Sep 17 00:00:00 2001 From: Domagoj Kriskovic Date: Wed, 1 Jul 2026 15:22:17 +0200 Subject: [PATCH] Add Cmd+Shift+X strikeout shortcut to the visual markdown editor GitOrigin-RevId: d76fce965c0bffd393f5ca5176d3b61453ac2f93 --- .../languages/markdown/shortcuts.ts | 6 ++ .../languages/markdown/toggle-marks.ts | 80 ++++++++++++------- 2 files changed, 56 insertions(+), 30 deletions(-) diff --git a/services/web/frontend/js/features/source-editor/languages/markdown/shortcuts.ts b/services/web/frontend/js/features/source-editor/languages/markdown/shortcuts.ts index 0c8a677b59..e0b10f7912 100644 --- a/services/web/frontend/js/features/source-editor/languages/markdown/shortcuts.ts +++ b/services/web/frontend/js/features/source-editor/languages/markdown/shortcuts.ts @@ -17,6 +17,12 @@ export const shortcuts = () => { preventDefault: true, run: toggleWrapRanges('_', '_', 'Emphasis'), }, + { + key: 'Ctrl-Shift-x', + mac: 'Mod-Shift-x', + preventDefault: true, + run: toggleWrapRanges('~~', '~~', 'Strikethrough', 'StrikethroughMark'), + }, ]) ) } diff --git a/services/web/frontend/js/features/source-editor/languages/markdown/toggle-marks.ts b/services/web/frontend/js/features/source-editor/languages/markdown/toggle-marks.ts index 53391e2bc0..22af12d0cd 100644 --- a/services/web/frontend/js/features/source-editor/languages/markdown/toggle-marks.ts +++ b/services/web/frontend/js/features/source-editor/languages/markdown/toggle-marks.ts @@ -5,48 +5,64 @@ import { SyntaxNode } from '@lezer/common' import { ancestorOfNodeWithType } from '../../utils/tree-query' import { wrapRanges } from '../../commands/ranges' -// Bold parses as `StrongEmphasis`, italic as `Emphasis`; in both cases the -// delimiters are `EmphasisMark` children of that node. -type EmphasisNodeType = 'StrongEmphasis' | 'Emphasis' +// Bold parses as `StrongEmphasis`, italic as `Emphasis`, strikethrough as +// `Strikethrough`; their delimiters are `EmphasisMark`/`StrikethroughMark`. +type InlineNodeType = 'StrongEmphasis' | 'Emphasis' | 'Strikethrough' +type DelimiterMarkType = 'EmphasisMark' | 'StrikethroughMark' -// The enclosing emphasis node of the requested type that fully contains the -// range, or null. The exact type is matched so bold and italic aren't confused. -const enclosingEmphasis = ( +// Rebuild a range at new offsets while keeping the user's selection direction +const moveRange = (range: SelectionRange, newFrom: number, newTo: number) => { + const forwards = range.from === range.anchor + return forwards + ? EditorSelection.range(newFrom, newTo) + : EditorSelection.range(newTo, newFrom) +} + +// The enclosing node of the requested type that fully contains the range, or +// null. The exact type is matched so bold, italic and strikethrough aren't +// confused with one another. +const enclosingNode = ( view: EditorView, range: SelectionRange, - nodeType: EmphasisNodeType + nodeType: InlineNodeType ): SyntaxNode | null => { const tree = ensureSyntaxTree(view.state, range.to, 1000) if (!tree) { return null } - const node = tree.resolveInner(range.from, range.empty ? 0 : 1) - const emphasis = ancestorOfNodeWithType(node, nodeType) - if (!emphasis) { + // For a collapsed cursor, try both sides so a cursor touching either + // delimiter boundary still resolves into the node rather than past it. + const resolveEnclosing = (side: -1 | 1) => + ancestorOfNodeWithType(tree.resolveInner(range.from, side), nodeType) + const enclosing = range.empty + ? (resolveEnclosing(-1) ?? resolveEnclosing(1)) + : resolveEnclosing(1) + if (!enclosing) { return null } - if (range.from < emphasis.from || range.to > emphasis.to) { + if (range.from < enclosing.from || range.to > enclosing.to) { return null } - return emphasis + return enclosing } -const emphasisMarks = ( - node: SyntaxNode +const delimiterMarks = ( + node: SyntaxNode, + markType: DelimiterMarkType ): { open: SyntaxNode; close: SyntaxNode } | null => { let open: SyntaxNode | null = null for (let child = node.firstChild; child; child = child.nextSibling) { - if (child.type.is('EmphasisMark')) { + if (child.type.is(markType)) { open = child break } } let close: SyntaxNode | null = null for (let child = node.lastChild; child; child = child.prevSibling) { - if (child.type.is('EmphasisMark')) { + if (child.type.is(markType)) { close = child break } @@ -62,7 +78,8 @@ const emphasisMarks = ( const innerMarksToStrip = ( view: EditorView, range: SelectionRange, - nodeType: EmphasisNodeType + nodeType: InlineNodeType, + markType: DelimiterMarkType ): { from: number; to: number }[] => { const result: { from: number; to: number }[] = [] if (range.empty) { @@ -81,7 +98,7 @@ const innerMarksToStrip = ( node.from >= range.from && node.to <= range.to ) { - const marks = emphasisMarks(node.node) + const marks = delimiterMarks(node.node, markType) if (marks) { result.push({ from: marks.open.from, to: marks.open.to }) result.push({ from: marks.close.from, to: marks.close.to }) @@ -93,14 +110,16 @@ const innerMarksToStrip = ( } /** - * Toggle a markdown inline emphasis delimiter (bold or italic). Unwraps when the - * cursor/selection is already inside a span of that type, flattens any same-type - * spans contained in the selection, and otherwise wraps via `wrapRanges`. + * Toggle a markdown inline delimiter (bold, italic or strikethrough). Unwraps + * when the cursor/selection is already inside a span of that type, flattens any + * same-type spans contained in the selection, and otherwise wraps via + * `wrapRanges`. */ export const toggleWrapRanges = ( prefix: string, suffix: string, - nodeType: EmphasisNodeType + nodeType: InlineNodeType, + markType: DelimiterMarkType = 'EmphasisMark' ) => { const wrap = wrapRanges(prefix, suffix) @@ -112,8 +131,8 @@ export const toggleWrapRanges = ( const ranges = view.state.selection.ranges const needsCustom = ranges.some( range => - enclosingEmphasis(view, range, nodeType) !== null || - innerMarksToStrip(view, range, nodeType).length > 0 + enclosingNode(view, range, nodeType) !== null || + innerMarksToStrip(view, range, nodeType, markType).length > 0 ) if (!needsCustom) { @@ -122,18 +141,19 @@ export const toggleWrapRanges = ( view.dispatch( view.state.changeByRange(range => { - const emphasis = enclosingEmphasis(view, range, nodeType) - if (!emphasis) { + const enclosing = enclosingNode(view, range, nodeType) + if (!enclosing) { // Strip any same-type spans inside the selection before wrapping so // the result is a single flat span rather than nested markup. - const innerMarks = innerMarksToStrip(view, range, nodeType) + const innerMarks = innerMarksToStrip(view, range, nodeType, markType) if (innerMarks.length > 0) { const deletedLength = innerMarks.reduce( (total, mark) => total + (mark.to - mark.from), 0 ) return { - range: EditorSelection.range( + range: moveRange( + range, range.from + prefix.length, range.to + prefix.length - deletedLength ), @@ -160,7 +180,7 @@ export const toggleWrapRanges = ( } } - const marks = emphasisMarks(emphasis) + const marks = delimiterMarks(enclosing, markType) if (!marks) { return { range } } @@ -176,7 +196,7 @@ export const toggleWrapRanges = ( Math.min(Math.max(pos, innerFrom), innerTo) - openLength return { - range: EditorSelection.range(mapPos(range.from), mapPos(range.to)), + range: moveRange(range, mapPos(range.from), mapPos(range.to)), changes: [ { from: open.from, to: open.to }, { from: close.from, to: close.to },