cursedbelt 5.79.4 → 5.80.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/react/activity-bar/ActivityBar.d.ts +18 -1
- package/dist/react/activity-bar/ActivityBar.d.ts.map +1 -1
- package/dist/react/activity-bar/ActivityBar.js +34 -5
- package/dist/react/activity-bar/ActivityBar.js.map +1 -1
- package/dist/react/activity-bar/activityBar.css +20 -0
- package/dist/react/code-editor/CodeEditor.d.ts +5 -1
- package/dist/react/code-editor/CodeEditor.d.ts.map +1 -1
- package/dist/react/code-editor/CodeEditor.js +13 -6
- package/dist/react/code-editor/CodeEditor.js.map +1 -1
- package/dist/react/code-editor/keys.d.ts +8 -0
- package/dist/react/code-editor/keys.d.ts.map +1 -0
- package/dist/react/code-editor/keys.js +16 -0
- package/dist/react/code-editor/keys.js.map +1 -0
- package/dist/react/code-editor/languages.d.ts.map +1 -1
- package/dist/react/code-editor/languages.js +4 -2
- package/dist/react/code-editor/languages.js.map +1 -1
- package/dist/react/code-editor/lineAbove.d.ts +5 -4
- package/dist/react/code-editor/lineAbove.d.ts.map +1 -1
- package/dist/react/code-editor/lineAbove.js +6 -10
- package/dist/react/code-editor/lineAbove.js.map +1 -1
- package/dist/react/code-editor/markdownRules.d.ts +8 -0
- package/dist/react/code-editor/markdownRules.d.ts.map +1 -0
- package/dist/react/code-editor/markdownRules.js +64 -0
- package/dist/react/code-editor/markdownRules.js.map +1 -0
- package/dist/react/code-editor/occurrences.d.ts +8 -0
- package/dist/react/code-editor/occurrences.d.ts.map +1 -0
- package/dist/react/code-editor/occurrences.js +63 -0
- package/dist/react/code-editor/occurrences.js.map +1 -0
- package/dist/react/code-editor/plainInput.d.ts +4 -0
- package/dist/react/code-editor/plainInput.d.ts.map +1 -0
- package/dist/react/code-editor/plainInput.js +36 -0
- package/dist/react/code-editor/plainInput.js.map +1 -0
- package/dist/react/code-editor/theme.d.ts.map +1 -1
- package/dist/react/code-editor/theme.js +5 -0
- package/dist/react/code-editor/theme.js.map +1 -1
- package/dist/react/line-keys/lineKeys.d.ts +12 -8
- package/dist/react/line-keys/lineKeys.d.ts.map +1 -1
- package/dist/react/line-keys/lineKeys.js +14 -10
- package/dist/react/line-keys/lineKeys.js.map +1 -1
- package/package.json +1 -1
- package/src/react/activity-bar/ActivityBar.spec.tsx +46 -0
- package/src/react/activity-bar/ActivityBar.tsx +67 -4
- package/src/react/activity-bar/activityBar.css +20 -0
- package/src/react/code-editor/CodeEditor.tsx +24 -7
- package/src/react/code-editor/keys.spec.ts +61 -0
- package/src/react/code-editor/keys.ts +17 -0
- package/src/react/code-editor/languages.ts +4 -2
- package/src/react/code-editor/lineAbove.spec.ts +1 -16
- package/src/react/code-editor/lineAbove.ts +6 -10
- package/src/react/code-editor/markdownRules.spec.ts +26 -0
- package/src/react/code-editor/markdownRules.ts +73 -0
- package/src/react/code-editor/occurrences.ts +69 -0
- package/src/react/code-editor/plainInput.spec.ts +23 -0
- package/src/react/code-editor/plainInput.ts +35 -0
- package/src/react/code-editor/theme.ts +5 -0
- package/src/react/line-keys/lineKeys.spec.tsx +19 -7
- package/src/react/line-keys/lineKeys.ts +14 -10
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { EditorSelection, EditorState, type Transaction } from '@codemirror/state';
|
|
3
|
+
import type { KeyBinding } from '@codemirror/view';
|
|
4
|
+
import { editorKeyBindings } from './keys.js';
|
|
5
|
+
import { selectAllOccurrences, selectNextOccurrence } from './occurrences.js';
|
|
6
|
+
|
|
7
|
+
/** The command the editor runs for `key` on a Mac — the first binding that names it, as `keymap` resolves. */
|
|
8
|
+
function macCommand(key: string): KeyBinding['run'] {
|
|
9
|
+
const named = (b: KeyBinding) => b.mac === key || (!b.mac && b.key === key.replace('Cmd-', 'Mod-'));
|
|
10
|
+
return editorKeyBindings.find(named)?.run;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function press(key: string, doc: string, at: number) {
|
|
14
|
+
let state = EditorState.create({ doc, selection: EditorSelection.cursor(at) });
|
|
15
|
+
const run = macCommand(key);
|
|
16
|
+
run?.({ state, dispatch: (tr: Transaction) => { state = tr.state; } } as never);
|
|
17
|
+
return { doc: state.doc.toString(), caret: state.selection.main.head };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('CodeEditor keys — VS Code on a Mac (forge task 3182)', () => {
|
|
21
|
+
test('⌘↑ / ⌘↓ jump to the top or bottom and leave the line where it is', () => {
|
|
22
|
+
expect(press('Cmd-ArrowUp', 'one\ntwo\nthree', 6)).toEqual({ doc: 'one\ntwo\nthree', caret: 0 });
|
|
23
|
+
expect(press('Cmd-ArrowDown', 'one\ntwo\nthree', 1)).toEqual({ doc: 'one\ntwo\nthree', caret: 13 });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('⌥↑ / ⌥↓ move the line', () => {
|
|
27
|
+
expect(press('Alt-ArrowUp', 'one\ntwo', 5)).toEqual({ doc: 'two\none', caret: 1 });
|
|
28
|
+
expect(press('Alt-ArrowDown', 'one\ntwo', 1)).toEqual({ doc: 'two\none', caret: 5 });
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('⌘D and ⇧⌘L — several cursors on the same word', () => {
|
|
33
|
+
const multi = EditorState.allowMultipleSelections.of(true);
|
|
34
|
+
function run(command: typeof selectNextOccurrence, state: EditorState): EditorState {
|
|
35
|
+
let next = state;
|
|
36
|
+
command({ state, dispatch: (tr: Transaction) => { next = tr.state; } });
|
|
37
|
+
return next;
|
|
38
|
+
}
|
|
39
|
+
const spans = (state: EditorState) => state.selection.ranges.map((r) => [r.from, r.to]);
|
|
40
|
+
|
|
41
|
+
test('⌘D takes the word under the caret, then each next match, wrapping, then stops', () => {
|
|
42
|
+
let state = EditorState.create({ doc: 'ab x ab y ab', selection: EditorSelection.cursor(6), extensions: multi });
|
|
43
|
+
state = run(selectNextOccurrence, state);
|
|
44
|
+
expect(spans(state)).toEqual([[5, 7]]);
|
|
45
|
+
state = run(selectNextOccurrence, state);
|
|
46
|
+
expect(spans(state)).toEqual([[5, 7], [10, 12]]);
|
|
47
|
+
state = run(selectNextOccurrence, state);
|
|
48
|
+
expect(spans(state)).toEqual([[0, 2], [5, 7], [10, 12]]);
|
|
49
|
+
expect(selectNextOccurrence({ state, dispatch: () => {} })).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('⇧⌘L selects every match at once', () => {
|
|
53
|
+
const state = run(selectAllOccurrences, EditorState.create({ doc: 'ab x ab', selection: EditorSelection.cursor(1), extensions: multi }));
|
|
54
|
+
expect(spans(state)).toEqual([[0, 2], [5, 7]]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('they sit in front of defaultKeymap, on VS Code\'s keys', () => {
|
|
58
|
+
expect(macCommand('Cmd-d')).toBe(selectNextOccurrence);
|
|
59
|
+
expect(macCommand('Shift-Cmd-l')).toBe(selectAllOccurrences);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defaultKeymap, historyKeymap, indentWithTab } from '@codemirror/commands';
|
|
2
|
+
import type { KeyBinding } from '@codemirror/view';
|
|
3
|
+
import { lineKeyBindings } from './lineAbove.js';
|
|
4
|
+
import { occurrenceBindings } from './occurrences.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Every key `CodeEditor` answers, first match wins: cb's VS Code additions IN FRONT of
|
|
8
|
+
* CodeMirror's `defaultKeymap`, which is already VS Code's for the rest. Nothing here may shadow
|
|
9
|
+
* a `defaultKeymap` key VS Code means the same way — `keys.spec.ts` holds ⌘↑ / ⌘↓ to the jump.
|
|
10
|
+
*/
|
|
11
|
+
export const editorKeyBindings: readonly KeyBinding[] = [
|
|
12
|
+
...lineKeyBindings,
|
|
13
|
+
...occurrenceBindings,
|
|
14
|
+
...defaultKeymap,
|
|
15
|
+
...historyKeymap,
|
|
16
|
+
indentWithTab,
|
|
17
|
+
];
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// and a snippet shown WITHOUT highlighting is the plain <pre> this component
|
|
10
10
|
// exists to replace.
|
|
11
11
|
import type { Extension } from '@codemirror/state';
|
|
12
|
+
import { markdownRules } from './markdownRules.js';
|
|
12
13
|
|
|
13
14
|
/** Supported language identifiers. `text` (the default) means no highlighting. */
|
|
14
15
|
export type CodeLanguage =
|
|
@@ -397,10 +398,11 @@ export async function loadLanguageExtension(language: CodeLanguage): Promise<Ext
|
|
|
397
398
|
case 'diff':
|
|
398
399
|
return stream((await import('@codemirror/legacy-modes/mode/diff')).diff);
|
|
399
400
|
// The one Lezer grammar past the first six: a markdown fence highlights its own
|
|
400
|
-
// code, and `#`, `**` and `[](…)` read as structure rather than as punctuation.
|
|
401
|
+
// code, and `#`, `**` and `[](…)` read as structure rather than as punctuation. A `---`
|
|
402
|
+
// rule is drawn as one while the caret is off its line (`markdownRules`).
|
|
401
403
|
case 'markdown': {
|
|
402
404
|
const { markdown } = await import('@codemirror/lang-markdown');
|
|
403
|
-
return markdown();
|
|
405
|
+
return [markdown(), markdownRules];
|
|
404
406
|
}
|
|
405
407
|
case 'scala':
|
|
406
408
|
return stream((await import('@codemirror/legacy-modes/mode/clike')).scala);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
2
|
import { EditorSelection, EditorState, type Transaction } from '@codemirror/state';
|
|
3
|
-
import { insertBlankLineAbove
|
|
3
|
+
import { insertBlankLineAbove } from './lineAbove.js';
|
|
4
4
|
|
|
5
5
|
function run(doc: string, at: number): { doc: string; caret: number } {
|
|
6
6
|
let state = EditorState.create({ doc, selection: EditorSelection.cursor(at) });
|
|
@@ -19,18 +19,3 @@ describe('insertBlankLineAbove — ⇧⌘↵ in CodeEditor', () => {
|
|
|
19
19
|
expect(insertBlankLineAbove({ state, dispatch: () => {} })).toBe(false);
|
|
20
20
|
});
|
|
21
21
|
});
|
|
22
|
-
|
|
23
|
-
describe('lineKeyBindings — ⌘↑ / ⌘↓ in CodeEditor', () => {
|
|
24
|
-
function press(key: string, doc: string, at: number): { doc: string; caret: number } {
|
|
25
|
-
let state = EditorState.create({ doc, selection: EditorSelection.cursor(at) });
|
|
26
|
-
const binding = lineKeyBindings.find((b) => b.key === key);
|
|
27
|
-
// moveLineUp/Down are StateCommands at heart; they read only `state` and `dispatch`.
|
|
28
|
-
binding?.run?.({ state, dispatch: (tr: Transaction) => { state = tr.state; } } as never);
|
|
29
|
-
return { doc: state.doc.toString(), caret: state.selection.main.head };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
test('⌘↑ moves the line up and ⌘↓ moves it down, the caret riding with it', () => {
|
|
33
|
-
expect(press('Mod-ArrowUp', 'one\ntwo', 5)).toEqual({ doc: 'two\none', caret: 1 });
|
|
34
|
-
expect(press('Mod-ArrowDown', 'one\ntwo', 1)).toEqual({ doc: 'two\none', caret: 5 });
|
|
35
|
-
});
|
|
36
|
-
});
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { moveLineDown, moveLineUp } from '@codemirror/commands';
|
|
2
1
|
import { EditorSelection, type StateCommand } from '@codemirror/state';
|
|
3
2
|
import type { KeyBinding } from '@codemirror/view';
|
|
4
3
|
|
|
@@ -23,13 +22,10 @@ export const insertBlankLineAbove: StateCommand = ({ state, dispatch }) => {
|
|
|
23
22
|
};
|
|
24
23
|
|
|
25
24
|
/**
|
|
26
|
-
* The line keys `CodeEditor` adds IN FRONT of `defaultKeymap`: ⇧⌘↵ above
|
|
27
|
-
*
|
|
28
|
-
* bottom
|
|
29
|
-
* the
|
|
25
|
+
* The line keys `CodeEditor` adds IN FRONT of `defaultKeymap`: ⇧⌘↵ above. Everything else VS Code
|
|
26
|
+
* binds for lines `defaultKeymap` already has — ⌥↑ / ⌥↓ move, ⇧⌥↑ / ⇧⌥↓ copy, ⌘↑ / ⌘↓ the jump to
|
|
27
|
+
* the top or bottom with the line left where it is. ⌘↑ / ⌘↓ moved the line here from 2026-10-05
|
|
28
|
+
* until the owner asked for VS Code's meaning back (2026-10-14, forge task 3182); the same keys
|
|
29
|
+
* in a `<textarea>` are `cursedbelt/react/line-keys`.
|
|
30
30
|
*/
|
|
31
|
-
export const lineKeyBindings: readonly KeyBinding[] = [
|
|
32
|
-
{ key: 'Shift-Mod-Enter', run: insertBlankLineAbove },
|
|
33
|
-
{ key: 'Mod-ArrowUp', run: moveLineUp },
|
|
34
|
-
{ key: 'Mod-ArrowDown', run: moveLineDown },
|
|
35
|
-
];
|
|
31
|
+
export const lineKeyBindings: readonly KeyBinding[] = [{ key: 'Shift-Mod-Enter', run: insertBlankLineAbove }];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { markdown } from '@codemirror/lang-markdown';
|
|
3
|
+
import { ensureSyntaxTree } from '@codemirror/language';
|
|
4
|
+
import { EditorSelection, EditorState } from '@codemirror/state';
|
|
5
|
+
import { ruleLines } from './markdownRules.js';
|
|
6
|
+
|
|
7
|
+
function rules(doc: string, caret: number): number[] {
|
|
8
|
+
const state = EditorState.create({ doc, selection: EditorSelection.cursor(caret), extensions: markdown() });
|
|
9
|
+
ensureSyntaxTree(state, state.doc.length, 5000);
|
|
10
|
+
return ruleLines(state, [{ from: 0, to: state.doc.length }]);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('markdownRules — `---` drawn as a rule while writing (forge task 3184)', () => {
|
|
14
|
+
test('a rule line is drawn, the caret elsewhere', () => {
|
|
15
|
+
expect(rules('one\n\n---\n\ntwo', 0)).toEqual([5]);
|
|
16
|
+
expect(rules('***\n\n___', 0)).toEqual([5]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('with the caret on it, the dashes show', () => {
|
|
20
|
+
expect(rules('one\n\n---\n\ntwo', 7)).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('`---` under text is that text\'s heading underline, not a rule — as the preview reads it', () => {
|
|
24
|
+
expect(rules('Title\n---\n', 0)).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { syntaxTree } from '@codemirror/language';
|
|
2
|
+
import type { EditorState, Extension, Range } from '@codemirror/state';
|
|
3
|
+
import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A markdown rule (`---`, `***`, `___`) drawn as a rule while you write, the dashes coming back
|
|
7
|
+
* when the caret is on its line — Obsidian's live preview of the same thing. The owner, 2026-10-14
|
|
8
|
+
* (forge task 3184): "it should show as a solid line in editing and in view". Only a rule the
|
|
9
|
+
* grammar agrees is one: `---` under a line of text is that line's heading underline, and the
|
|
10
|
+
* preview reads it the same way.
|
|
11
|
+
*/
|
|
12
|
+
const rule = Decoration.line({ class: 'cb-md-rule' });
|
|
13
|
+
|
|
14
|
+
/** The line starts of every rule in the visible ranges, less any line a selection touches. */
|
|
15
|
+
export function ruleLines(state: EditorState, ranges: readonly { from: number; to: number }[]): number[] {
|
|
16
|
+
const starts: number[] = [];
|
|
17
|
+
const tree = syntaxTree(state);
|
|
18
|
+
for (const { from, to } of ranges) {
|
|
19
|
+
tree.iterate({
|
|
20
|
+
from,
|
|
21
|
+
to,
|
|
22
|
+
enter: (node) => {
|
|
23
|
+
if (node.name !== 'HorizontalRule') return;
|
|
24
|
+
const line = state.doc.lineAt(node.from);
|
|
25
|
+
const touched = state.selection.ranges.some((r) => r.from <= line.to && r.to >= line.from);
|
|
26
|
+
if (!touched && starts.at(-1) !== line.from) starts.push(line.from);
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return starts;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function build(view: EditorView): DecorationSet {
|
|
34
|
+
const marks: Range<Decoration>[] = ruleLines(view.state, view.visibleRanges).map((at) => rule.range(at));
|
|
35
|
+
return Decoration.set(marks);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const rulePlugin = ViewPlugin.fromClass(
|
|
39
|
+
class {
|
|
40
|
+
decorations: DecorationSet;
|
|
41
|
+
constructor(view: EditorView) {
|
|
42
|
+
this.decorations = build(view);
|
|
43
|
+
}
|
|
44
|
+
update(update: ViewUpdate) {
|
|
45
|
+
if (
|
|
46
|
+
update.docChanged ||
|
|
47
|
+
update.selectionSet ||
|
|
48
|
+
update.viewportChanged ||
|
|
49
|
+
syntaxTree(update.startState) !== syntaxTree(update.state)
|
|
50
|
+
)
|
|
51
|
+
this.decorations = build(update.view);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{ decorations: (plugin) => plugin.decorations },
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const ruleTheme = EditorView.baseTheme({
|
|
58
|
+
// The rule is the line's background, centred, the width of the text column; the dashes stay
|
|
59
|
+
// in the document (and under the caret's click), just not painted.
|
|
60
|
+
'.cb-md-rule': {
|
|
61
|
+
backgroundImage: 'linear-gradient(var(--border), var(--border))',
|
|
62
|
+
backgroundSize: '100% 1px',
|
|
63
|
+
backgroundPosition: 'center',
|
|
64
|
+
backgroundRepeat: 'no-repeat',
|
|
65
|
+
backgroundOrigin: 'content-box',
|
|
66
|
+
backgroundClip: 'content-box',
|
|
67
|
+
},
|
|
68
|
+
'.cb-md-rule, .cb-md-rule *': {
|
|
69
|
+
color: 'transparent !important',
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export const markdownRules: Extension = [rulePlugin, ruleTheme];
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { EditorSelection, type EditorState, type SelectionRange, type StateCommand } from '@codemirror/state';
|
|
2
|
+
import type { KeyBinding } from '@codemirror/view';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* VS Code's ⌘D and ⇧⌘L — the two multi-cursor keys `defaultKeymap` leaves to `@codemirror/search`,
|
|
6
|
+
* which `CodeEditor` does not load (its find panel would fight the app's own find). Exact text,
|
|
7
|
+
* case-sensitive; a caret with nothing selected first takes the word it is in, as in VS Code.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** The text the commands look for: the main selection, or the word under an empty caret. */
|
|
11
|
+
function queryOf(state: EditorState): { query: string; range: SelectionRange } | null {
|
|
12
|
+
const main = state.selection.main;
|
|
13
|
+
const range = main.empty ? state.wordAt(main.head) : main;
|
|
14
|
+
if (!range || range.empty) return null;
|
|
15
|
+
return { query: state.sliceDoc(range.from, range.to), range: EditorSelection.range(range.from, range.to) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Where `query` starts in `text`, matches never overlapping. */
|
|
19
|
+
function matchesOf(text: string, query: string): number[] {
|
|
20
|
+
const starts: number[] = [];
|
|
21
|
+
for (let at = text.indexOf(query); at !== -1; at = text.indexOf(query, at + query.length)) starts.push(at);
|
|
22
|
+
return starts;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isSelected(state: EditorState, from: number, to: number): boolean {
|
|
26
|
+
return state.selection.ranges.some((r) => r.from === from && r.to === to);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** ⌘D — select the word under the caret, then add the next match of it, wrapping at the end. */
|
|
30
|
+
export const selectNextOccurrence: StateCommand = ({ state, dispatch }) => {
|
|
31
|
+
const found = queryOf(state);
|
|
32
|
+
if (!found) return false;
|
|
33
|
+
const { query, range } = found;
|
|
34
|
+
if (state.selection.main.empty) {
|
|
35
|
+
dispatch(state.update({ selection: state.selection.replaceRange(range), scrollIntoView: true, userEvent: 'select' }));
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
// Every match, the ones after the selection first, then from the top — the first not yet selected.
|
|
39
|
+
const starts = matchesOf(state.doc.toString(), query);
|
|
40
|
+
const at = [...starts.filter((a) => a >= range.to), ...starts.filter((a) => a < range.to)].find(
|
|
41
|
+
(a) => !isSelected(state, a, a + query.length),
|
|
42
|
+
);
|
|
43
|
+
if (at === undefined) return false;
|
|
44
|
+
dispatch(
|
|
45
|
+
state.update({
|
|
46
|
+
selection: state.selection.addRange(EditorSelection.range(at, at + query.length)),
|
|
47
|
+
scrollIntoView: true,
|
|
48
|
+
userEvent: 'select',
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
return true;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** ⇧⌘L — every match of the selection (or the word under the caret) selected at once. */
|
|
55
|
+
export const selectAllOccurrences: StateCommand = ({ state, dispatch }) => {
|
|
56
|
+
const found = queryOf(state);
|
|
57
|
+
if (!found) return false;
|
|
58
|
+
const { query, range } = found;
|
|
59
|
+
const starts = matchesOf(state.doc.toString(), query);
|
|
60
|
+
const ranges = starts.map((at) => EditorSelection.range(at, at + query.length));
|
|
61
|
+
const main = Math.max(0, starts.indexOf(range.from));
|
|
62
|
+
dispatch(state.update({ selection: EditorSelection.create(ranges, main), userEvent: 'select' }));
|
|
63
|
+
return true;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const occurrenceBindings: readonly KeyBinding[] = [
|
|
67
|
+
{ key: 'Mod-d', run: selectNextOccurrence, preventDefault: true },
|
|
68
|
+
{ key: 'Shift-Mod-l', run: selectAllOccurrences, preventDefault: true },
|
|
69
|
+
];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { unsmarten } from './plainInput.js';
|
|
3
|
+
|
|
4
|
+
describe('unsmarten — the OS\'s smart punctuation, put back (forge task 3184)', () => {
|
|
5
|
+
test('a dash the OS made of typed hyphens is the hyphens again', () => {
|
|
6
|
+
expect(unsmarten('-', '—')).toBe('--');
|
|
7
|
+
expect(unsmarten('--', '—')).toBe('---');
|
|
8
|
+
expect(unsmarten('-', '–')).toBe('--');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('a curly quote where a straight one was pressed is straight', () => {
|
|
12
|
+
expect(unsmarten('', '“')).toBe('"');
|
|
13
|
+
expect(unsmarten('', '’')).toBe("'");
|
|
14
|
+
expect(unsmarten('"', '”')).toBe('"');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('anything else is typing, left alone', () => {
|
|
18
|
+
expect(unsmarten('', '—')).toBeNull();
|
|
19
|
+
expect(unsmarten('a', '—')).toBeNull();
|
|
20
|
+
expect(unsmarten('', '-')).toBeNull();
|
|
21
|
+
expect(unsmarten('x', '“')).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { EditorView } from '@codemirror/view';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the OS's "smart punctuation" types into a source editor, put back to what the keys said.
|
|
5
|
+
*
|
|
6
|
+
* iOS (and a Mac with Edit ▸ Substitutions on) rewrites `--` to `—` and `"` to `“` as you type —
|
|
7
|
+
* in markdown that eats a rule's dashes and a code span's quotes (the owner, 2026-10-14, forge
|
|
8
|
+
* task 3184: "when typing three dashes … it shifts and deletes some dashes … please fix all other
|
|
9
|
+
* syntax errors that may be similar"). `CodeEditor` already asks the browser for no autocorrect;
|
|
10
|
+
* this is the half that holds where the OS does not listen. VS Code substitutes nothing, either.
|
|
11
|
+
*
|
|
12
|
+
* Only a substitution is undone: a dash replacing the `-` (or `--`) the keys just typed, or a
|
|
13
|
+
* curly quote arriving where a straight one was pressed. A `—` pasted in is a paste, never seen here.
|
|
14
|
+
*/
|
|
15
|
+
const QUOTES: Record<string, string> = { '“': '"', '”': '"', '‘': "'", '’': "'" };
|
|
16
|
+
|
|
17
|
+
/** The text the keys meant, or null when `text` replacing `replaced` is not a substitution. */
|
|
18
|
+
export function unsmarten(replaced: string, text: string): string | null {
|
|
19
|
+
if ((text === '—' || text === '–') && /^-{1,2}$/.test(replaced)) return text === '—' && replaced === '-' ? '--' : `${replaced}-`;
|
|
20
|
+
const quote = QUOTES[text];
|
|
21
|
+
if (quote !== undefined && (replaced === '' || replaced === quote)) return quote;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const plainInput = EditorView.inputHandler.of((view, from, to, text) => {
|
|
26
|
+
const meant = unsmarten(view.state.sliceDoc(from, to), text);
|
|
27
|
+
if (meant === null) return false;
|
|
28
|
+
view.dispatch({
|
|
29
|
+
changes: { from, to, insert: meant },
|
|
30
|
+
selection: { anchor: from + meant.length },
|
|
31
|
+
scrollIntoView: true,
|
|
32
|
+
userEvent: 'input.type',
|
|
33
|
+
});
|
|
34
|
+
return true;
|
|
35
|
+
});
|
|
@@ -21,6 +21,11 @@ export const cbEditorTheme = EditorView.theme({
|
|
|
21
21
|
'.cm-content': {
|
|
22
22
|
caretColor: 'var(--foreground)',
|
|
23
23
|
padding: '0.5rem 0',
|
|
24
|
+
// Every character drawn as itself — VS Code's `editor.fontLigatures: false`. A ligature font
|
|
25
|
+
// (JetBrains Mono, desk's) joins `--` and `---` into one glyph that redraws as each dash
|
|
26
|
+
// lands, and turns `!=`, `=>` and `<=` into symbols that are not what was typed (forge task 3184).
|
|
27
|
+
fontVariantLigatures: 'none',
|
|
28
|
+
fontFeatureSettings: '"calt" 0, "liga" 0',
|
|
24
29
|
},
|
|
25
30
|
// CodeMirror's own recipe for a minimum height: give it to the content AND the gutter, so the
|
|
26
31
|
// line-number rail runs the editor's full height instead of stopping under the last line
|
|
@@ -89,9 +89,10 @@ describe('lineCommandOf', () => {
|
|
|
89
89
|
expect(key('Enter', { metaKey: true, altKey: true })).toBeNull();
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
-
test('⌘↑ / ⌘↓
|
|
93
|
-
expect(key('ArrowUp', { metaKey: true })).
|
|
94
|
-
expect(key('ArrowDown', {
|
|
92
|
+
test('⌥↑ / ⌥↓ move a line; ⌘↑ / ⌘↓ stay the jump to the top or bottom (VS Code, forge task 3182); ⇧ stays a selection key', () => {
|
|
93
|
+
expect(key('ArrowUp', { metaKey: true })).toBeNull();
|
|
94
|
+
expect(key('ArrowDown', { metaKey: true })).toBeNull();
|
|
95
|
+
expect(key('ArrowDown', { ctrlKey: true })).toBeNull();
|
|
95
96
|
expect(key('ArrowUp', { altKey: true })).toBe('up');
|
|
96
97
|
expect(key('ArrowDown', { altKey: true })).toBe('down');
|
|
97
98
|
expect(key('ArrowUp')).toBeNull();
|
|
@@ -136,23 +137,34 @@ describe('installLineKeys — on a React-controlled textarea', () => {
|
|
|
136
137
|
expect(box.value).toBe('first\nsecond');
|
|
137
138
|
});
|
|
138
139
|
|
|
139
|
-
test('
|
|
140
|
+
test('⌥↓ moves a line through React state, the caret riding with it; past the last line it does nothing', () => {
|
|
140
141
|
off = installLineKeys();
|
|
141
142
|
const view = render(<Box initial={'one\ntwo\nthree'} />);
|
|
142
143
|
const box = view.getByLabelText('box') as HTMLTextAreaElement;
|
|
143
144
|
box.focus();
|
|
144
145
|
box.setSelectionRange(1, 1);
|
|
145
|
-
fireEvent.keyDown(box, { key: 'ArrowDown',
|
|
146
|
+
fireEvent.keyDown(box, { key: 'ArrowDown', altKey: true });
|
|
146
147
|
expect(box.value).toBe('two\none\nthree');
|
|
147
148
|
expect(box.selectionStart).toBe(5);
|
|
148
149
|
fireEvent.keyDown(box, { key: 'ArrowDown', altKey: true });
|
|
149
150
|
expect(box.value).toBe('two\nthree\none');
|
|
150
|
-
const blocked = fireEvent.keyDown(box, { key: 'ArrowDown',
|
|
151
|
+
const blocked = fireEvent.keyDown(box, { key: 'ArrowDown', altKey: true });
|
|
151
152
|
expect(box.value).toBe('two\nthree\none');
|
|
152
|
-
// Swallowed, so
|
|
153
|
+
// Swallowed, so ⌥↓ at the last line does not also move the caret.
|
|
153
154
|
expect(blocked).toBe(false);
|
|
154
155
|
});
|
|
155
156
|
|
|
157
|
+
test('⌘↑ / ⌘↓ leave the line where it is and the key to the browser — its jump to the top or bottom', () => {
|
|
158
|
+
off = installLineKeys();
|
|
159
|
+
const view = render(<Box initial={'one\ntwo\nthree'} />);
|
|
160
|
+
const box = view.getByLabelText('box') as HTMLTextAreaElement;
|
|
161
|
+
box.focus();
|
|
162
|
+
box.setSelectionRange(5, 5);
|
|
163
|
+
expect(fireEvent.keyDown(box, { key: 'ArrowUp', metaKey: true })).toBe(true);
|
|
164
|
+
expect(fireEvent.keyDown(box, { key: 'ArrowDown', metaKey: true })).toBe(true);
|
|
165
|
+
expect(box.value).toBe('one\ntwo\nthree');
|
|
166
|
+
});
|
|
167
|
+
|
|
156
168
|
test('a box that already answers ⌘↵ keeps it', () => {
|
|
157
169
|
off = installLineKeys();
|
|
158
170
|
const view = render(<Box claims initial="send me" />);
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
* ⌘↵ (Ctrl↵) a new line BELOW the caret's line, from anywhere on it
|
|
5
5
|
* ⇧⌘↵ (Ctrl⇧↵) a new line ABOVE it
|
|
6
6
|
* ⇧⌘K (Ctrl⇧K) delete the line (every line the selection touches)
|
|
7
|
-
*
|
|
8
|
-
* ⌥↑ ⌥↓ (Alt↑ Alt↓) the same — VS Code's own binding for it
|
|
7
|
+
* ⌥↑ ⌥↓ (Alt↑ Alt↓) move the line (every line the selection touches) up or down one
|
|
9
8
|
*
|
|
10
9
|
* The owner asked for these by name (2026-10-05, desk's context components first, "in apps with
|
|
11
|
-
* multi line text boxes"
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* multi line text boxes"). ⌘↑ / ⌘↓ is NOT a line key: it is VS Code's — and the Mac's — jump to the
|
|
11
|
+
* top or bottom, the line staying where it is (the owner, 2026-10-14, forge task 3182: "holding
|
|
12
|
+
* command and pushing up should jump to the top of the file … make all commands and keystrokes
|
|
13
|
+
* work like it does in VS code"). It moved the line from 2026-10-05 to then; the browser has it
|
|
14
|
+
* back. They are one document listener rather than an `onKeyDown` on each
|
|
15
15
|
* `<textarea>` because desk alone draws a dozen of them by hand: a prop is a thing the next box
|
|
16
16
|
* forgets, an install at boot is not.
|
|
17
17
|
*
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*
|
|
29
29
|
* CodeMirror boxes (cb's `CodeEditor`) are `contenteditable`, not `<textarea>`, and are left to
|
|
30
30
|
* CodeMirror's own keymap: `Mod-Enter` and `Shift-Mod-k` are already in its `defaultKeymap`, and
|
|
31
|
-
* `CodeEditor` adds the line above
|
|
31
|
+
* `CodeEditor` adds the line above (⌥↑ / ⌥↓ is `defaultKeymap`'s `moveLineUp`/`Down`).
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
export type LineCommand = 'below' | 'above' | 'delete' | 'up' | 'down';
|
|
@@ -114,11 +114,15 @@ function moveLines(value: string, start: number, end: number, from: number, to:
|
|
|
114
114
|
return { from, to: nextEnd, text: `${next}\n${block}`, caret: start + shift, end: end + shift };
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* Which command a key event asks for, or null. ⌘ on a Mac, Ctrl elsewhere — either is taken —
|
|
119
|
+
* except on an arrow, which moves a line on ⌥ alone: ⌘↑ / ⌘↓ stays the browser's jump to the top
|
|
120
|
+
* or bottom, as in VS Code.
|
|
121
|
+
*/
|
|
118
122
|
export function lineCommandOf(event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'>): LineCommand | null {
|
|
119
123
|
const arrow = event.key === 'ArrowUp' ? 'up' : event.key === 'ArrowDown' ? 'down' : null;
|
|
120
124
|
// ⇧ with an arrow extends a selection, in every one of these chords; that stays the browser's.
|
|
121
|
-
if (arrow) return event.
|
|
125
|
+
if (arrow) return event.altKey && !event.shiftKey && !event.metaKey && !event.ctrlKey ? arrow : null;
|
|
122
126
|
if (!(event.metaKey || event.ctrlKey) || event.altKey) return null;
|
|
123
127
|
if (event.key === 'Enter') return event.shiftKey ? 'above' : 'below';
|
|
124
128
|
if (event.shiftKey && (event.key === 'k' || event.key === 'K')) return 'delete';
|
|
@@ -154,7 +158,7 @@ function onKeyDown(event: KeyboardEvent): void {
|
|
|
154
158
|
if (command === 'delete' && field.value === '') return;
|
|
155
159
|
event.preventDefault();
|
|
156
160
|
// A move past the first or last line does nothing — and still swallows the key, as VS Code
|
|
157
|
-
// does, rather than letting
|
|
161
|
+
// does, rather than letting ⌥↑ jump the caret to the start of the paragraph.
|
|
158
162
|
const edit = lineEdit(field.value, field.selectionStart, field.selectionEnd, command);
|
|
159
163
|
if (edit) applyLineEdit(field, edit);
|
|
160
164
|
}
|