publish codemirrot helix package flow
Some checks failed
Publish codemirror-helix / publish (push) Failing after 2m16s

This commit is contained in:
2026-06-19 17:07:18 +02:00
parent 81c60feed9
commit 781e03c50c
18 changed files with 2265 additions and 54 deletions

View 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 };

View 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";

View 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;
},
}),
);
}

View 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 };
}

View 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;
}

View 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 };
}

View 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";

View 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",
},
});