Add Cmd+Shift+X strikeout shortcut to the visual markdown editor

GitOrigin-RevId: d76fce965c0bffd393f5ca5176d3b61453ac2f93
This commit is contained in:
Domagoj Kriskovic
2026-09-17 11:16:55 +00:00
committed by Copybot
parent a8d888ea0c
commit 74609f74ed
2 changed files with 56 additions and 30 deletions
@@ -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'),
},
])
)
}
@@ -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 },