All files / helpers quote-commands.ts

94.87% Statements 37/39
100% Branches 14/14
60% Functions 3/5
94.59% Lines 35/37

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 892x       2x                 10x                   2x 20x   20x 20x   2x     18x 18x 18x   18x 18x       18x   4x 4x     14x   14x 28x 28x 106x 14x 14x 14x           14x 14x     18x 14x   18x 18x     18x 4x     14x       14x       2x        
import { EditorSelection, SelectionRange } from '@codemirror/state';
import { EditorView, KeyBinding } from '@codemirror/view';
 
/** All supported quote pairs for stripping */
const outerPairs: [string, string][] = [
    ["'", "'"],
    ['"', '"'],
    ['«', '»'],
    ['„', '“'],
    ['‟', '”'],
];
 
/** Flattened set of all individual quote chars */
const allQuoteChars = new Set(outerPairs.flatMap(([open, close]) => [open, close]));
 
/**
 * Handle a quote key press **only when there is a non-empty selection**:
 * - single-char selection of any supported quote → replace with quoteChar
 * - selection wrapped in any supported pair(s) → strip all layers, then wrap with quoteChar
 * - otherwise → wrap selection with quoteChar
 *
 * If there is no selection (cursor only), do nothing and allow default behavior.
 */
export function handleQuote(view: EditorView, quoteChar: "'" | '"'): boolean {
    const { state } = view;
    // collect only ranges that actually have text selected
    const ranges = state.selection.ranges.filter(r => !r.empty);
    if (ranges.length === 0) {
        // no non-empty selection: do not handle
        return false;
    }
 
    const changes: { from: number; to: number; insert: string }[] = [];
    const newRanges: SelectionRange[] = [];
    let didChange = false;
 
    for (const { from, to } of ranges) {
        const text = state.sliceDoc(from, to);
        let replacement: string;
        let cursorPos: number;
 
        if (text.length === 1 && allQuoteChars.has(text)) {
            // replace a single existing quote
            replacement = quoteChar;
            cursorPos = from + 1;
        } else {
            // strip all matching outer pairs
            let inner = text;
            let stripped: boolean;
            do {
                stripped = false;
                for (const [open, close] of outerPairs) {
                    if (inner.startsWith(open) && inner.endsWith(close)) {
                        inner = inner.slice(open.length, inner.length - close.length);
                        stripped = true;
                        break;
                    }
                }
            } while (stripped);
 
            // wrap the stripped (or original) text
            replacement = quoteChar + inner + quoteChar;
            cursorPos = from + replacement.length;
        }
 
        if (replacement !== text) {
            didChange = true;
        }
        changes.push({ from, to, insert: replacement });
        newRanges.push(EditorSelection.cursor(cursorPos));
    }
 
    if (!didChange) {
        return false;
    }
 
    view.dispatch({
        changes,
        selection: EditorSelection.create(newRanges),
    });
    return true;
}
 
/** Bind only the two quote keys; default behavior applies when no selection */
export const quoteKeymap: readonly KeyBinding[] = [
    { key: "'", run: view => handleQuote(view, "'") },
    { key: '"', run: view => handleQuote(view, '"') },
];