mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
fix(editor): Count wide characters as two columns in column editing (#172)
Column-based editing computed columns with countColumn / findColumn, which count every grapheme as one column, and up/down and Clone Caret moved by pixel x, so block selections and cloned carets drifted on lines with CJK, fullwidth punctuation, Hangul, kana or emoji. A shared visible-column helper (UI side only, UTF-16 offsets in and out) now counts East Asian wide/fullwidth characters and emoji (ZWJ sequences, modifiers, flags, VS16) as 2, combining marks and format characters as 0, and expands tabs to the next tab stop. It drives Alt+drag block selection, drag and Shift+arrows in Column Selection Mode, middle-drag block selection (new; not on Linux, where middle-click pastes the primary selection), the up/down goal column (kept across short lines; soft-wrapped and folded lines still move visually), Clone Caret Above/Below, the caret move after Comment Line, Tab padding to the next indent stop, and macro playback of arrow keys. Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
This commit is contained in:
@@ -1,14 +1,7 @@
|
||||
/** Editor actions (ids from keymaps/intellij-macos.ts). */
|
||||
import { EditorSelection, type EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import {
|
||||
addCursorAbove,
|
||||
addCursorBelow,
|
||||
indentSelection,
|
||||
moveLineDown,
|
||||
moveLineUp,
|
||||
toggleBlockComment,
|
||||
} from "@codemirror/commands";
|
||||
import { indentSelection, moveLineDown, moveLineUp, toggleBlockComment } from "@codemirror/commands";
|
||||
import { foldEffect, syntaxTree, toggleFold } from "@codemirror/language";
|
||||
import { acceptCompletion, completionStatus } from "@codemirror/autocomplete";
|
||||
import { executeAction, registerActions, type Action } from "@/platform/actions";
|
||||
@@ -66,6 +59,7 @@ import {
|
||||
} from "./cm/caretCommands";
|
||||
import { isOverwrite, toggleOverwrite } from "./cm/overwrite";
|
||||
import { isColumnMode, toggleColumnMode } from "./cm/columnMode";
|
||||
import { cloneCaretAbove, cloneCaretBelow } from "./cm/verticalMotion";
|
||||
import { pasteAsPlainText, pasteFromHistory, unwrapAtCaret } from "./extras";
|
||||
import { cyclicExpandWord } from "./wordCompletion";
|
||||
import { qualifiedReference } from "./copyReference";
|
||||
@@ -609,8 +603,8 @@ export function registerEditorActions(): () => void {
|
||||
text("code.autoIndentLines", "Auto-Indent Lines", (v) => !v.state.readOnly && indentSelection(v), {
|
||||
keywords: ["reindent", "fix indentation"],
|
||||
}),
|
||||
text("editor.cloneCaretAbove", "Clone Caret Above", (v) => addCursorAbove(v), { category: "Editor Actions" }),
|
||||
text("editor.cloneCaretBelow", "Clone Caret Below", (v) => addCursorBelow(v), { category: "Editor Actions" }),
|
||||
text("editor.cloneCaretAbove", "Clone Caret Above", (v) => cloneCaretAbove(v), { category: "Editor Actions" }),
|
||||
text("editor.cloneCaretBelow", "Clone Caret Below", (v) => cloneCaretBelow(v), { category: "Editor Actions" }),
|
||||
text("editor.matchBrace", "Move Caret to Matching Brace", (v) => cursorMatchBrace(v), {
|
||||
category: "Editor Actions",
|
||||
// ⌃M is Show Commit Message History in the commit window.
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
* Those keys belong to IDE actions instead (keymaps in `platform/keymaps/`):
|
||||
* ⌘/Ctrl+Enter is Split Line, Ctrl+M / ⌃L Scroll to Center, Ctrl+[ / ] code
|
||||
* block navigation, ⌥⌘↑↓ / Ctrl+Alt+↑↓ Previous / Next Occurrence.
|
||||
* Line up / down (with ⇧ too) keep the caret's visible column (`verticalMotion`).
|
||||
*/
|
||||
import type { KeyBinding } from "@codemirror/view";
|
||||
import type { Command, KeyBinding } from "@codemirror/view";
|
||||
import {
|
||||
addCursorAbove,
|
||||
addCursorBelow,
|
||||
cursorLineDown as cmCursorLineDown,
|
||||
cursorLineUp as cmCursorLineUp,
|
||||
defaultKeymap,
|
||||
historyKeymap,
|
||||
indentLess,
|
||||
@@ -15,8 +18,11 @@ import {
|
||||
insertBlankLine,
|
||||
redo,
|
||||
selectLine,
|
||||
selectLineDown as cmSelectLineDown,
|
||||
selectLineUp as cmSelectLineUp,
|
||||
toggleTabFocusMode,
|
||||
} from "@codemirror/commands";
|
||||
import { cursorLineDown, cursorLineUp, selectLineDown, selectLineUp } from "./verticalMotion";
|
||||
|
||||
const DROPPED = new Set<unknown>([
|
||||
insertBlankLine, // Mod-Enter
|
||||
@@ -28,8 +34,20 @@ const DROPPED = new Set<unknown>([
|
||||
indentMore, // Mod-]
|
||||
]);
|
||||
|
||||
const VERTICAL = new Map<Command | undefined, Command>([
|
||||
[cmCursorLineUp, cursorLineUp],
|
||||
[cmCursorLineDown, cursorLineDown],
|
||||
[cmSelectLineUp, selectLineUp],
|
||||
[cmSelectLineDown, selectLineDown],
|
||||
]);
|
||||
|
||||
const vertical = (b: KeyBinding): KeyBinding =>
|
||||
VERTICAL.has(b.run) || VERTICAL.has(b.shift)
|
||||
? { ...b, run: VERTICAL.get(b.run) ?? b.run, shift: VERTICAL.get(b.shift) ?? b.shift }
|
||||
: b;
|
||||
|
||||
export const editorBaseKeymap: readonly KeyBinding[] = [
|
||||
...defaultKeymap.filter((b) => !DROPPED.has(b.run)),
|
||||
...defaultKeymap.filter((b) => !DROPPED.has(b.run)).map(vertical),
|
||||
...historyKeymap,
|
||||
// IDEA's Windows Redo is Ctrl+Shift+Z (Ctrl+Y is Delete Line); CodeMirror only binds it on macOS and Linux.
|
||||
{ win: "Ctrl-Shift-z", run: redo, preventDefault: true },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { columnModeExtension, extendBlock, isColumnMode, toggleColumnMode } from "./columnMode";
|
||||
import { columnModeExtension, extendBlock, isColumnMode, rectangleRanges, toggleColumnMode } from "./columnMode";
|
||||
|
||||
let view: EditorView | null = null;
|
||||
afterEach(() => {
|
||||
@@ -60,3 +60,49 @@ describe("Column Selection Mode", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("block selection in visible columns", () => {
|
||||
// `张三` takes four columns, so `//` starts at column 15 on both lines.
|
||||
const doc = 'name = "张三" // 中文注释\nid = 42 // answer';
|
||||
const [first, second] = doc.split("\n") as [string, string];
|
||||
const from2 = first.length + 1;
|
||||
|
||||
it("⇧↓ / ⇧→ keep the block on the same columns across CJK text", () => {
|
||||
const v = make(doc, first.indexOf("//"));
|
||||
toggleColumnMode(v);
|
||||
extendBlock(v, 1, 0);
|
||||
extendBlock(v, 0, 1);
|
||||
extendBlock(v, 0, 1);
|
||||
expect(ranges(v)).toEqual([
|
||||
[first.indexOf("//"), first.indexOf("//") + 2],
|
||||
[from2 + second.indexOf("//"), from2 + second.indexOf("//") + 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it("⇧→ steps over a wide character as one unit", () => {
|
||||
const v = make("张三四\nabcdef", 0);
|
||||
toggleColumnMode(v);
|
||||
extendBlock(v, 0, 1);
|
||||
extendBlock(v, 1, 0);
|
||||
// One ideograph on line 1 is two letters on line 2.
|
||||
expect(ranges(v)).toEqual([
|
||||
[0, 1],
|
||||
[4, 6],
|
||||
]);
|
||||
});
|
||||
|
||||
it("mouse rectangles map visible columns back to UTF-16 offsets per line", () => {
|
||||
const v = make(`${doc}\n😀😀ab`, 0);
|
||||
const line3 = v.state.doc.line(3);
|
||||
const got = rectangleRanges(v.state, { line: 1, col: 15 }, { line: 3, col: 17 }).map((r) => [r.from, r.to]);
|
||||
expect(got).toEqual([
|
||||
[first.indexOf("//"), first.indexOf("//") + 2],
|
||||
[from2 + second.indexOf("//"), from2 + second.indexOf("//") + 2],
|
||||
// Line 3 is six columns wide, left of the block: a caret at its end.
|
||||
[line3.to, line3.to],
|
||||
]);
|
||||
// Columns 2..6 of "😀😀ab": the second emoji (code units 2..4) and "ab".
|
||||
const inner = rectangleRanges(v.state, { line: 3, col: 2 }, { line: 3, col: 6 });
|
||||
expect(inner.map((r) => [r.from, r.to])).toEqual([[line3.from + 2, line3.from + 6]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,14 +7,14 @@ import {
|
||||
Prec,
|
||||
StateEffect,
|
||||
StateField,
|
||||
countColumn,
|
||||
findColumn,
|
||||
type EditorState,
|
||||
type Extension,
|
||||
type SelectionRange,
|
||||
} from "@codemirror/state";
|
||||
import { EditorView, keymap, rectangularSelection } from "@codemirror/view";
|
||||
import { EditorView, keymap, type MouseSelectionStyle } from "@codemirror/view";
|
||||
import { offsetAtVisibleColumn, stepVisibleColumn, visibleColumn, visibleWidth } from "./visibleColumn";
|
||||
|
||||
/** A block between two visual columns (tabs expanded) of two lines. */
|
||||
/** A block between two visible columns (tabs expanded, wide characters 2) of two lines. */
|
||||
interface Block {
|
||||
anchorLine: number;
|
||||
anchorCol: number;
|
||||
@@ -53,11 +53,11 @@ export function toggleColumnMode(view: EditorView): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** One range per line from the anchor column to the head column (visual columns, clamped to each line). */
|
||||
/** One range per line from the anchor column to the head column (visible columns, clamped to each line). */
|
||||
export function blockSelection(state: EditorState, b: Block): EditorSelection {
|
||||
const ranges = [];
|
||||
const step = b.headLine >= b.anchorLine ? 1 : -1;
|
||||
const at = (text: string, col: number) => findColumn(text, col, state.tabSize);
|
||||
const at = (text: string, col: number) => offsetAtVisibleColumn(text, col, state.tabSize);
|
||||
for (let n = b.anchorLine; ; n += step) {
|
||||
const line = state.doc.line(n);
|
||||
ranges.push(EditorSelection.range(line.from + at(line.text, b.anchorCol), line.from + at(line.text, b.headCol)));
|
||||
@@ -66,17 +66,6 @@ export function blockSelection(state: EditorState, b: Block): EditorSelection {
|
||||
return EditorSelection.create(ranges, ranges.length - 1);
|
||||
}
|
||||
|
||||
/** The visual column one character left / right of `col` (a tab is one step; past the line end, one column). */
|
||||
export function stepColumn(text: string, col: number, d: number, tabSize: number): number {
|
||||
if (!d) return col;
|
||||
const width = countColumn(text, tabSize);
|
||||
if (d > 0 ? col >= width : col > width) return Math.max(0, col + d);
|
||||
const pos = findColumn(text, col, tabSize);
|
||||
const at = countColumn(text, tabSize, pos);
|
||||
if (d > 0) return at > col ? at : countColumn(text, tabSize, pos + 1);
|
||||
return pos === 0 ? 0 : countColumn(text, tabSize, pos - 1);
|
||||
}
|
||||
|
||||
/** ⇧+arrow in column mode: moves the block's head by a line / column. */
|
||||
export function extendBlock(view: EditorView, dLine: number, dCol: number): boolean {
|
||||
const state = view.state;
|
||||
@@ -87,7 +76,7 @@ export function extendBlock(view: EditorView, dLine: number, dCol: number): bool
|
||||
const main = state.selection.main;
|
||||
const a = state.doc.lineAt(main.anchor);
|
||||
const h = state.doc.lineAt(main.head);
|
||||
const col = (text: string, offset: number) => countColumn(text, state.tabSize, offset);
|
||||
const col = (text: string, offset: number) => visibleColumn(text, offset, state.tabSize);
|
||||
b = {
|
||||
anchorLine: a.number,
|
||||
anchorCol: col(a.text, main.anchor - a.from),
|
||||
@@ -96,7 +85,11 @@ export function extendBlock(view: EditorView, dLine: number, dCol: number): bool
|
||||
};
|
||||
}
|
||||
const headLine = Math.max(1, Math.min(state.doc.lines, b.headLine + dLine));
|
||||
const next = { ...b, headLine, headCol: stepColumn(state.doc.line(b.headLine).text, b.headCol, dCol, state.tabSize) };
|
||||
const next = {
|
||||
...b,
|
||||
headLine,
|
||||
headCol: stepVisibleColumn(state.doc.line(b.headLine).text, b.headCol, dCol, state.tabSize),
|
||||
};
|
||||
view.dispatch({
|
||||
selection: blockSelection(state, next),
|
||||
effects: setBlock.of(next),
|
||||
@@ -106,18 +99,73 @@ export function extendBlock(view: EditorView, dLine: number, dCol: number): bool
|
||||
return true;
|
||||
}
|
||||
|
||||
interface BlockPoint {
|
||||
line: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
/** The line and visible column under the pointer; past the line end, columns of the default character width. */
|
||||
function blockPointAt(view: EditorView, e: MouseEvent): BlockPoint {
|
||||
const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false);
|
||||
const line = view.state.doc.lineAt(pos);
|
||||
let col = visibleColumn(line.text, pos - line.from, view.state.tabSize);
|
||||
if (pos === line.to) {
|
||||
const end = view.coordsAtPos(line.to, -1);
|
||||
if (end) col += Math.max(0, Math.round((e.clientX - end.left) / view.defaultCharacterWidth));
|
||||
}
|
||||
return { line: line.number, col };
|
||||
}
|
||||
|
||||
/** One range per line of the rectangle between two pointer points; lines ending left of it get a caret at their end. */
|
||||
export function rectangleRanges(state: EditorState, a: BlockPoint, b: BlockPoint): SelectionRange[] {
|
||||
const from = Math.min(a.col, b.col);
|
||||
const to = Math.max(a.col, b.col);
|
||||
const ranges: SelectionRange[] = [];
|
||||
for (let n = Math.min(a.line, b.line); n <= Math.max(a.line, b.line); n++) {
|
||||
const line = state.doc.line(n);
|
||||
if (visibleWidth(line.text, state.tabSize) < from) {
|
||||
ranges.push(EditorSelection.cursor(line.to));
|
||||
continue;
|
||||
}
|
||||
const start = offsetAtVisibleColumn(line.text, from, state.tabSize);
|
||||
const end = offsetAtVisibleColumn(line.text, to, state.tabSize);
|
||||
ranges.push(EditorSelection.range(line.from + start, line.from + end));
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/** Mouse rectangular (block) selection in visible columns, for the drags `filter` accepts. */
|
||||
export function blockMouseSelection(filter: (e: MouseEvent) => boolean): Extension {
|
||||
return EditorView.mouseSelectionStyle.of((view, event): MouseSelectionStyle | null => {
|
||||
if (!filter(event)) return null;
|
||||
let start = blockPointAt(view, event);
|
||||
let startSel = view.state.selection;
|
||||
return {
|
||||
update(update) {
|
||||
if (!update.docChanged) return;
|
||||
const from = update.changes.mapPos(update.startState.doc.line(start.line).from);
|
||||
start = { line: update.state.doc.lineAt(from).number, col: start.col };
|
||||
startSel = startSel.map(update.changes);
|
||||
},
|
||||
get(e, _extend, multiple) {
|
||||
const ranges = rectangleRanges(view.state, start, blockPointAt(view, e));
|
||||
if (!ranges.length) return startSel;
|
||||
return EditorSelection.create(multiple ? ranges.concat(startSel.ranges) : ranges);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function viewOf(e: MouseEvent): EditorView | null {
|
||||
return e.target instanceof HTMLElement ? EditorView.findFromDOM(e.target) : null;
|
||||
}
|
||||
|
||||
export const columnModeExtension: Extension = [
|
||||
columnModeField,
|
||||
rectangularSelection({
|
||||
eventFilter: (e) => {
|
||||
if (e.button !== 0 || e.altKey || e.metaKey || e.ctrlKey) return false;
|
||||
const view = viewOf(e);
|
||||
return !!view && isColumnMode(view.state);
|
||||
},
|
||||
blockMouseSelection((e) => {
|
||||
if (e.button !== 0 || e.altKey || e.metaKey || e.ctrlKey) return false;
|
||||
const view = viewOf(e);
|
||||
return !!view && isColumnMode(view.state);
|
||||
}),
|
||||
Prec.high(
|
||||
keymap.of([
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { Command, EditorView } from "@codemirror/view";
|
||||
import { indentString, syntaxTree } from "@codemirror/language";
|
||||
import { toggleComment } from "@codemirror/commands";
|
||||
import { offsetAtVisibleColumn, visibleColumn } from "./visibleColumn";
|
||||
|
||||
/** Code Style › <Language> › Code Generation › Comment Code. */
|
||||
export interface LineCommentStyle {
|
||||
@@ -151,9 +152,9 @@ export const commentLine: Command = (view) => {
|
||||
} else if (b.caretUpdate === "down") {
|
||||
const line = next.lineAt(r.head);
|
||||
if (line.number >= next.lines) continue;
|
||||
const col = countColumn(next.sliceString(line.from, r.head), state.tabSize);
|
||||
const col = visibleColumn(line.text, r.head - line.from, state.tabSize);
|
||||
const below = next.line(line.number + 1);
|
||||
ranges[i] = EditorSelection.cursor(below.from + offsetAtColumn(below.text, col, state.tabSize));
|
||||
ranges[i] = EditorSelection.cursor(below.from + offsetAtVisibleColumn(below.text, col, state.tabSize));
|
||||
} else {
|
||||
const from = next.lineAt(r.from).from;
|
||||
ranges[i] = r.head >= r.anchor ? EditorSelection.range(from, r.to) : EditorSelection.range(r.to, from);
|
||||
@@ -170,15 +171,6 @@ export const commentLine: Command = (view) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
function offsetAtColumn(text: string, col: number, tabSize: number): number {
|
||||
let cols = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (cols >= col) return i;
|
||||
cols += text[i] === "\t" ? tabSize - (cols % tabSize) : 1;
|
||||
}
|
||||
return text.length;
|
||||
}
|
||||
|
||||
/** Smallest indent of the non-blank lines, also no deeper than a line comment right above. */
|
||||
function minIndent(state: EditorState, startLine: number, endLine: number, token: string): number {
|
||||
const doc = state.doc;
|
||||
|
||||
@@ -201,6 +201,13 @@ describe("comment with line comment", () => {
|
||||
expect(show(v)).toBe("//a\nb|\n//c\nd|\n");
|
||||
});
|
||||
|
||||
it("moves the caret down to the same visible column past CJK text", () => {
|
||||
const v = make("张三|x\nabcdefgh\n", comments);
|
||||
commentLine(v);
|
||||
// "//张三" is six columns wide.
|
||||
expect(show(v)).toBe("//张三x\nabcdef|gh\n");
|
||||
});
|
||||
|
||||
it("keeps the caret after the prefix on an empty line", () => {
|
||||
const v = make("f {\n |\n b", indented);
|
||||
commentLine(v);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* IDEA mouse multi-caret gestures (macOS keymap): ⌥⇧click adds a caret (or
|
||||
* removes the one under the pointer), ⌥⇧drag adds a selection, ⌥drag makes a
|
||||
* column (rectangular) selection.
|
||||
* removes the one under the pointer), ⌥⇧drag adds a selection, ⌥drag and
|
||||
* middle-button drag make a column (rectangular) selection. Middle-button
|
||||
* drag is left alone on Linux, where a middle click pastes the primary selection.
|
||||
*/
|
||||
import { EditorSelection, type Extension } from "@codemirror/state";
|
||||
import { EditorView, rectangularSelection, type MouseSelectionStyle } from "@codemirror/view";
|
||||
import { EditorView, type MouseSelectionStyle } from "@codemirror/view";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { blockMouseSelection } from "./columnMode";
|
||||
|
||||
/** The selection after ⌥⇧click / drag from `anchor` to `head` on top of `start`. */
|
||||
export function toggleCaret(start: EditorSelection, anchor: number, head: number): EditorSelection {
|
||||
@@ -37,5 +40,9 @@ export const multiCaretMouse: Extension = [
|
||||
addCaretStyle,
|
||||
// CodeMirror's default (⌘click) would clash with ⌘click navigation.
|
||||
EditorView.clickAddsSelectionRange.of(() => false),
|
||||
rectangularSelection({ eventFilter: (e) => e.altKey && !e.shiftKey && !e.metaKey && !e.ctrlKey }),
|
||||
blockMouseSelection((e) => {
|
||||
if (e.shiftKey || e.metaKey || e.ctrlKey) return false;
|
||||
if (e.button === 0) return e.altKey;
|
||||
return e.button === 1 && !e.altKey && usePlatform.getState().os !== "linux";
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -45,6 +45,7 @@ import { caretExtension, fontZoom, methodLinesField, prefsExtension, softWrapAct
|
||||
import { commentTokens } from "./languages";
|
||||
import { multiCaretMouse } from "./multiCaret";
|
||||
import { columnModeExtension } from "./columnMode";
|
||||
import { visibleGoalColumns } from "./verticalMotion";
|
||||
import { tabKeymap } from "./smartKeys";
|
||||
import { editorBaseKeymap } from "./baseKeymap";
|
||||
import { occurrenceSearch } from "./editing";
|
||||
@@ -330,6 +331,7 @@ export function baseExtensions(o: SetupOptions): Extension {
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
multiCaretMouse,
|
||||
columnModeExtension,
|
||||
visibleGoalColumns,
|
||||
occurrenceSearch,
|
||||
overwriteField,
|
||||
gutterLineDrag,
|
||||
|
||||
@@ -72,6 +72,12 @@ describe("tab", () => {
|
||||
expect(show(v)).toBe("ab |c\n |x");
|
||||
});
|
||||
|
||||
it("counts wide characters as two columns towards the indent stop", () => {
|
||||
const v = make("a张|x\n张|y");
|
||||
insertTab(v);
|
||||
expect(show(v)).toBe("a张 |x\n张 |y");
|
||||
});
|
||||
|
||||
it("inserts a tab character when the code style uses tabs", () => {
|
||||
const v = make("a|b", [], "\t");
|
||||
insertTab(v);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { EditorView, keymap, type Command } from "@codemirror/view";
|
||||
import { getIndentUnit, getIndentation, indentString, indentUnit, syntaxTree } from "@codemirror/language";
|
||||
import { indentMore } from "@codemirror/commands";
|
||||
import { visibleColumn } from "./visibleColumn";
|
||||
|
||||
// --- Tab -------------------------------------------------------------------------------
|
||||
|
||||
@@ -32,7 +33,7 @@ export const insertTab: Command = (view) => {
|
||||
const size = Math.max(1, getIndentUnit(state));
|
||||
const tr = state.changeByRange((r) => {
|
||||
const line = state.doc.lineAt(r.from);
|
||||
const col = countColumn(line.text.slice(0, r.from - line.from), state.tabSize);
|
||||
const col = visibleColumn(line.text, r.from - line.from, state.tabSize);
|
||||
const insert = useTab ? "\t" : " ".repeat(size - (col % size));
|
||||
return { changes: { from: r.from, to: r.to, insert }, range: EditorSelection.cursor(r.from + insert.length) };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import {
|
||||
cloneCaretAbove,
|
||||
cloneCaretBelow,
|
||||
cursorLineDown,
|
||||
cursorLineUp,
|
||||
selectLineDown,
|
||||
visibleGoalColumns,
|
||||
} from "./verticalMotion";
|
||||
|
||||
let view: EditorView | null = null;
|
||||
afterEach(() => {
|
||||
view?.destroy();
|
||||
view = null;
|
||||
});
|
||||
|
||||
function make(doc: string, at: number) {
|
||||
view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
selection: { anchor: at },
|
||||
extensions: [visibleGoalColumns, EditorState.allowMultipleSelections.of(true), EditorState.tabSize.of(4)],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
const heads = (v: EditorView) => v.state.selection.ranges.map((r) => r.head);
|
||||
/** Offset of `needle` on line `n` (1-based) of the view's document. */
|
||||
const at = (v: EditorView, n: number, needle: string) =>
|
||||
v.state.doc.line(n).from + v.state.doc.line(n).text.indexOf(needle);
|
||||
|
||||
// Every `|` sits at visible column 15.
|
||||
const DOC = [
|
||||
'name = "张三" |/ 中文注释',
|
||||
"id = 42 |/ answer",
|
||||
",。!:()。 |/ 标点",
|
||||
"😀😀 a\t |/ emoji",
|
||||
].join("\n");
|
||||
|
||||
describe("vertical caret motion in visible columns", () => {
|
||||
it("↓ keeps the column through CJK, fullwidth punctuation, emoji and tabs", () => {
|
||||
const v = make(DOC, DOC.indexOf("|"));
|
||||
for (let n = 2; n <= 4; n++) {
|
||||
cursorLineDown(v);
|
||||
expect(heads(v)).toEqual([at(v, n, "|")]);
|
||||
}
|
||||
for (let n = 3; n >= 1; n--) {
|
||||
cursorLineUp(v);
|
||||
expect(heads(v)).toEqual([at(v, n, "|")]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the goal column across a short line", () => {
|
||||
const v = make("abcdefgh\n张\nabcdefgh", 6);
|
||||
cursorLineDown(v);
|
||||
expect(heads(v)).toEqual([10]); // end of "张"
|
||||
cursorLineDown(v);
|
||||
expect(heads(v)).toEqual([11 + 6]);
|
||||
});
|
||||
|
||||
it("snaps inside a wide character to the nearer side", () => {
|
||||
// Column 3 is the middle of 中 (columns 2..4) on line 2: the tie goes after it.
|
||||
const v = make("abcd\n张中", 3);
|
||||
cursorLineDown(v);
|
||||
expect(heads(v)).toEqual([5 + 2]);
|
||||
});
|
||||
|
||||
it("⇧↓ extends to the same column", () => {
|
||||
const v = make("张三ab\nabcdef", 2);
|
||||
selectLineDown(v);
|
||||
expect(v.state.selection.main.anchor).toBe(2);
|
||||
expect(v.state.selection.main.head).toBe(5 + 4);
|
||||
});
|
||||
|
||||
it("moves to the line boundary on the first / last line", () => {
|
||||
const v = make("ab\ncd", 1);
|
||||
cursorLineUp(v);
|
||||
expect(heads(v)).toEqual([0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Clone Caret Above / Below in visible columns", () => {
|
||||
it("clones at the same visible column on mixed CJK / ASCII lines", () => {
|
||||
const v = make(DOC, DOC.indexOf("|"));
|
||||
cloneCaretBelow(v);
|
||||
cloneCaretBelow(v);
|
||||
cloneCaretBelow(v);
|
||||
expect(heads(v)).toEqual([1, 2, 3, 4].map((n) => at(v, n, "|")));
|
||||
});
|
||||
|
||||
it("keeps the original column past a short line", () => {
|
||||
const v = make("abcdefgh\nab\nabcdefgh", 18);
|
||||
// Caret at column 6 of line 3; line 2 is two columns wide.
|
||||
cloneCaretAbove(v);
|
||||
cloneCaretAbove(v);
|
||||
expect(heads(v)).toEqual([6, 11, 18]);
|
||||
});
|
||||
|
||||
it("does nothing at the document edge", () => {
|
||||
const v = make("张三", 1);
|
||||
expect(cloneCaretAbove(v)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* ↑ / ↓ (with ⇧ extending) and Clone Caret Above / Below in visible columns:
|
||||
* the caret keeps its goal column across lines of mixed-width text (CJK,
|
||||
* fullwidth punctuation, emoji, tabs) instead of a pixel x that drifts when a
|
||||
* wide glyph is not exactly two cells wide. Soft-wrapped lines keep
|
||||
* CodeMirror's pixel motion between their visual rows.
|
||||
*/
|
||||
import { EditorSelection, StateEffect, StateField, type EditorState, type SelectionRange } from "@codemirror/state";
|
||||
import type { Command, EditorView } from "@codemirror/view";
|
||||
import { offsetAtVisibleColumn, visibleColumn } from "./visibleColumn";
|
||||
|
||||
/** Goal visible column per caret head, kept while only these commands move the carets. */
|
||||
const setGoals = StateEffect.define<ReadonlyMap<number, number>>();
|
||||
|
||||
export const visibleGoalColumns = StateField.define<ReadonlyMap<number, number> | null>({
|
||||
create: () => null,
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) if (e.is(setGoals)) return e.value;
|
||||
return tr.selection || tr.docChanged ? null : value;
|
||||
},
|
||||
});
|
||||
|
||||
function goalOf(state: EditorState, head: number): number {
|
||||
const goal = state.field(visibleGoalColumns, false)?.get(head);
|
||||
if (goal !== undefined) return goal;
|
||||
const line = state.doc.lineAt(head);
|
||||
return visibleColumn(line.text, head - line.from, state.tabSize);
|
||||
}
|
||||
|
||||
/** Whether the line from `pos` is one visual row (not soft-wrapped, not merged with others by a fold). */
|
||||
function singleRow(view: EditorView, pos: number): boolean {
|
||||
const line = view.state.doc.lineAt(pos);
|
||||
const block = view.lineBlockAt(pos);
|
||||
if (block.from !== line.from || block.to !== line.to) return false;
|
||||
if (!view.lineWrapping || line.length === 0) return true;
|
||||
const a = view.coordsAtPos(line.from, 1);
|
||||
const b = view.coordsAtPos(line.to, -1);
|
||||
return !!a && !!b && Math.abs(a.top - b.top) < 1;
|
||||
}
|
||||
|
||||
/** The head one visual line up / down from `head` at visible column `goal`, or null at the document edge. */
|
||||
export function verticalHead(view: EditorView, head: number, forward: boolean, goal: number): number | null {
|
||||
const { state } = view;
|
||||
const block = view.lineBlockAt(head);
|
||||
if (forward ? block.to >= state.doc.length : block.from <= 0) return null;
|
||||
const target = forward ? block.to + 1 : block.from - 1;
|
||||
if (singleRow(view, head) && singleRow(view, target)) {
|
||||
const line = state.doc.lineAt(target);
|
||||
return line.from + offsetAtVisibleColumn(line.text, goal, state.tabSize);
|
||||
}
|
||||
const moved = view.moveVertically(EditorSelection.cursor(head), forward).head;
|
||||
return moved === head ? null : moved;
|
||||
}
|
||||
|
||||
function moveCarets(view: EditorView, forward: boolean, extend: boolean): boolean {
|
||||
const { state } = view;
|
||||
const goals = new Map<number, number>();
|
||||
const ranges = state.selection.ranges.map((r): SelectionRange => {
|
||||
if (!extend && !r.empty) return EditorSelection.cursor(forward ? r.to : r.from);
|
||||
const goal = goalOf(state, r.head);
|
||||
let head = verticalHead(view, r.head, forward, goal);
|
||||
if (head === null) head = forward ? state.doc.lineAt(r.head).to : state.doc.lineAt(r.head).from;
|
||||
goals.set(head, goal);
|
||||
return extend ? EditorSelection.range(r.anchor, head) : EditorSelection.cursor(head);
|
||||
});
|
||||
const selection = EditorSelection.create(ranges, state.selection.mainIndex);
|
||||
if (selection.eq(state.selection)) return false;
|
||||
view.dispatch({ selection, effects: setGoals.of(goals), scrollIntoView: true, userEvent: "select" });
|
||||
return true;
|
||||
}
|
||||
|
||||
export const cursorLineUp: Command = (view) => moveCarets(view, false, false);
|
||||
export const cursorLineDown: Command = (view) => moveCarets(view, true, false);
|
||||
export const selectLineUp: Command = (view) => moveCarets(view, false, true);
|
||||
export const selectLineDown: Command = (view) => moveCarets(view, true, true);
|
||||
|
||||
/** Clone Caret Above / Below: a caret on the next line at each caret's goal column. */
|
||||
function cloneCaret(view: EditorView, forward: boolean): boolean {
|
||||
const { state } = view;
|
||||
const ranges = state.selection.ranges.slice();
|
||||
const goals = new Map<number, number>();
|
||||
for (const r of state.selection.ranges) {
|
||||
const goal = goalOf(state, r.head);
|
||||
goals.set(r.head, goal);
|
||||
const line = state.doc.lineAt(r.head);
|
||||
// A soft-wrapped line: step over its own visual rows first.
|
||||
let head: number | null = r.head;
|
||||
while (head !== null && head >= line.from && head <= line.to) {
|
||||
const next = verticalHead(view, head, forward, goal);
|
||||
head = next === head ? null : next;
|
||||
}
|
||||
if (head === null || ranges.some((x) => x.head === head)) continue;
|
||||
ranges.push(EditorSelection.cursor(head));
|
||||
goals.set(head, goal);
|
||||
}
|
||||
if (ranges.length === state.selection.ranges.length) return false;
|
||||
view.dispatch({
|
||||
selection: EditorSelection.create(ranges, ranges.length - 1),
|
||||
effects: setGoals.of(goals),
|
||||
scrollIntoView: true,
|
||||
userEvent: "select",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export const cloneCaretAbove: Command = (view) => cloneCaret(view, false);
|
||||
export const cloneCaretBelow: Command = (view) => cloneCaret(view, true);
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findClusterBreak } from "@codemirror/state";
|
||||
import {
|
||||
clusterWidth,
|
||||
codePointWidth,
|
||||
offsetAtVisibleColumn,
|
||||
stepVisibleColumn,
|
||||
visibleColumn,
|
||||
visibleWidth,
|
||||
} from "./visibleColumn";
|
||||
|
||||
const WIDTHS: [label: string, text: string, width: number][] = [
|
||||
["ASCII", "id = 42", 7],
|
||||
["CJK ideographs", "张三", 4],
|
||||
["mixed line", 'name = "张三" // 中文注释', 26],
|
||||
["fullwidth punctuation", ",。!:()", 12],
|
||||
["ideographic space", "a b", 4],
|
||||
["fullwidth Latin", "AB1", 6],
|
||||
["halfwidth katakana", "アイ", 2],
|
||||
["hiragana / katakana", "かなカナ", 8],
|
||||
["Hangul", "한글", 4],
|
||||
["CJK Ext B", "𠀀", 2],
|
||||
["emoji", "😀", 2],
|
||||
["emoji ZWJ sequence", "👨👩👧", 2],
|
||||
["emoji with skin tone", "👍🏽", 2],
|
||||
["flag", "🇨🇳", 2],
|
||||
["text-style pictograph", "❤", 1],
|
||||
["pictograph + VS16", "❤️", 2],
|
||||
["combining acute", "é", 1],
|
||||
["several combining marks", "à̖b", 2],
|
||||
["zero-width space", "ab", 2],
|
||||
["ambiguous arrow", "→", 1],
|
||||
["tab", "\t", 4],
|
||||
["tab after ASCII", "a\tb", 5],
|
||||
["tab after CJK", "张\tb", 5],
|
||||
["tab after 4 columns of CJK", "张三\tb", 9],
|
||||
["empty", "", 0],
|
||||
];
|
||||
|
||||
describe("visible columns", () => {
|
||||
it.each(WIDTHS)("%s", (_label, text, width) => {
|
||||
expect(visibleWidth(text, 4)).toBe(width);
|
||||
});
|
||||
|
||||
it.each(WIDTHS)("round-trips every cluster boundary: %s", (_label, text) => {
|
||||
for (let i = 0; ; i = findClusterBreak(text, i)) {
|
||||
const col = visibleColumn(text, i, 4);
|
||||
const back = offsetAtVisibleColumn(text, col, 4);
|
||||
// A zero-width cluster shares its column with the boundary before it.
|
||||
if (clusterWidth(text, back, i) === 0) expect(back).toBeLessThanOrEqual(i);
|
||||
else expect(back).toBe(i);
|
||||
if (i >= text.length) break;
|
||||
}
|
||||
});
|
||||
|
||||
it("code points", () => {
|
||||
expect(codePointWidth(0x41)).toBe(1);
|
||||
expect(codePointWidth(0x4e2d)).toBe(2);
|
||||
expect(codePointWidth(0xff0c)).toBe(2);
|
||||
expect(codePointWidth(0x0301)).toBe(0);
|
||||
expect(codePointWidth(0x200d)).toBe(0);
|
||||
expect(codePointWidth(0x1f600)).toBe(2);
|
||||
expect(clusterWidth("é")).toBe(1);
|
||||
});
|
||||
|
||||
it("maps a column inside a wide character to the nearer side", () => {
|
||||
// "a张b": a [0,1) 张 [1,3) b [3,4); UTF-16 offsets 0, 1, 2, 3.
|
||||
const t = "a张b";
|
||||
expect([0, 1, 2, 3, 4].map((c) => offsetAtVisibleColumn(t, c, 4))).toEqual([0, 1, 2, 2, 3]);
|
||||
expect(offsetAtVisibleColumn(t, 2, 4, "before")).toBe(1);
|
||||
expect(offsetAtVisibleColumn(t, 2, 4, "after")).toBe(2);
|
||||
expect(offsetAtVisibleColumn(t, 9, 4)).toBe(3);
|
||||
expect(offsetAtVisibleColumn(t, 9, 4, "nearest", true)).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns UTF-16 offsets around astral characters", () => {
|
||||
// "a😀b": the emoji is two code units and two columns.
|
||||
expect(offsetAtVisibleColumn("a😀b", 3, 4)).toBe(3);
|
||||
expect(visibleColumn("a😀b", 3, 4)).toBe(3);
|
||||
expect(offsetAtVisibleColumn("a👨👩👧b", 3, 4)).toBe(1 + "👨👩👧".length);
|
||||
});
|
||||
|
||||
it("steps one cluster at a time, then one column past the end", () => {
|
||||
const t = "a张\tb";
|
||||
// Columns: a 0, 张 1..3, tab 3..4, b 4..5.
|
||||
expect(stepVisibleColumn(t, 0, 1, 4)).toBe(1);
|
||||
expect(stepVisibleColumn(t, 1, 1, 4)).toBe(3);
|
||||
expect(stepVisibleColumn(t, 3, 1, 4)).toBe(4);
|
||||
expect(stepVisibleColumn(t, 5, 1, 4)).toBe(6);
|
||||
expect(stepVisibleColumn(t, 6, -1, 4)).toBe(5);
|
||||
expect(stepVisibleColumn(t, 3, -1, 4)).toBe(1);
|
||||
expect(stepVisibleColumn(t, 2, -1, 4)).toBe(1);
|
||||
expect(stepVisibleColumn(t, 1, -1, 4)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Visible columns: the column model of every column-based editing operation
|
||||
* (block selection, clone caret, ↑/↓ goal column, caret column after
|
||||
* commenting, Tab stops). East Asian Wide / Fullwidth characters and emoji
|
||||
* count 2, combining marks and other zero-width characters 0, a tab runs to
|
||||
* the next tab stop, everything else 1. Measured per grapheme cluster, so a
|
||||
* ZWJ emoji sequence or a base letter with accents is one unit.
|
||||
*
|
||||
* UI-side only: offsets in and out are UTF-16 offsets into the line text.
|
||||
*/
|
||||
import { findClusterBreak } from "@codemirror/state";
|
||||
|
||||
// East Asian Width W / F ranges outside emoji (Unicode 15.1), sorted, inclusive.
|
||||
// prettier-ignore
|
||||
const WIDE: readonly number[] = [
|
||||
0x1100, 0x115f, 0x2329, 0x232a, 0x2e80, 0x303e, 0x3041, 0x33ff, 0x3400, 0x4dbf, 0x4e00, 0x9fff,
|
||||
0xa000, 0xa4cf, 0xa960, 0xa97f, 0xac00, 0xd7a3, 0xf900, 0xfaff, 0xfe10, 0xfe19, 0xfe30, 0xfe6f,
|
||||
0xff00, 0xff60, 0xffe0, 0xffe6, 0x16fe0, 0x16fe4, 0x16ff0, 0x16ff1, 0x17000, 0x18cff, 0x18d00, 0x18d08,
|
||||
0x1aff0, 0x1b2ff, 0x1f200, 0x1f202, 0x1f210, 0x1f23b, 0x1f240, 0x1f248, 0x1f250, 0x1f251, 0x1f260, 0x1f265,
|
||||
0x20000, 0x2fffd, 0x30000, 0x3fffd,
|
||||
];
|
||||
|
||||
const ZERO = /[\p{Mn}\p{Me}\p{Cf}ᅠ-ᇿ]/u;
|
||||
const EMOJI = /\p{Emoji_Presentation}/u;
|
||||
const PICTOGRAPHIC = /\p{Extended_Pictographic}/u;
|
||||
|
||||
function inWide(cp: number): boolean {
|
||||
let lo = 0;
|
||||
let hi = WIDE.length / 2 - 1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (cp < WIDE[mid * 2]!) hi = mid - 1;
|
||||
else if (cp > WIDE[mid * 2 + 1]!) lo = mid + 1;
|
||||
else return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Columns one code point takes on its own (a tab is not handled here). */
|
||||
export function codePointWidth(cp: number): 0 | 1 | 2 {
|
||||
if (cp < 0x300) return 1;
|
||||
const ch = String.fromCodePoint(cp);
|
||||
if (ZERO.test(ch)) return 0;
|
||||
if (inWide(cp) || EMOJI.test(ch)) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Columns one grapheme cluster takes: its widest code point, 2 for a pictograph in emoji presentation (U+FE0F). */
|
||||
export function clusterWidth(text: string, from = 0, to = text.length): number {
|
||||
let width = 0;
|
||||
let pictographic = false;
|
||||
for (let i = from; i < to;) {
|
||||
const cp = text.codePointAt(i)!;
|
||||
if (cp === 0xfe0f && pictographic) return 2;
|
||||
if (i === from) pictographic = PICTOGRAPHIC.test(String.fromCodePoint(cp));
|
||||
width = Math.max(width, codePointWidth(cp));
|
||||
if (width === 2) return 2;
|
||||
i += cp > 0xffff ? 2 : 1;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
/** Width of the cluster starting at `i` (at visible column `col`) and where it ends. */
|
||||
function step(text: string, i: number, col: number, tabSize: number): [width: number, next: number] {
|
||||
const c = text.charCodeAt(i);
|
||||
if (c === 9) return [tabSize - (col % tabSize), i + 1];
|
||||
// Plain ASCII never extends a cluster except before a combining mark / ZWJ / variation selector.
|
||||
const next = c < 0x80 && (i + 1 >= text.length || text.charCodeAt(i + 1) < 0x300) ? i + 1 : findClusterBreak(text, i);
|
||||
return [c >= 0x20 && c < 0x7f ? 1 : clusterWidth(text, i, next), next];
|
||||
}
|
||||
|
||||
/** The visible column at UTF-16 `offset` of `text` (a cluster `offset` falls inside counts whole). */
|
||||
export function visibleColumn(text: string, offset: number, tabSize: number): number {
|
||||
let col = 0;
|
||||
for (let i = 0; i < offset && i < text.length;) {
|
||||
const [w, next] = step(text, i, col, tabSize);
|
||||
col += w;
|
||||
i = next;
|
||||
}
|
||||
return col;
|
||||
}
|
||||
|
||||
/** The visible width of `text`. */
|
||||
export function visibleWidth(text: string, tabSize: number): number {
|
||||
return visibleColumn(text, text.length, tabSize);
|
||||
}
|
||||
|
||||
export type ColumnBias = "nearest" | "before" | "after";
|
||||
|
||||
/**
|
||||
* The UTF-16 offset in `text` at visible column `col`. A column inside a wide
|
||||
* character or a tab snaps to the nearer side (a tie goes after) unless `bias`
|
||||
* says otherwise. Past the end: `text.length`, or -1 when `strict`.
|
||||
*/
|
||||
export function offsetAtVisibleColumn(
|
||||
text: string,
|
||||
col: number,
|
||||
tabSize: number,
|
||||
bias: ColumnBias = "nearest",
|
||||
strict = false,
|
||||
): number {
|
||||
let n = 0;
|
||||
for (let i = 0; ;) {
|
||||
if (n >= col) return i;
|
||||
if (i >= text.length) break;
|
||||
const [w, next] = step(text, i, n, tabSize);
|
||||
if (n + w > col) {
|
||||
if (bias === "before") return i;
|
||||
if (bias === "after") return next;
|
||||
return col - n < n + w - col ? i : next;
|
||||
}
|
||||
n += w;
|
||||
i = next;
|
||||
}
|
||||
return strict ? -1 : text.length;
|
||||
}
|
||||
|
||||
/** The visible column one cluster left / right of `col` in `text`; past the line end, one column. */
|
||||
export function stepVisibleColumn(text: string, col: number, d: number, tabSize: number): number {
|
||||
if (!d) return col;
|
||||
const width = visibleWidth(text, tabSize);
|
||||
if (d > 0 ? col >= width : col > width) return Math.max(0, col + d);
|
||||
if (d > 0) return visibleColumn(text, offsetAtVisibleColumn(text, col + 1, tabSize, "after"), tabSize);
|
||||
const pos = offsetAtVisibleColumn(text, col, tabSize, "before");
|
||||
if (visibleColumn(text, pos, tabSize) < col) return visibleColumn(text, pos, tabSize);
|
||||
return pos === 0 ? 0 : visibleColumn(text, offsetAtVisibleColumn(text, col - 1, tabSize, "before"), tabSize);
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
cursorGroupRight,
|
||||
cursorLineBoundaryBackward,
|
||||
cursorLineBoundaryForward,
|
||||
cursorLineDown,
|
||||
cursorLineUp,
|
||||
cursorPageDown,
|
||||
cursorPageUp,
|
||||
deleteCharBackward,
|
||||
@@ -30,12 +28,11 @@ import {
|
||||
selectGroupRight,
|
||||
selectLineBoundaryBackward,
|
||||
selectLineBoundaryForward,
|
||||
selectLineDown,
|
||||
selectLineUp,
|
||||
selectPageDown,
|
||||
selectPageUp,
|
||||
} from "@codemirror/commands";
|
||||
import type { Command } from "@codemirror/view";
|
||||
import { cursorLineDown, cursorLineUp, selectLineDown, selectLineUp } from "@/editor/core/cm/verticalMotion";
|
||||
|
||||
/** Recorded key name (`Shift+ArrowLeft`, `Alt+Backspace`) → command. */
|
||||
export const EDITOR_KEY_COMMANDS: Record<string, Command> = {
|
||||
|
||||
Reference in New Issue
Block a user