publish codemirrot helix package flow
Some checks failed
Publish codemirror-helix / publish (push) Failing after 2m16s
Some checks failed
Publish codemirror-helix / publish (push) Failing after 2m16s
This commit is contained in:
593
packages/codemirror-helix/src/commands.ts
Normal file
593
packages/codemirror-helix/src/commands.ts
Normal file
@@ -0,0 +1,593 @@
|
||||
import {
|
||||
EditorSelection,
|
||||
type ChangeSpec,
|
||||
type EditorState,
|
||||
type SelectionRange,
|
||||
} from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { indentLess, indentMore, redo, undo } from "@codemirror/commands";
|
||||
import { findNext, findPrevious, SearchQuery, setSearchQuery } from "@codemirror/search";
|
||||
import {
|
||||
clearPending,
|
||||
getHelix,
|
||||
helixEffect,
|
||||
readRegister,
|
||||
setRegister,
|
||||
type HelixMode,
|
||||
} from "./state";
|
||||
import * as M from "./motions";
|
||||
|
||||
const settle = { ...clearPending() };
|
||||
|
||||
function clamp(n: number, state: EditorState): number {
|
||||
return Math.max(0, Math.min(n, state.doc.length));
|
||||
}
|
||||
|
||||
function rangesText(state: EditorState, ranges: readonly SelectionRange[]): string {
|
||||
return ranges.map((r) => state.doc.sliceString(r.from, r.to)).join("\n");
|
||||
}
|
||||
|
||||
function maybeClipboard(register: string | null, text: string) {
|
||||
if ((register === "+" || register === "*") && typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
void navigator.clipboard.writeText(text).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** After leaving an edit, choose the resting mode. */
|
||||
function restMode(current: HelixMode, target?: HelixMode): HelixMode {
|
||||
if (target) return target;
|
||||
return current === "select" ? "normal" : current;
|
||||
}
|
||||
|
||||
// --- motion application ------------------------------------------------------
|
||||
|
||||
export type PointFn = (state: EditorState, pos: number) => number;
|
||||
|
||||
export interface MotionOpts {
|
||||
extend: boolean;
|
||||
span: boolean; // word-like: selects the traversed span in normal mode
|
||||
count: number;
|
||||
}
|
||||
|
||||
export function moveByPoint(view: EditorView, fn: PointFn, opts: MotionOpts) {
|
||||
const { state } = view;
|
||||
const selection = EditorSelection.create(
|
||||
state.selection.ranges.map((r) => {
|
||||
let head = r.head;
|
||||
for (let i = 0; i < Math.max(1, opts.count); i++) head = fn(state, head);
|
||||
head = clamp(head, state);
|
||||
const anchor = opts.extend ? r.anchor : opts.span ? r.head : head;
|
||||
return EditorSelection.range(clamp(anchor, state), head);
|
||||
}),
|
||||
state.selection.mainIndex,
|
||||
);
|
||||
view.dispatch({ selection, scrollIntoView: true });
|
||||
}
|
||||
|
||||
export function moveVertical(view: EditorView, forward: boolean, opts: { extend: boolean; count: number }) {
|
||||
const selection = EditorSelection.create(
|
||||
view.state.selection.ranges.map((r) => {
|
||||
let cur: SelectionRange = EditorSelection.cursor(r.head);
|
||||
for (let i = 0; i < Math.max(1, opts.count); i++) cur = view.moveVertically(cur, forward);
|
||||
return opts.extend
|
||||
? EditorSelection.range(r.anchor, cur.head)
|
||||
: EditorSelection.cursor(cur.head);
|
||||
}),
|
||||
view.state.selection.mainIndex,
|
||||
);
|
||||
view.dispatch({ selection, scrollIntoView: true });
|
||||
}
|
||||
|
||||
// --- insertion entry points --------------------------------------------------
|
||||
|
||||
export function enterInsert(view: EditorView, posOf: (state: EditorState, r: SelectionRange) => number) {
|
||||
const { state } = view;
|
||||
const selection = EditorSelection.create(
|
||||
state.selection.ranges.map((r) => EditorSelection.cursor(clamp(posOf(state, r), state))),
|
||||
state.selection.mainIndex,
|
||||
);
|
||||
view.dispatch({ selection, effects: helixEffect.of({ mode: "insert", ...settle }) });
|
||||
}
|
||||
|
||||
export function openLine(view: EditorView, above: boolean) {
|
||||
const { state } = view;
|
||||
const seen = new Set<number>();
|
||||
const changes: ChangeSpec[] = [];
|
||||
for (const r of state.selection.ranges) {
|
||||
const line = state.doc.lineAt(r.head);
|
||||
if (seen.has(line.number)) continue;
|
||||
seen.add(line.number);
|
||||
const indent = line.text.match(/^\s*/)?.[0] ?? "";
|
||||
changes.push(above ? { from: line.from, insert: `${indent}\n` } : { from: line.to, insert: `\n${indent}` });
|
||||
}
|
||||
const changeSet = state.changes(changes);
|
||||
// Place a cursor on each newly opened line.
|
||||
const selection = EditorSelection.create(
|
||||
[...seen].map((n) => {
|
||||
const line = state.doc.line(n);
|
||||
const pos = above ? line.from : line.to;
|
||||
const mapped = changeSet.mapPos(pos, 1);
|
||||
return EditorSelection.cursor(mapped);
|
||||
}),
|
||||
);
|
||||
view.dispatch({
|
||||
changes: changeSet,
|
||||
selection,
|
||||
effects: helixEffect.of({ mode: "insert", ...settle }),
|
||||
scrollIntoView: true,
|
||||
});
|
||||
}
|
||||
|
||||
// --- delete / change / yank / paste -----------------------------------------
|
||||
|
||||
interface DeleteOpts {
|
||||
insert?: boolean; // change (c)
|
||||
}
|
||||
|
||||
export function deleteSelection(view: EditorView, opts: DeleteOpts = {}) {
|
||||
const { state } = view;
|
||||
const helix = getHelix(state);
|
||||
const specs = state.selection.ranges.map((r) => ({
|
||||
from: r.from,
|
||||
to: r.empty ? Math.min(r.to + 1, state.doc.length) : r.to,
|
||||
}));
|
||||
const yanked = specs.map((s) => state.doc.sliceString(s.from, s.to)).join("\n");
|
||||
const changeSet = state.changes(specs.map((s) => ({ from: s.from, to: s.to, insert: "" })));
|
||||
const selection = EditorSelection.create(
|
||||
specs.map((s) => EditorSelection.cursor(changeSet.mapPos(s.from, -1))),
|
||||
state.selection.mainIndex,
|
||||
);
|
||||
view.dispatch({
|
||||
changes: changeSet,
|
||||
selection,
|
||||
effects: [
|
||||
setRegister.of({ name: helix.register ?? '"', text: yanked }),
|
||||
helixEffect.of({ mode: opts.insert ? "insert" : restMode(helix.mode), ...settle }),
|
||||
],
|
||||
scrollIntoView: true,
|
||||
});
|
||||
maybeClipboard(helix.register, yanked);
|
||||
}
|
||||
|
||||
export function yankSelection(view: EditorView) {
|
||||
const { state } = view;
|
||||
const helix = getHelix(state);
|
||||
const text = rangesText(state, state.selection.ranges);
|
||||
view.dispatch({
|
||||
effects: [
|
||||
setRegister.of({ name: helix.register ?? '"', text }),
|
||||
helixEffect.of({ mode: restMode(helix.mode), ...settle }),
|
||||
],
|
||||
});
|
||||
maybeClipboard(helix.register, text);
|
||||
}
|
||||
|
||||
export function paste(view: EditorView, before: boolean) {
|
||||
const { state } = view;
|
||||
const helix = getHelix(state);
|
||||
const text = readRegister(state, helix.register);
|
||||
if (!text) return;
|
||||
const linewise = text.endsWith("\n");
|
||||
const changes: { from: number; to: number; insert: string }[] = [];
|
||||
for (const r of state.selection.ranges) {
|
||||
if (linewise) {
|
||||
const line = state.doc.lineAt(before ? r.from : r.to);
|
||||
if (before) changes.push({ from: line.from, to: line.from, insert: text });
|
||||
else changes.push({ from: line.to, to: line.to, insert: `\n${text.replace(/\n$/, "")}` });
|
||||
} else {
|
||||
const at = before ? r.from : r.to;
|
||||
changes.push({ from: at, to: at, insert: text });
|
||||
}
|
||||
}
|
||||
const changeSet = state.changes(changes);
|
||||
view.dispatch({
|
||||
changes: changeSet,
|
||||
selection: state.selection.map(changeSet),
|
||||
effects: helixEffect.of(settle),
|
||||
scrollIntoView: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** R: replace selections with the register contents. */
|
||||
export function replaceWithYank(view: EditorView) {
|
||||
const { state } = view;
|
||||
const helix = getHelix(state);
|
||||
const text = readRegister(state, helix.register);
|
||||
if (!text) return;
|
||||
const changes = state.selection.ranges.map((r) => ({ from: r.from, to: r.to, insert: text }));
|
||||
const changeSet = state.changes(changes);
|
||||
view.dispatch({
|
||||
changes: changeSet,
|
||||
selection: state.selection.map(changeSet),
|
||||
effects: helixEffect.of(settle),
|
||||
scrollIntoView: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** r{char}: replace every selected character with `char`. */
|
||||
export function replaceChar(view: EditorView, char: string) {
|
||||
if (char.length !== 1) return;
|
||||
const { state } = view;
|
||||
const changes = state.selection.ranges
|
||||
.map((r) => {
|
||||
const from = r.from;
|
||||
const to = r.empty ? Math.min(r.to + 1, state.doc.length) : r.to;
|
||||
const text = state.doc.sliceString(from, to).replace(/[^\n]/g, char);
|
||||
return { from, to, insert: text };
|
||||
})
|
||||
.filter((c) => c.to > c.from);
|
||||
if (!changes.length) return;
|
||||
view.dispatch({ changes, effects: helixEffect.of(settle) });
|
||||
}
|
||||
|
||||
type CaseMode = "toggle" | "lower" | "upper";
|
||||
|
||||
export function changeCase(view: EditorView, mode: CaseMode) {
|
||||
const { state } = view;
|
||||
const changes = state.selection.ranges
|
||||
.map((r) => {
|
||||
const from = r.from;
|
||||
const to = r.empty ? Math.min(r.to + 1, state.doc.length) : r.to;
|
||||
const text = state.doc.sliceString(from, to);
|
||||
const next =
|
||||
mode === "lower"
|
||||
? text.toLowerCase()
|
||||
: mode === "upper"
|
||||
? text.toUpperCase()
|
||||
: text.replace(/[a-zA-Z]/g, (c) => (c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase()));
|
||||
return { from, to, insert: next };
|
||||
})
|
||||
.filter((c) => c.to > c.from);
|
||||
if (!changes.length) return;
|
||||
view.dispatch({ changes, effects: helixEffect.of(settle) });
|
||||
}
|
||||
|
||||
/** J: join the lines spanned by each selection into one. */
|
||||
export function joinLines(view: EditorView) {
|
||||
const { state } = view;
|
||||
const changes: ChangeSpec[] = [];
|
||||
for (const r of state.selection.ranges) {
|
||||
const startLine = state.doc.lineAt(r.from).number;
|
||||
const endLine = state.doc.lineAt(r.to).number;
|
||||
const last = Math.max(endLine, startLine + 1);
|
||||
for (let n = startLine; n < last && n < state.doc.lines; n++) {
|
||||
const line = state.doc.line(n);
|
||||
const nextLine = state.doc.line(n + 1);
|
||||
const trimmed = nextLine.text.match(/^\s*/)?.[0].length ?? 0;
|
||||
changes.push({ from: line.to, to: nextLine.from + trimmed, insert: " " });
|
||||
}
|
||||
}
|
||||
if (!changes.length) return;
|
||||
view.dispatch({ changes, effects: helixEffect.of(settle), scrollIntoView: true });
|
||||
}
|
||||
|
||||
export function indent(view: EditorView, less: boolean) {
|
||||
(less ? indentLess : indentMore)(view);
|
||||
view.dispatch({ effects: helixEffect.of(settle) });
|
||||
}
|
||||
|
||||
export function undoCmd(view: EditorView) {
|
||||
undo(view);
|
||||
}
|
||||
export function redoCmd(view: EditorView) {
|
||||
redo(view);
|
||||
}
|
||||
|
||||
// --- selection manipulation --------------------------------------------------
|
||||
|
||||
function setSelection(view: EditorView, ranges: SelectionRange[], mainIndex?: number) {
|
||||
if (!ranges.length) return;
|
||||
view.dispatch({
|
||||
selection: EditorSelection.create(ranges, mainIndex ?? ranges.length - 1),
|
||||
scrollIntoView: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function collapseToCursor(view: EditorView) {
|
||||
setSelection(
|
||||
view,
|
||||
view.state.selection.ranges.map((r) => EditorSelection.cursor(r.head)),
|
||||
view.state.selection.mainIndex,
|
||||
);
|
||||
}
|
||||
|
||||
export function flipSelections(view: EditorView) {
|
||||
setSelection(
|
||||
view,
|
||||
view.state.selection.ranges.map((r) => EditorSelection.range(r.head, r.anchor)),
|
||||
view.state.selection.mainIndex,
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureForward(view: EditorView) {
|
||||
setSelection(
|
||||
view,
|
||||
view.state.selection.ranges.map((r) => EditorSelection.range(r.from, r.to)),
|
||||
view.state.selection.mainIndex,
|
||||
);
|
||||
}
|
||||
|
||||
export function keepPrimary(view: EditorView) {
|
||||
const main = view.state.selection.main;
|
||||
setSelection(view, [main], 0);
|
||||
}
|
||||
|
||||
export function removePrimary(view: EditorView) {
|
||||
const { ranges, mainIndex } = view.state.selection;
|
||||
if (ranges.length < 2) return;
|
||||
const remaining = ranges.filter((_, i) => i !== mainIndex);
|
||||
setSelection(view, remaining, Math.min(mainIndex, remaining.length - 1));
|
||||
}
|
||||
|
||||
export function rotatePrimary(view: EditorView, forward: boolean) {
|
||||
const { ranges, mainIndex } = view.state.selection;
|
||||
if (ranges.length < 2) return;
|
||||
const next = (mainIndex + (forward ? 1 : -1) + ranges.length) % ranges.length;
|
||||
setSelection(view, [...ranges], next);
|
||||
}
|
||||
|
||||
export function selectAll(view: EditorView) {
|
||||
setSelection(view, [EditorSelection.range(0, view.state.doc.length)], 0);
|
||||
}
|
||||
|
||||
export function trimSelections(view: EditorView) {
|
||||
const { state } = view;
|
||||
const ranges: SelectionRange[] = [];
|
||||
for (const r of state.selection.ranges) {
|
||||
const text = state.doc.sliceString(r.from, r.to);
|
||||
const lead = text.match(/^\s*/)?.[0].length ?? 0;
|
||||
const trail = text.match(/\s*$/)?.[0].length ?? 0;
|
||||
const from = r.from + lead;
|
||||
const to = Math.max(from, r.to - trail);
|
||||
ranges.push(EditorSelection.range(from, to));
|
||||
}
|
||||
setSelection(view, ranges, state.selection.mainIndex);
|
||||
}
|
||||
|
||||
/** x: extend each selection to whole line(s); repeat grows downward. */
|
||||
export function selectLine(view: EditorView, count: number) {
|
||||
const { state } = view;
|
||||
const ranges = state.selection.ranges.map((r) => {
|
||||
const startLine = state.doc.lineAt(r.from);
|
||||
let endLine = state.doc.lineAt(r.to);
|
||||
const from = startLine.from;
|
||||
let to = Math.min(endLine.to + 1, state.doc.length);
|
||||
const whole = r.from === from && r.to === to;
|
||||
const steps = whole ? Math.max(1, count) : Math.max(0, count - 1);
|
||||
for (let i = 0; i < steps && endLine.number < state.doc.lines; i++) {
|
||||
endLine = state.doc.line(endLine.number + 1);
|
||||
to = Math.min(endLine.to + 1, state.doc.length);
|
||||
}
|
||||
return EditorSelection.range(from, to);
|
||||
});
|
||||
setSelection(view, ranges, state.selection.mainIndex);
|
||||
}
|
||||
|
||||
/** X: extend selections to cover full lines without crossing into the next. */
|
||||
export function extendToLineBounds(view: EditorView) {
|
||||
const { state } = view;
|
||||
const ranges = state.selection.ranges.map((r) =>
|
||||
EditorSelection.range(state.doc.lineAt(r.from).from, state.doc.lineAt(r.to).to),
|
||||
);
|
||||
setSelection(view, ranges, state.selection.mainIndex);
|
||||
}
|
||||
|
||||
/** C / Alt-C: copy each selection onto the next/previous line at the same columns. */
|
||||
export function copySelectionToLine(view: EditorView, below: boolean) {
|
||||
const { state } = view;
|
||||
const additions: SelectionRange[] = [];
|
||||
for (const r of state.selection.ranges) {
|
||||
const anchorLine = state.doc.lineAt(r.anchor);
|
||||
const headLine = state.doc.lineAt(r.head);
|
||||
const anchorCol = r.anchor - anchorLine.from;
|
||||
const headCol = r.head - headLine.from;
|
||||
const targetAnchorNo = anchorLine.number + (below ? 1 : -1);
|
||||
const targetHeadNo = headLine.number + (below ? 1 : -1);
|
||||
if (targetAnchorNo < 1 || targetAnchorNo > state.doc.lines) continue;
|
||||
if (targetHeadNo < 1 || targetHeadNo > state.doc.lines) continue;
|
||||
const ta = state.doc.line(targetAnchorNo);
|
||||
const th = state.doc.line(targetHeadNo);
|
||||
additions.push(
|
||||
EditorSelection.range(
|
||||
Math.min(ta.from + anchorCol, ta.to),
|
||||
Math.min(th.from + headCol, th.to),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!additions.length) return;
|
||||
const all = [...state.selection.ranges, ...additions];
|
||||
setSelection(view, all, all.length - 1);
|
||||
}
|
||||
|
||||
function restoreBase(view: EditorView, base: EditorSelection) {
|
||||
view.dispatch({ selection: base, scrollIntoView: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* s: keep only the regex matches found within each selection of `base`.
|
||||
* `base` is the selection captured when the prompt opened, so this can be
|
||||
* called live on every keystroke without compounding.
|
||||
*/
|
||||
export function selectRegexInSelections(
|
||||
view: EditorView,
|
||||
pattern: string,
|
||||
base: EditorSelection = view.state.selection,
|
||||
) {
|
||||
if (!pattern) return restoreBase(view, base);
|
||||
let re: RegExp;
|
||||
try {
|
||||
re = new RegExp(pattern, "g");
|
||||
} catch {
|
||||
return; // incomplete/invalid regex: leave the last good preview in place
|
||||
}
|
||||
const { state } = view;
|
||||
const ranges: SelectionRange[] = [];
|
||||
for (const r of base.ranges) {
|
||||
const text = state.doc.sliceString(r.from, r.to);
|
||||
for (const m of text.matchAll(re)) {
|
||||
const from = r.from + (m.index ?? 0);
|
||||
const to = from + m[0].length;
|
||||
ranges.push(EditorSelection.range(from, Math.max(from, to)));
|
||||
if (m[0].length === 0) re.lastIndex++;
|
||||
}
|
||||
}
|
||||
if (ranges.length) setSelection(view, ranges, ranges.length - 1);
|
||||
else restoreBase(view, base);
|
||||
}
|
||||
|
||||
/** S: split each selection of `base` on the regex, keeping the pieces between. */
|
||||
export function splitOnRegex(
|
||||
view: EditorView,
|
||||
pattern: string,
|
||||
base: EditorSelection = view.state.selection,
|
||||
) {
|
||||
if (!pattern) return restoreBase(view, base);
|
||||
let re: RegExp;
|
||||
try {
|
||||
re = new RegExp(pattern, "g");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { state } = view;
|
||||
const ranges: SelectionRange[] = [];
|
||||
for (const r of base.ranges) {
|
||||
const text = state.doc.sliceString(r.from, r.to);
|
||||
let last = 0;
|
||||
for (const m of text.matchAll(re)) {
|
||||
const idx = m.index ?? 0;
|
||||
ranges.push(EditorSelection.range(r.from + last, r.from + idx));
|
||||
last = idx + m[0].length;
|
||||
if (m[0].length === 0) re.lastIndex++;
|
||||
}
|
||||
ranges.push(EditorSelection.range(r.from + last, r.to));
|
||||
}
|
||||
const filtered = ranges.filter((r) => r.to >= r.from);
|
||||
if (filtered.length) setSelection(view, filtered, filtered.length - 1);
|
||||
else restoreBase(view, base);
|
||||
}
|
||||
|
||||
/** Keep (or remove) selections of `base` whose text matches the regex. */
|
||||
export function filterSelections(
|
||||
view: EditorView,
|
||||
pattern: string,
|
||||
keep: boolean,
|
||||
base: EditorSelection = view.state.selection,
|
||||
) {
|
||||
if (!pattern) return restoreBase(view, base);
|
||||
let re: RegExp;
|
||||
try {
|
||||
re = new RegExp(pattern);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { state } = view;
|
||||
const ranges = base.ranges.filter((r) => {
|
||||
const matches = re.test(state.doc.sliceString(r.from, r.to));
|
||||
return keep ? matches : !matches;
|
||||
});
|
||||
if (ranges.length) setSelection(view, ranges, ranges.length - 1);
|
||||
else restoreBase(view, base);
|
||||
}
|
||||
|
||||
// --- surround ----------------------------------------------------------------
|
||||
|
||||
const SURROUND_PAIRS: Record<string, [string, string]> = {
|
||||
"(": ["(", ")"],
|
||||
")": ["(", ")"],
|
||||
"[": ["[", "]"],
|
||||
"]": ["[", "]"],
|
||||
"{": ["{", "}"],
|
||||
"}": ["{", "}"],
|
||||
"<": ["<", ">"],
|
||||
">": ["<", ">"],
|
||||
};
|
||||
|
||||
function surroundPair(ch: string): [string, string] {
|
||||
return SURROUND_PAIRS[ch] ?? [ch, ch];
|
||||
}
|
||||
|
||||
/** ms{char}: wrap each selection in the chosen pair. */
|
||||
export function surroundAdd(view: EditorView, ch: string) {
|
||||
const [open, close] = surroundPair(ch);
|
||||
const { state } = view;
|
||||
const changes: ChangeSpec[] = [];
|
||||
for (const r of state.selection.ranges) {
|
||||
changes.push({ from: r.from, insert: open });
|
||||
changes.push({ from: r.to, insert: close });
|
||||
}
|
||||
const changeSet = state.changes(changes);
|
||||
view.dispatch({
|
||||
changes: changeSet,
|
||||
selection: state.selection.map(changeSet),
|
||||
effects: helixEffect.of(settle),
|
||||
});
|
||||
}
|
||||
|
||||
/** md{char}: remove the surrounding pair around each selection. */
|
||||
export function surroundDelete(view: EditorView, ch: string) {
|
||||
const [open, close] = surroundPair(ch);
|
||||
const { state } = view;
|
||||
const changes: { from: number; to: number; insert: string }[] = [];
|
||||
for (const r of state.selection.ranges) {
|
||||
const openPos = M.findOpen(state, r.head, open, close);
|
||||
const closePos = openPos === null ? null : M.findClose(state, openPos, open, close);
|
||||
if (openPos === null || closePos === null) continue;
|
||||
changes.push({ from: openPos, to: openPos + 1, insert: "" });
|
||||
changes.push({ from: closePos, to: closePos + 1, insert: "" });
|
||||
}
|
||||
if (!changes.length) return;
|
||||
view.dispatch({ changes, effects: helixEffect.of(settle) });
|
||||
}
|
||||
|
||||
/** mr{from}{to}: replace the surrounding pair. */
|
||||
export function surroundReplace(view: EditorView, fromCh: string, toCh: string) {
|
||||
const [open, close] = surroundPair(fromCh);
|
||||
const [newOpen, newClose] = surroundPair(toCh);
|
||||
const { state } = view;
|
||||
const changes: { from: number; to: number; insert: string }[] = [];
|
||||
for (const r of state.selection.ranges) {
|
||||
const openPos = M.findOpen(state, r.head, open, close);
|
||||
const closePos = openPos === null ? null : M.findClose(state, openPos, open, close);
|
||||
if (openPos === null || closePos === null) continue;
|
||||
changes.push({ from: openPos, to: openPos + 1, insert: newOpen });
|
||||
changes.push({ from: closePos, to: closePos + 1, insert: newClose });
|
||||
}
|
||||
if (!changes.length) return;
|
||||
view.dispatch({ changes, effects: helixEffect.of(settle) });
|
||||
}
|
||||
|
||||
// --- search ------------------------------------------------------------------
|
||||
|
||||
export function runSearch(view: EditorView, query: string, reverse: boolean) {
|
||||
if (!query) return;
|
||||
view.dispatch({ effects: setSearchQuery.of(new SearchQuery({ search: query, regexp: true })) });
|
||||
(reverse ? findPrevious : findNext)(view);
|
||||
}
|
||||
|
||||
export function searchNext(view: EditorView, reverse: boolean) {
|
||||
(reverse ? findPrevious : findNext)(view);
|
||||
}
|
||||
|
||||
export function searchSelection(view: EditorView, reverse: boolean) {
|
||||
const text = view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to);
|
||||
if (!text) return;
|
||||
view.dispatch({
|
||||
effects: setSearchQuery.of(new SearchQuery({ search: text, regexp: false })),
|
||||
});
|
||||
(reverse ? findPrevious : findNext)(view);
|
||||
}
|
||||
|
||||
// --- view scrolling ----------------------------------------------------------
|
||||
|
||||
export function scrollTo(view: EditorView, y: "center" | "start" | "end") {
|
||||
const pos = view.state.selection.main.head;
|
||||
view.dispatch({ effects: EditorView.scrollIntoView(pos, { y }) });
|
||||
}
|
||||
|
||||
export function halfPage(view: EditorView, forward: boolean, extend: boolean) {
|
||||
const lineHeight = view.defaultLineHeight || 16;
|
||||
const lines = Math.max(1, Math.floor(view.dom.clientHeight / lineHeight / 2));
|
||||
moveVertical(view, forward, { extend, count: lines });
|
||||
}
|
||||
|
||||
export { restMode };
|
||||
51
packages/codemirror-helix/src/index.ts
Normal file
51
packages/codemirror-helix/src/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { EditorState, type Extension } from "@codemirror/state";
|
||||
import { search } from "@codemirror/search";
|
||||
import { helixState, registersField } from "./state";
|
||||
import { blockCursor, helixStatusPanel, helixTheme, modeEditorClass } from "./view";
|
||||
import { helixPrompt } from "./prompt";
|
||||
import { helixKeymap, type HelixOptions } from "./keymap";
|
||||
|
||||
export interface HelixConfig extends HelixOptions {
|
||||
/** Render the bottom status line. Default: true. */
|
||||
statusBar?: boolean;
|
||||
/** Start the editor in Insert mode instead of Normal. Default: false. */
|
||||
startInInsert?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helix-style modal editing for CodeMirror 6.
|
||||
*
|
||||
* Add to a CodeMirror instance's `extensions`. Requires a selection drawer
|
||||
* (CodeMirror's `basicSetup` or `drawSelection()`) for multi-cursor rendering.
|
||||
*
|
||||
* ```ts
|
||||
* import { basicSetup } from "codemirror";
|
||||
* import { helix } from "codemirror-helix";
|
||||
*
|
||||
* new EditorView({ extensions: [basicSetup, helix()], parent });
|
||||
* ```
|
||||
*/
|
||||
export function helix(config: HelixConfig = {}): Extension {
|
||||
return [
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
helixState.init(() => ({
|
||||
mode: config.startInInsert ? "insert" : "normal",
|
||||
pending: null,
|
||||
count: 0,
|
||||
register: null,
|
||||
lastFind: null,
|
||||
})),
|
||||
registersField,
|
||||
search(),
|
||||
helixKeymap(config),
|
||||
blockCursor,
|
||||
modeEditorClass,
|
||||
helixTheme,
|
||||
...(config.statusBar === false ? [] : [helixStatusPanel]),
|
||||
helixPrompt,
|
||||
];
|
||||
}
|
||||
|
||||
export { getMode, getHelix, helixState } from "./state";
|
||||
export type { HelixMode, HelixStateValue } from "./state";
|
||||
export type { HelixOptions } from "./keymap";
|
||||
562
packages/codemirror-helix/src/keymap.ts
Normal file
562
packages/codemirror-helix/src/keymap.ts
Normal file
@@ -0,0 +1,562 @@
|
||||
import { EditorSelection, Prec, type EditorState, type Extension } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import {
|
||||
clearPending,
|
||||
getHelix,
|
||||
helixEffect,
|
||||
type HelixStateValue,
|
||||
type Pending,
|
||||
} from "./state";
|
||||
import * as C from "./commands";
|
||||
import * as M from "./motions";
|
||||
import { textObject } from "./textobjects";
|
||||
import { openPrompt } from "./prompt";
|
||||
|
||||
export interface HelixOptions {
|
||||
/**
|
||||
* Return true to let a pressed Escape fall through to other handlers instead
|
||||
* of leaving Insert mode — e.g. when an autocompletion popup is open.
|
||||
*/
|
||||
escapeGuard?: (state: EditorState) => boolean;
|
||||
}
|
||||
|
||||
function reset(view: EditorView, extra: Partial<HelixStateValue> = {}) {
|
||||
view.dispatch({ effects: helixEffect.of({ ...clearPending(), ...extra }) });
|
||||
}
|
||||
|
||||
function setPending(view: EditorView, pending: Pending) {
|
||||
view.dispatch({ effects: helixEffect.of({ pending }) });
|
||||
}
|
||||
|
||||
// --- find char ---------------------------------------------------------------
|
||||
|
||||
function doFind(view: EditorView, kind: "f" | "t" | "F" | "T", char: string, helix: HelixStateValue) {
|
||||
const fn: C.PointFn = (state, pos) => {
|
||||
const found = M.findCharOnLine(state, pos, char, kind);
|
||||
return found === null ? pos : found;
|
||||
};
|
||||
C.moveByPoint(view, fn, { extend: helix.mode === "select", span: false, count: helix.count || 1 });
|
||||
}
|
||||
|
||||
// --- goto mode ---------------------------------------------------------------
|
||||
|
||||
function gotoAbsolute(view: EditorView, pos: number, select: boolean) {
|
||||
const anchor = select ? view.state.selection.main.anchor : pos;
|
||||
view.dispatch({ selection: EditorSelection.range(anchor, pos), scrollIntoView: true });
|
||||
}
|
||||
|
||||
function doGoto(view: EditorView, key: string, helix: HelixStateValue) {
|
||||
const select = helix.mode === "select";
|
||||
const { state } = view;
|
||||
switch (key) {
|
||||
case "h":
|
||||
C.moveByPoint(view, (s, p) => s.doc.lineAt(p).from, { extend: select, span: false, count: 1 });
|
||||
break;
|
||||
case "l":
|
||||
C.moveByPoint(view, (s, p) => s.doc.lineAt(p).to, { extend: select, span: false, count: 1 });
|
||||
break;
|
||||
case "s":
|
||||
C.moveByPoint(view, (s, p) => M.firstNonBlank(s, s.doc.lineAt(p).number), {
|
||||
extend: select,
|
||||
span: false,
|
||||
count: 1,
|
||||
});
|
||||
break;
|
||||
case "g":
|
||||
gotoAbsolute(view, helix.count > 0 ? state.doc.line(Math.min(helix.count, state.doc.lines)).from : 0, select);
|
||||
break;
|
||||
case "e":
|
||||
gotoAbsolute(view, state.doc.length, select);
|
||||
break;
|
||||
case "t":
|
||||
gotoAbsolute(view, state.doc.lineAt(view.viewport.from).from, select);
|
||||
break;
|
||||
case "b":
|
||||
gotoAbsolute(view, state.doc.lineAt(view.viewport.to).from, select);
|
||||
break;
|
||||
case "c":
|
||||
gotoAbsolute(view, state.doc.lineAt(Math.floor((view.viewport.from + view.viewport.to) / 2)).from, select);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- match mode --------------------------------------------------------------
|
||||
|
||||
function doMatch(view: EditorView, key: string, helix: HelixStateValue) {
|
||||
switch (key) {
|
||||
case "m":
|
||||
C.moveByPoint(
|
||||
view,
|
||||
(s, p) => {
|
||||
const m = M.matchingBracket(s, p);
|
||||
return m === null ? p : m;
|
||||
},
|
||||
{ extend: helix.mode === "select", span: false, count: 1 },
|
||||
);
|
||||
reset(view);
|
||||
return;
|
||||
case "i":
|
||||
setPending(view, { type: "textobject", around: false });
|
||||
return;
|
||||
case "a":
|
||||
setPending(view, { type: "textobject", around: true });
|
||||
return;
|
||||
case "s":
|
||||
setPending(view, { type: "surround-add" });
|
||||
return;
|
||||
case "d":
|
||||
setPending(view, { type: "surround-delete" });
|
||||
return;
|
||||
case "r":
|
||||
setPending(view, { type: "surround-replace" });
|
||||
return;
|
||||
default:
|
||||
reset(view);
|
||||
}
|
||||
}
|
||||
|
||||
function doTextObject(view: EditorView, key: string, around: boolean) {
|
||||
const { state } = view;
|
||||
const ranges = state.selection.ranges.map((r) => {
|
||||
const span = textObject(state, r, key, around);
|
||||
return span ? EditorSelection.range(span.from, span.to) : r;
|
||||
});
|
||||
view.dispatch({
|
||||
selection: EditorSelection.create(ranges, state.selection.mainIndex),
|
||||
scrollIntoView: true,
|
||||
});
|
||||
}
|
||||
|
||||
// --- view mode (z) -----------------------------------------------------------
|
||||
|
||||
function doView(view: EditorView, key: string) {
|
||||
if (key === "z") C.scrollTo(view, "center");
|
||||
else if (key === "t") C.scrollTo(view, "start");
|
||||
else if (key === "b") C.scrollTo(view, "end");
|
||||
}
|
||||
|
||||
// --- space menu (clipboard subset) ------------------------------------------
|
||||
|
||||
function doSpace(view: EditorView, key: string) {
|
||||
const text = view.state.sliceDoc(view.state.selection.main.from, view.state.selection.main.to);
|
||||
if (key === "y") {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard && text) {
|
||||
void navigator.clipboard.writeText(text).catch(() => {});
|
||||
}
|
||||
} else if (key === "p" || key === "P") {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
void navigator.clipboard.readText().then((clip) => {
|
||||
if (!clip) return;
|
||||
const changes = view.state.selection.ranges.map((r) => {
|
||||
const at = key === "P" ? r.from : r.to;
|
||||
return { from: at, to: at, insert: clip };
|
||||
});
|
||||
const set = view.state.changes(changes);
|
||||
view.dispatch({ changes: set, selection: view.state.selection.map(set) });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- pending dispatch --------------------------------------------------------
|
||||
|
||||
function handlePending(view: EditorView, pending: Pending, key: string, helix: HelixStateValue) {
|
||||
switch (pending.type) {
|
||||
case "register":
|
||||
if (key.length === 1) view.dispatch({ effects: helixEffect.of({ register: key, pending: null }) });
|
||||
else reset(view);
|
||||
return;
|
||||
case "find":
|
||||
if (key.length === 1) {
|
||||
doFind(view, pending.kind, key, helix);
|
||||
reset(view, { lastFind: { kind: pending.kind, char: key } });
|
||||
} else reset(view);
|
||||
return;
|
||||
case "replace":
|
||||
if (key.length === 1) C.replaceChar(view, key);
|
||||
reset(view);
|
||||
return;
|
||||
case "g":
|
||||
doGoto(view, key, helix);
|
||||
reset(view);
|
||||
return;
|
||||
case "z":
|
||||
doView(view, key);
|
||||
reset(view);
|
||||
return;
|
||||
case "space":
|
||||
doSpace(view, key);
|
||||
reset(view);
|
||||
return;
|
||||
case "m":
|
||||
doMatch(view, key, helix);
|
||||
return;
|
||||
case "textobject":
|
||||
doTextObject(view, key, pending.around);
|
||||
reset(view);
|
||||
return;
|
||||
case "surround-add":
|
||||
if (key.length === 1) C.surroundAdd(view, key);
|
||||
reset(view);
|
||||
return;
|
||||
case "surround-delete":
|
||||
if (key.length === 1) C.surroundDelete(view, key);
|
||||
reset(view);
|
||||
return;
|
||||
case "surround-replace":
|
||||
if (pending.from === undefined) {
|
||||
view.dispatch({ effects: helixEffect.of({ pending: { type: "surround-replace", from: key } }) });
|
||||
} else {
|
||||
C.surroundReplace(view, pending.from, key);
|
||||
reset(view);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// --- terminal commands -------------------------------------------------------
|
||||
|
||||
function handleAlt(view: EditorView, key: string, helix: HelixStateValue): boolean {
|
||||
switch (key) {
|
||||
case ";":
|
||||
C.flipSelections(view);
|
||||
return true;
|
||||
case ":":
|
||||
C.ensureForward(view);
|
||||
return true;
|
||||
case ",":
|
||||
C.removePrimary(view);
|
||||
return true;
|
||||
case "c":
|
||||
case "C":
|
||||
C.copySelectionToLine(view, false);
|
||||
return true;
|
||||
case "`":
|
||||
C.changeCase(view, "upper");
|
||||
return true;
|
||||
case ".":
|
||||
if (helix.lastFind) doFind(view, helix.lastFind.kind, helix.lastFind.char, helix);
|
||||
return true;
|
||||
case "k":
|
||||
case "K": {
|
||||
const base = view.state.selection;
|
||||
openPrompt(view, {
|
||||
label: "remove:",
|
||||
onChange: (p) => C.filterSelections(view, p, false, base),
|
||||
onSubmit: (p) => C.filterSelections(view, p, false, base),
|
||||
onCancel: () => view.dispatch({ selection: base }),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommand(view: EditorView, key: string, event: KeyboardEvent, helix: HelixStateValue): boolean {
|
||||
const extend = helix.mode === "select";
|
||||
const count = helix.count || 1;
|
||||
|
||||
if (event.altKey) return handleAlt(view, key, helix);
|
||||
|
||||
switch (key) {
|
||||
// movement
|
||||
case "h":
|
||||
case "ArrowLeft":
|
||||
C.moveByPoint(view, (_s, p) => Math.max(0, p - 1), { extend, span: false, count });
|
||||
return true;
|
||||
case "l":
|
||||
case "ArrowRight":
|
||||
C.moveByPoint(view, (s, p) => Math.min(s.doc.length, p + 1), { extend, span: false, count });
|
||||
return true;
|
||||
case "j":
|
||||
case "ArrowDown":
|
||||
C.moveVertical(view, true, { extend, count });
|
||||
return true;
|
||||
case "k":
|
||||
case "ArrowUp":
|
||||
C.moveVertical(view, false, { extend, count });
|
||||
return true;
|
||||
case "w":
|
||||
C.moveByPoint(view, (s, p) => M.nextWordStart(s, p, false), { extend, span: true, count });
|
||||
return true;
|
||||
case "W":
|
||||
C.moveByPoint(view, (s, p) => M.nextWordStart(s, p, true), { extend, span: true, count });
|
||||
return true;
|
||||
case "b":
|
||||
C.moveByPoint(view, (s, p) => M.prevWordStart(s, p, false), { extend, span: true, count });
|
||||
return true;
|
||||
case "B":
|
||||
C.moveByPoint(view, (s, p) => M.prevWordStart(s, p, true), { extend, span: true, count });
|
||||
return true;
|
||||
case "e":
|
||||
C.moveByPoint(view, (s, p) => M.nextWordEnd(s, p, false), { extend, span: true, count });
|
||||
return true;
|
||||
case "E":
|
||||
C.moveByPoint(view, (s, p) => M.nextWordEnd(s, p, true), { extend, span: true, count });
|
||||
return true;
|
||||
case "Home":
|
||||
C.moveByPoint(view, (s, p) => s.doc.lineAt(p).from, { extend, span: false, count: 1 });
|
||||
return true;
|
||||
case "End":
|
||||
C.moveByPoint(view, (s, p) => s.doc.lineAt(p).to, { extend, span: false, count: 1 });
|
||||
return true;
|
||||
|
||||
// selection manipulation
|
||||
case "x":
|
||||
C.selectLine(view, count);
|
||||
return true;
|
||||
case "X":
|
||||
C.extendToLineBounds(view);
|
||||
return true;
|
||||
case "%":
|
||||
C.selectAll(view);
|
||||
return true;
|
||||
case ";":
|
||||
C.collapseToCursor(view);
|
||||
return true;
|
||||
case ",":
|
||||
C.keepPrimary(view);
|
||||
return true;
|
||||
case "(":
|
||||
C.rotatePrimary(view, false);
|
||||
return true;
|
||||
case ")":
|
||||
C.rotatePrimary(view, true);
|
||||
return true;
|
||||
case "_":
|
||||
C.trimSelections(view);
|
||||
return true;
|
||||
case "C":
|
||||
C.copySelectionToLine(view, true);
|
||||
return true;
|
||||
|
||||
// mode switches
|
||||
case "v":
|
||||
view.dispatch({ effects: helixEffect.of({ mode: extend ? "normal" : "select" }) });
|
||||
return true;
|
||||
case "i":
|
||||
C.enterInsert(view, (_s, r) => r.from);
|
||||
return true;
|
||||
case "a":
|
||||
C.enterInsert(view, (_s, r) => r.to);
|
||||
return true;
|
||||
case "I":
|
||||
C.enterInsert(view, (s, r) => M.firstNonBlank(s, s.doc.lineAt(r.head).number));
|
||||
return true;
|
||||
case "A":
|
||||
C.enterInsert(view, (s, r) => s.doc.lineAt(r.head).to);
|
||||
return true;
|
||||
case "o":
|
||||
C.openLine(view, false);
|
||||
return true;
|
||||
case "O":
|
||||
C.openLine(view, true);
|
||||
return true;
|
||||
|
||||
// edits
|
||||
case "d":
|
||||
case "Delete":
|
||||
C.deleteSelection(view);
|
||||
return true;
|
||||
case "c":
|
||||
C.deleteSelection(view, { insert: true });
|
||||
return true;
|
||||
case "y":
|
||||
C.yankSelection(view);
|
||||
return true;
|
||||
case "p":
|
||||
C.paste(view, false);
|
||||
return true;
|
||||
case "P":
|
||||
C.paste(view, true);
|
||||
return true;
|
||||
case "R":
|
||||
C.replaceWithYank(view);
|
||||
return true;
|
||||
case "~":
|
||||
C.changeCase(view, "toggle");
|
||||
return true;
|
||||
case "`":
|
||||
C.changeCase(view, "lower");
|
||||
return true;
|
||||
case "J":
|
||||
C.joinLines(view);
|
||||
return true;
|
||||
case ">":
|
||||
C.indent(view, false);
|
||||
return true;
|
||||
case "<":
|
||||
C.indent(view, true);
|
||||
return true;
|
||||
case "u":
|
||||
C.undoCmd(view);
|
||||
return true;
|
||||
case "U":
|
||||
C.redoCmd(view);
|
||||
return true;
|
||||
|
||||
// search
|
||||
case "/":
|
||||
openPrompt(view, { label: "/", onSubmit: (q) => C.runSearch(view, q, false) });
|
||||
return true;
|
||||
case "?":
|
||||
openPrompt(view, { label: "?", onSubmit: (q) => C.runSearch(view, q, true) });
|
||||
return true;
|
||||
case "n":
|
||||
C.searchNext(view, false);
|
||||
return true;
|
||||
case "N":
|
||||
C.searchNext(view, true);
|
||||
return true;
|
||||
case "*":
|
||||
C.searchSelection(view, false);
|
||||
return true;
|
||||
|
||||
// regex selection (live preview against the selection captured here)
|
||||
case "s": {
|
||||
const base = view.state.selection;
|
||||
openPrompt(view, {
|
||||
label: "select:",
|
||||
onChange: (p) => C.selectRegexInSelections(view, p, base),
|
||||
onSubmit: (p) => C.selectRegexInSelections(view, p, base),
|
||||
onCancel: () => view.dispatch({ selection: base }),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "S": {
|
||||
const base = view.state.selection;
|
||||
openPrompt(view, {
|
||||
label: "split:",
|
||||
onChange: (p) => C.splitOnRegex(view, p, base),
|
||||
onSubmit: (p) => C.splitOnRegex(view, p, base),
|
||||
onCancel: () => view.dispatch({ selection: base }),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "K": {
|
||||
const base = view.state.selection;
|
||||
openPrompt(view, {
|
||||
label: "keep:",
|
||||
onChange: (p) => C.filterSelections(view, p, true, base),
|
||||
onSubmit: (p) => C.filterSelections(view, p, true, base),
|
||||
onCancel: () => view.dispatch({ selection: base }),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
case "Escape":
|
||||
C.collapseToCursor(view);
|
||||
view.dispatch({ effects: helixEffect.of({ mode: "normal", ...clearPending() }) });
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCtrl(view: EditorView, key: string, helix: HelixStateValue): boolean {
|
||||
const extend = helix.mode === "select";
|
||||
switch (key) {
|
||||
case "d":
|
||||
C.halfPage(view, true, extend);
|
||||
return true;
|
||||
case "u":
|
||||
C.halfPage(view, false, extend);
|
||||
return true;
|
||||
case "f":
|
||||
C.moveVertical(view, true, { extend, count: 20 });
|
||||
return true;
|
||||
case "b":
|
||||
C.moveVertical(view, false, { extend, count: 20 });
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- top-level keydown -------------------------------------------------------
|
||||
|
||||
export function helixKeymap(options: HelixOptions = {}): Extension {
|
||||
return Prec.highest(
|
||||
EditorView.domEventHandlers({
|
||||
keydown(event, view) {
|
||||
if (event.isComposing) return false;
|
||||
const helix = getHelix(view.state);
|
||||
|
||||
if (helix.mode === "insert") {
|
||||
if (event.key === "Escape") {
|
||||
if (options.escapeGuard?.(view.state)) return false;
|
||||
view.dispatch({ effects: helixEffect.of({ mode: "normal", ...clearPending() }) });
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.metaKey) return false;
|
||||
|
||||
const key = event.key;
|
||||
|
||||
if (helix.pending) {
|
||||
event.preventDefault();
|
||||
handlePending(view, helix.pending, key, helix);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.ctrlKey) {
|
||||
if (handleCtrl(view, key, helix)) {
|
||||
event.preventDefault();
|
||||
reset(view);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// count digits (0 only continues an existing count)
|
||||
if (!event.altKey && /^[0-9]$/.test(key) && !(key === "0" && helix.count === 0)) {
|
||||
event.preventDefault();
|
||||
view.dispatch({ effects: helixEffect.of({ count: helix.count * 10 + Number(key) }) });
|
||||
return true;
|
||||
}
|
||||
|
||||
// prefixes awaiting another key
|
||||
if (!event.altKey) {
|
||||
const prefix: Record<string, Pending> = {
|
||||
g: { type: "g" },
|
||||
m: { type: "m" },
|
||||
z: { type: "z" },
|
||||
" ": { type: "space" },
|
||||
'"': { type: "register" },
|
||||
f: { type: "find", kind: "f" },
|
||||
t: { type: "find", kind: "t" },
|
||||
F: { type: "find", kind: "F" },
|
||||
T: { type: "find", kind: "T" },
|
||||
r: { type: "replace" },
|
||||
};
|
||||
const pending = prefix[key];
|
||||
if (pending) {
|
||||
setPending(view, pending);
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (handleCommand(view, key, event, helix)) {
|
||||
event.preventDefault();
|
||||
reset(view);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Swallow stray printable keys so Normal/Select mode never types.
|
||||
if (key.length === 1 || ["Enter", "Backspace", "Tab", "Delete"].includes(key)) {
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
180
packages/codemirror-helix/src/motions.ts
Normal file
180
packages/codemirror-helix/src/motions.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import type { EditorState } from "@codemirror/state";
|
||||
|
||||
export type CharClass = "space" | "word" | "punct";
|
||||
|
||||
export function charAt(state: EditorState, i: number): string {
|
||||
return i >= 0 && i < state.doc.length ? state.doc.sliceString(i, i + 1) : "";
|
||||
}
|
||||
|
||||
export function classOf(ch: string): CharClass {
|
||||
if (ch === "" || /\s/.test(ch)) return "space";
|
||||
if (/[\p{L}\p{N}_]/u.test(ch)) return "word";
|
||||
return "punct";
|
||||
}
|
||||
|
||||
// For WORD (big) motions only whitespace separates tokens.
|
||||
function bigClassOf(ch: string): CharClass {
|
||||
return ch === "" || /\s/.test(ch) ? "space" : "word";
|
||||
}
|
||||
|
||||
function classAt(state: EditorState, i: number, big: boolean): CharClass {
|
||||
const ch = charAt(state, i);
|
||||
return big ? bigClassOf(ch) : classOf(ch);
|
||||
}
|
||||
|
||||
/** Helix `w`/`W`: move to the start of the next word. */
|
||||
export function nextWordStart(state: EditorState, pos: number, big: boolean): number {
|
||||
const len = state.doc.length;
|
||||
let i = pos;
|
||||
if (i >= len) return len;
|
||||
if (classAt(state, i, big) === "space") {
|
||||
while (i < len && classAt(state, i, big) === "space") i++;
|
||||
} else {
|
||||
const cls = classAt(state, i, big);
|
||||
while (i < len && classAt(state, i, big) === cls) i++;
|
||||
while (i < len && classAt(state, i, big) === "space") i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Helix `b`/`B`: move to the start of the previous word. */
|
||||
export function prevWordStart(state: EditorState, pos: number, big: boolean): number {
|
||||
let i = pos;
|
||||
if (i <= 0) return 0;
|
||||
i--;
|
||||
while (i > 0 && classAt(state, i, big) === "space") i--;
|
||||
const cls = classAt(state, i, big);
|
||||
while (i > 0 && classAt(state, i - 1, big) === cls) i--;
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Helix `e`/`E`: move to the end of the next word. */
|
||||
export function nextWordEnd(state: EditorState, pos: number, big: boolean): number {
|
||||
const len = state.doc.length;
|
||||
let i = pos + 1;
|
||||
while (i < len && classAt(state, i, big) === "space") i++;
|
||||
const cls = classAt(state, i, big);
|
||||
while (i < len && classAt(state, i, big) === cls) i++;
|
||||
return Math.min(i, len);
|
||||
}
|
||||
|
||||
export function firstNonBlank(state: EditorState, line: number): number {
|
||||
const l = state.doc.line(line);
|
||||
return l.from + (l.text.match(/^\s*/)?.[0].length ?? 0);
|
||||
}
|
||||
|
||||
/** f/t/F/T: find `target` from `pos`. Returns null if not found on the line. */
|
||||
export function findCharOnLine(
|
||||
state: EditorState,
|
||||
pos: number,
|
||||
target: string,
|
||||
kind: "f" | "t" | "F" | "T",
|
||||
): number | null {
|
||||
const line = state.doc.lineAt(pos);
|
||||
const forward = kind === "f" || kind === "t";
|
||||
const till = kind === "t" || kind === "T";
|
||||
|
||||
if (forward) {
|
||||
let i = pos + 1;
|
||||
// `t` should skip an adjacent target so repeated presses advance.
|
||||
if (till && charAt(state, i) === target) i++;
|
||||
for (; i <= line.to; i++) {
|
||||
if (charAt(state, i) === target) return till ? i - 1 : i;
|
||||
}
|
||||
} else {
|
||||
let i = pos - 1;
|
||||
if (till && charAt(state, i) === target) i--;
|
||||
for (; i >= line.from; i--) {
|
||||
if (charAt(state, i) === target) return till ? i + 1 : i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const PAIRS: Record<string, { open: string; close: string }> = {
|
||||
"(": { open: "(", close: ")" },
|
||||
")": { open: "(", close: ")" },
|
||||
"[": { open: "[", close: "]" },
|
||||
"]": { open: "[", close: "]" },
|
||||
"{": { open: "{", close: "}" },
|
||||
"}": { open: "{", close: "}" },
|
||||
"<": { open: "<", close: ">" },
|
||||
">": { open: "<", close: ">" },
|
||||
};
|
||||
|
||||
export function pairFor(ch: string): { open: string; close: string } | null {
|
||||
return PAIRS[ch] ?? null;
|
||||
}
|
||||
|
||||
/** Scan forward for the close that matches an open at/after `pos` (nesting-aware). */
|
||||
export function findClose(state: EditorState, from: number, open: string, close: string): number | null {
|
||||
const len = state.doc.length;
|
||||
let depth = 0;
|
||||
for (let i = from; i < len; i++) {
|
||||
const ch = charAt(state, i);
|
||||
if (ch === open) depth++;
|
||||
else if (ch === close) {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Scan backward for the open that matches a close at/before `pos` (nesting-aware). */
|
||||
export function findOpen(state: EditorState, from: number, open: string, close: string): number | null {
|
||||
let depth = 0;
|
||||
for (let i = from; i >= 0; i--) {
|
||||
const ch = charAt(state, i);
|
||||
if (ch === close) depth++;
|
||||
else if (ch === open) {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `mm`: position of the bracket matching the one at/under `pos`, or null. */
|
||||
export function matchingBracket(state: EditorState, pos: number): number | null {
|
||||
for (const probe of [pos, pos - 1]) {
|
||||
const ch = charAt(state, probe);
|
||||
const pair = pairFor(ch);
|
||||
if (!pair) continue;
|
||||
if (ch === pair.open) return findClose2(state, probe, pair.open, pair.close);
|
||||
return findOpen2(state, probe, pair.open, pair.close);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findClose2(state: EditorState, openPos: number, open: string, close: string): number | null {
|
||||
const len = state.doc.length;
|
||||
let depth = 0;
|
||||
for (let i = openPos; i < len; i++) {
|
||||
const ch = charAt(state, i);
|
||||
if (ch === open) depth++;
|
||||
else if (ch === close && --depth === 0) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findOpen2(state: EditorState, closePos: number, open: string, close: string): number | null {
|
||||
let depth = 0;
|
||||
for (let i = closePos; i >= 0; i--) {
|
||||
const ch = charAt(state, i);
|
||||
if (ch === close) depth++;
|
||||
else if (ch === open && --depth === 0) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Paragraph bounds (blank-line delimited) containing `pos`. */
|
||||
export function paragraphAt(state: EditorState, pos: number): { from: number; to: number } {
|
||||
const { doc } = state;
|
||||
let startLine = doc.lineAt(pos).number;
|
||||
let endLine = startLine;
|
||||
const blank = (n: number) => doc.line(n).text.trim() === "";
|
||||
while (startLine > 1 && !blank(startLine - 1)) startLine--;
|
||||
while (endLine < doc.lines && !blank(endLine + 1)) endLine++;
|
||||
return { from: doc.line(startLine).from, to: doc.line(endLine).to };
|
||||
}
|
||||
83
packages/codemirror-helix/src/prompt.ts
Normal file
83
packages/codemirror-helix/src/prompt.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { StateEffect, StateField } from "@codemirror/state";
|
||||
import { EditorView, showPanel, type Panel } from "@codemirror/view";
|
||||
|
||||
export interface PromptConfig {
|
||||
label: string;
|
||||
initial?: string;
|
||||
onSubmit: (value: string, view: EditorView) => void;
|
||||
onChange?: (value: string, view: EditorView) => void;
|
||||
onCancel?: (view: EditorView) => void;
|
||||
}
|
||||
|
||||
const openPromptEffect = StateEffect.define<PromptConfig>();
|
||||
const closePromptEffect = StateEffect.define<null>();
|
||||
|
||||
const promptState = StateField.define<PromptConfig | null>({
|
||||
create: () => null,
|
||||
update(value, tr) {
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(openPromptEffect)) return effect.value;
|
||||
if (effect.is(closePromptEffect)) return null;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
export function openPrompt(view: EditorView, config: PromptConfig) {
|
||||
view.dispatch({ effects: openPromptEffect.of(config) });
|
||||
// Focus after the panel mounts.
|
||||
requestAnimationFrame(() => {
|
||||
const input = view.dom.querySelector<HTMLInputElement>(".cm-helix-prompt input");
|
||||
input?.focus();
|
||||
input?.select();
|
||||
});
|
||||
}
|
||||
|
||||
function closePrompt(view: EditorView) {
|
||||
view.dispatch({ effects: closePromptEffect.of(null) });
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function promptPanel(view: EditorView): Panel {
|
||||
const config = view.state.field(promptState);
|
||||
const dom = document.createElement("form");
|
||||
dom.className = "cm-helix-prompt";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = config?.label ?? "";
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.value = config?.initial ?? "";
|
||||
input.spellcheck = false;
|
||||
input.autocomplete = "off";
|
||||
|
||||
dom.append(label, input);
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
view.state.field(promptState)?.onChange?.(input.value, view);
|
||||
});
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
const current = view.state.field(promptState);
|
||||
closePrompt(view);
|
||||
current?.onSubmit(input.value, view);
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
const current = view.state.field(promptState);
|
||||
closePrompt(view);
|
||||
current?.onCancel?.(view);
|
||||
}
|
||||
});
|
||||
|
||||
return { dom, top: false };
|
||||
}
|
||||
|
||||
export const helixPrompt = [
|
||||
promptState,
|
||||
showPanel.from(promptState, (config) => (config ? promptPanel : null)),
|
||||
];
|
||||
|
||||
export function isPromptOpen(view: EditorView): boolean {
|
||||
return view.state.field(promptState) !== null;
|
||||
}
|
||||
87
packages/codemirror-helix/src/state.ts
Normal file
87
packages/codemirror-helix/src/state.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { StateEffect, StateField, type EditorState } from "@codemirror/state";
|
||||
|
||||
export type HelixMode = "normal" | "insert" | "select";
|
||||
|
||||
/** A multi-key prefix or a command waiting for one more keystroke. */
|
||||
export type Pending =
|
||||
| { type: "g" } // goto mode
|
||||
| { type: "m" } // match mode
|
||||
| { type: "z" } // view mode
|
||||
| { type: "space" } // space menu (minimal)
|
||||
| { type: "find"; kind: "f" | "t" | "F" | "T" } // awaiting target char
|
||||
| { type: "replace" } // r{char}
|
||||
| { type: "register" } // "{char}
|
||||
| { type: "textobject"; around: boolean } // mi{o} / ma{o}
|
||||
| { type: "surround-add" } // ms{char}
|
||||
| { type: "surround-delete" } // md{char}
|
||||
| { type: "surround-replace"; from?: string }; // mr{from}{to}
|
||||
|
||||
export interface LastFind {
|
||||
kind: "f" | "t" | "F" | "T";
|
||||
char: string;
|
||||
}
|
||||
|
||||
export interface HelixStateValue {
|
||||
mode: HelixMode;
|
||||
pending: Pending | null;
|
||||
count: number; // 0 = no explicit count
|
||||
register: string | null; // selected register for next yank/paste/delete
|
||||
lastFind: LastFind | null;
|
||||
}
|
||||
|
||||
const initial: HelixStateValue = {
|
||||
mode: "normal",
|
||||
pending: null,
|
||||
count: 0,
|
||||
register: null,
|
||||
lastFind: null,
|
||||
};
|
||||
|
||||
/** Patch the transient Helix state. */
|
||||
export const helixEffect = StateEffect.define<Partial<HelixStateValue>>();
|
||||
|
||||
export const helixState = StateField.define<HelixStateValue>({
|
||||
create: () => initial,
|
||||
update(value, tr) {
|
||||
let next = value;
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(helixEffect)) next = { ...next, ...effect.value };
|
||||
}
|
||||
return next;
|
||||
},
|
||||
});
|
||||
|
||||
export function getHelix(state: EditorState): HelixStateValue {
|
||||
return state.field(helixState);
|
||||
}
|
||||
|
||||
export function getMode(state: EditorState): HelixMode {
|
||||
return state.field(helixState).mode;
|
||||
}
|
||||
|
||||
// --- registers ---------------------------------------------------------------
|
||||
|
||||
export const setRegister = StateEffect.define<{ name: string; text: string }>();
|
||||
|
||||
export const registersField = StateField.define<Map<string, string>>({
|
||||
create: () => new Map(),
|
||||
update(value, tr) {
|
||||
let next = value;
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(setRegister)) {
|
||||
next = new Map(next);
|
||||
next.set(effect.value.name, effect.value.text);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
},
|
||||
});
|
||||
|
||||
export function readRegister(state: EditorState, name: string | null): string {
|
||||
return state.field(registersField).get(name ?? '"') ?? "";
|
||||
}
|
||||
|
||||
/** Clear any pending prefix / count / register selection. */
|
||||
export function clearPending(): Partial<HelixStateValue> {
|
||||
return { pending: null, count: 0, register: null };
|
||||
}
|
||||
127
packages/codemirror-helix/src/textobjects.ts
Normal file
127
packages/codemirror-helix/src/textobjects.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { EditorState, SelectionRange } from "@codemirror/state";
|
||||
import { charAt, classOf, findClose, findOpen, pairFor, paragraphAt } from "./motions";
|
||||
|
||||
export interface Span {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
function wordSpan(state: EditorState, pos: number, around: boolean): Span | null {
|
||||
const cls = classOf(charAt(state, pos));
|
||||
if (cls === "space") return null;
|
||||
let from = pos;
|
||||
let to = pos;
|
||||
while (from > 0 && classOf(charAt(state, from - 1)) === cls) from--;
|
||||
while (to < state.doc.length && classOf(charAt(state, to)) === cls) to++;
|
||||
if (around) {
|
||||
const start = to;
|
||||
while (to < state.doc.length && classOf(charAt(state, to)) === "space") to++;
|
||||
if (to === start) while (from > 0 && classOf(charAt(state, from - 1)) === "space") from--;
|
||||
}
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
function pairSpan(
|
||||
state: EditorState,
|
||||
pos: number,
|
||||
open: string,
|
||||
close: string,
|
||||
around: boolean,
|
||||
): Span | null {
|
||||
// If we sit on a bracket, anchor the search to it.
|
||||
const here = charAt(state, pos);
|
||||
const openPos =
|
||||
here === open ? pos : findOpen(state, here === close ? pos - 1 : pos, open, close);
|
||||
if (openPos === null) return null;
|
||||
const closePos = findClose(state, openPos, open, close);
|
||||
if (closePos === null) return null;
|
||||
return around ? { from: openPos, to: closePos + 1 } : { from: openPos + 1, to: closePos };
|
||||
}
|
||||
|
||||
function quoteSpan(state: EditorState, pos: number, quote: string, around: boolean): Span | null {
|
||||
const line = state.doc.lineAt(pos);
|
||||
const positions: number[] = [];
|
||||
for (let i = line.from; i <= line.to; i++) if (charAt(state, i) === quote) positions.push(i);
|
||||
for (let i = 0; i + 1 < positions.length; i += 2) {
|
||||
const a = positions[i]!;
|
||||
const b = positions[i + 1]!;
|
||||
if (pos >= a && pos <= b + 1) {
|
||||
return around ? { from: a, to: b + 1 } : { from: a + 1, to: b };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function paragraphSpan(state: EditorState, pos: number, around: boolean): Span {
|
||||
const span = paragraphAt(state, pos);
|
||||
if (!around) return span;
|
||||
let to = span.to;
|
||||
while (to < state.doc.length && state.doc.lineAt(to + 1).text.trim() === "") {
|
||||
to = state.doc.lineAt(to + 1).to;
|
||||
}
|
||||
return { from: span.from, to };
|
||||
}
|
||||
|
||||
const QUOTES = new Set(['"', "'", "`"]);
|
||||
|
||||
/** Innermost enclosing bracket/quote pair (Helix `mi m` / `ma m`). */
|
||||
function nearestPair(state: EditorState, pos: number, around: boolean): Span | null {
|
||||
const candidates: Span[] = [];
|
||||
for (const ch of ["(", "[", "{", "<"]) {
|
||||
const span = pairSpan(state, pos, ch, pairFor(ch)!.close, around);
|
||||
if (span) candidates.push(span);
|
||||
}
|
||||
for (const q of QUOTES) {
|
||||
const span = quoteSpan(state, pos, q, around);
|
||||
if (span) candidates.push(span);
|
||||
}
|
||||
if (!candidates.length) return null;
|
||||
// Smallest enclosing span wins.
|
||||
return candidates.sort((a, b) => b.from - a.from || a.to - b.to)[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Helix textobject for `mi{obj}` / `ma{obj}`.
|
||||
* Returns null when nothing sensible encloses the cursor.
|
||||
*/
|
||||
export function textObject(
|
||||
state: EditorState,
|
||||
range: SelectionRange,
|
||||
obj: string,
|
||||
around: boolean,
|
||||
): Span | null {
|
||||
const pos = range.head > range.from ? range.head - 1 : range.head;
|
||||
switch (obj) {
|
||||
case "w":
|
||||
case "W":
|
||||
return wordSpan(state, range.head, around);
|
||||
case "p":
|
||||
return paragraphSpan(state, range.head, around);
|
||||
case "(":
|
||||
case ")":
|
||||
case "b":
|
||||
return pairSpan(state, pos, "(", ")", around);
|
||||
case "{":
|
||||
case "}":
|
||||
case "B":
|
||||
return pairSpan(state, pos, "{", "}", around);
|
||||
case "[":
|
||||
case "]":
|
||||
return pairSpan(state, pos, "[", "]", around);
|
||||
case "<":
|
||||
case ">":
|
||||
return pairSpan(state, pos, "<", ">", around);
|
||||
case '"':
|
||||
return quoteSpan(state, range.head, '"', around);
|
||||
case "'":
|
||||
return quoteSpan(state, range.head, "'", around);
|
||||
case "`":
|
||||
return quoteSpan(state, range.head, "`", around);
|
||||
case "m":
|
||||
return nearestPair(state, pos, around);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export { pairFor } from "./motions";
|
||||
123
packages/codemirror-helix/src/view.ts
Normal file
123
packages/codemirror-helix/src/view.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
showPanel,
|
||||
type DecorationSet,
|
||||
type Panel,
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
import { getMode, helixState } from "./state";
|
||||
|
||||
class EolCursorWidget extends WidgetType {
|
||||
toDOM() {
|
||||
const span = document.createElement("span");
|
||||
span.className = "cm-helix-block cm-helix-block-eol";
|
||||
span.textContent = " ";
|
||||
return span;
|
||||
}
|
||||
ignoreEvent() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const blockMark = Decoration.mark({ class: "cm-helix-block" });
|
||||
const selectionMark = Decoration.mark({ class: "cm-helix-selection" });
|
||||
const eolCursor = Decoration.widget({ widget: new EolCursorWidget(), side: 1 });
|
||||
|
||||
// Draws the selection ranges + a block cursor at each head while in
|
||||
// normal/select mode. We render the selection ourselves (rather than relying
|
||||
// on drawSelection, which the host theme can leave invisible) so multi-cursor
|
||||
// selections are always clearly highlighted.
|
||||
export const blockCursor = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.build(view);
|
||||
}
|
||||
update(update: ViewUpdate) {
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.selectionSet ||
|
||||
update.viewportChanged ||
|
||||
update.startState.field(helixState).mode !== update.state.field(helixState).mode
|
||||
) {
|
||||
this.decorations = this.build(update.view);
|
||||
}
|
||||
}
|
||||
build(view: EditorView): DecorationSet {
|
||||
if (getMode(view.state) === "insert") return Decoration.none;
|
||||
const deco: ReturnType<typeof blockMark.range>[] = [];
|
||||
for (const range of view.state.selection.ranges) {
|
||||
if (!range.empty) deco.push(selectionMark.range(range.from, range.to));
|
||||
const pos = range.head;
|
||||
const line = view.state.doc.lineAt(pos);
|
||||
deco.push(pos < line.to ? blockMark.range(pos, pos + 1) : eolCursor.range(pos));
|
||||
}
|
||||
return Decoration.set(deco, true);
|
||||
}
|
||||
},
|
||||
{ decorations: (plugin) => plugin.decorations },
|
||||
);
|
||||
|
||||
export const modeEditorClass = EditorView.editorAttributes.compute([helixState], (state) => ({
|
||||
class: `cm-helix cm-helix-${getMode(state)}`,
|
||||
}));
|
||||
|
||||
function statusPanel(view: EditorView): Panel {
|
||||
const dom = document.createElement("div");
|
||||
dom.className = "cm-helix-status";
|
||||
const render = () => {
|
||||
const s = view.state.field(helixState);
|
||||
const label = s.mode === "insert" ? "INS" : s.mode === "select" ? "SEL" : "NOR";
|
||||
const count = s.count ? ` ${s.count}` : "";
|
||||
const reg = s.register ? ` "${s.register}` : "";
|
||||
const pend = s.pending ? ` ${s.pending.type}` : "";
|
||||
const sels = view.state.selection.ranges.length;
|
||||
const multi = sels > 1 ? ` ${sels} sels` : "";
|
||||
dom.textContent = `${label}${count}${reg}${pend}${multi}`;
|
||||
dom.dataset.mode = s.mode;
|
||||
};
|
||||
render();
|
||||
return {
|
||||
dom,
|
||||
update: (u) => {
|
||||
if (u.docChanged || u.selectionSet || u.transactions.length) render();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const helixStatusPanel = showPanel.of(statusPanel);
|
||||
|
||||
export const helixTheme = EditorView.baseTheme({
|
||||
"&.cm-helix-normal .cm-cursor, &.cm-helix-normal .cm-cursorLayer": { display: "none" },
|
||||
"&.cm-helix-select .cm-cursor, &.cm-helix-select .cm-cursorLayer": { display: "none" },
|
||||
".cm-helix-selection": { backgroundColor: "rgba(120,160,255,0.32)" },
|
||||
".cm-helix-block": { backgroundColor: "rgba(125,165,255,0.7)", borderRadius: "1px" },
|
||||
".cm-helix-block-eol": { display: "inline-block", width: "0.55em" },
|
||||
".cm-helix-status": {
|
||||
padding: "1px 10px",
|
||||
font: "11px ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
letterSpacing: "0.05em",
|
||||
},
|
||||
".cm-helix-status[data-mode=insert]": { color: "#16a34a" },
|
||||
".cm-helix-status[data-mode=select]": { color: "#d97706" },
|
||||
".cm-helix-status[data-mode=normal]": { color: "#2563eb" },
|
||||
".cm-helix-prompt": {
|
||||
display: "flex",
|
||||
gap: "0.5em",
|
||||
alignItems: "center",
|
||||
padding: "2px 10px",
|
||||
font: "12px ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
},
|
||||
".cm-helix-prompt label": { opacity: 0.7 },
|
||||
".cm-helix-prompt input": {
|
||||
flex: "1",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
color: "inherit",
|
||||
font: "inherit",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user