blots-editor 0.0.6 → 0.0.8
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/core/document/document.d.ts +65 -0
- package/dist/core/document/document.js +175 -0
- package/dist/core/document/operations.d.ts +61 -0
- package/dist/core/document/operations.js +176 -0
- package/dist/core/document/position.d.ts +17 -0
- package/dist/core/document/position.js +9 -0
- package/dist/core/document/text-line.d.ts +24 -0
- package/dist/core/document/text-line.js +60 -0
- package/dist/core/editor-controller.d.ts +27 -0
- package/dist/core/editor-controller.js +90 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -3
- package/dist/view/click-mapping.d.ts +23 -0
- package/dist/view/click-mapping.js +17 -0
- package/dist/{core/view → view}/editor.d.ts +6 -1
- package/dist/view/editor.js +85 -0
- package/package.json +1 -1
- package/dist/core/text/text-based-calculation.d.ts +0 -41
- package/dist/core/text/text-based-calculation.js +0 -161
- package/dist/core/view/editor.js +0 -55
- /package/dist/{core/view → view}/information-bar.d.ts +0 -0
- /package/dist/{core/view → view}/information-bar.js +0 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { CursorState } from "./position.js";
|
|
2
|
+
export type DocumentOptions = {
|
|
3
|
+
indentWidth?: number;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Document model: array of lines + cursor + scroll state.
|
|
7
|
+
*
|
|
8
|
+
* Pure data and behavior: no React/Ink awareness, and no change
|
|
9
|
+
* notifications — `EditorController` owns those. The cursor stores only
|
|
10
|
+
* `{line, logical}`; `visual` is derived (see {@link CursorState}), so
|
|
11
|
+
* "logical column" and "display column" cannot diverge by construction.
|
|
12
|
+
*
|
|
13
|
+
* The document always has at least one line (empty document = one empty
|
|
14
|
+
* line), matching the result of `"".split(/\r?\n/)`.
|
|
15
|
+
*/
|
|
16
|
+
export declare class Document {
|
|
17
|
+
private _lines;
|
|
18
|
+
private _cursor;
|
|
19
|
+
private _scrollTop;
|
|
20
|
+
readonly indentWidth: number;
|
|
21
|
+
constructor(text: string, options?: DocumentOptions);
|
|
22
|
+
get lineCount(): number;
|
|
23
|
+
/** Snapshot of all line texts (for rendering; internally the model is `TextLine[]`). */
|
|
24
|
+
get lines(): string[];
|
|
25
|
+
getLine(i: number): string;
|
|
26
|
+
get cursor(): CursorState;
|
|
27
|
+
get scrollTop(): number;
|
|
28
|
+
/** Gutter width = decimal digit count of the last line number (at least 1). */
|
|
29
|
+
getLineNumberWidth(): number;
|
|
30
|
+
setLine(i: number, text: string): void;
|
|
31
|
+
/** Insert a line at index `i` (0 <= i <= lineCount), shifting the rest down. */
|
|
32
|
+
insertLineAt(i: number, text: string): void;
|
|
33
|
+
/** Remove line `i` and return its text. */
|
|
34
|
+
removeLineAt(i: number): string;
|
|
35
|
+
/** Set the cursor, clamping out-of-range values (line to `[0, lineCount-1]`, column to end of line). */
|
|
36
|
+
setCursor(line: number, logical: number): void;
|
|
37
|
+
/**
|
|
38
|
+
* Set the cursor by terminal column instead of code unit index — the
|
|
39
|
+
* entry point for mouse clicks. Snaps to the nearest valid position
|
|
40
|
+
* (see {@link TextLine.logicalAt}).
|
|
41
|
+
*/
|
|
42
|
+
setCursorAtVisual(line: number, visual: number): void;
|
|
43
|
+
moveLeft(): void;
|
|
44
|
+
moveRight(): void;
|
|
45
|
+
/** Vertical movement aligns by visual column, so wide-character lines do not drift. */
|
|
46
|
+
moveUp(): void;
|
|
47
|
+
moveDown(): void;
|
|
48
|
+
moveToLineStart(): void;
|
|
49
|
+
moveToLineEnd(): void;
|
|
50
|
+
moveToDocumentStart(): void;
|
|
51
|
+
moveToDocumentEnd(): void;
|
|
52
|
+
/** A word is a run of non-whitespace; jump to the next word start (stays put at end of line). */
|
|
53
|
+
moveWordForward(): void;
|
|
54
|
+
/** A word is a run of non-whitespace; jump back to the current/previous word start. */
|
|
55
|
+
moveWordBackward(): void;
|
|
56
|
+
/** Page up: move the cursor up by `height - 1` lines (one line of overlap), keeping the visual column. */
|
|
57
|
+
movePageUp(height: number): void;
|
|
58
|
+
movePageDown(height: number): void;
|
|
59
|
+
/**
|
|
60
|
+
* Scroll the viewport so the cursor stays visible: if the cursor is above
|
|
61
|
+
* the viewport, scroll to its line; if below, scroll so it becomes the
|
|
62
|
+
* last visible line. Returns the new scrollTop.
|
|
63
|
+
*/
|
|
64
|
+
updateScroll(height: number): number;
|
|
65
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { TextLine } from "./text-line.js";
|
|
2
|
+
/**
|
|
3
|
+
* Document model: array of lines + cursor + scroll state.
|
|
4
|
+
*
|
|
5
|
+
* Pure data and behavior: no React/Ink awareness, and no change
|
|
6
|
+
* notifications — `EditorController` owns those. The cursor stores only
|
|
7
|
+
* `{line, logical}`; `visual` is derived (see {@link CursorState}), so
|
|
8
|
+
* "logical column" and "display column" cannot diverge by construction.
|
|
9
|
+
*
|
|
10
|
+
* The document always has at least one line (empty document = one empty
|
|
11
|
+
* line), matching the result of `"".split(/\r?\n/)`.
|
|
12
|
+
*/
|
|
13
|
+
export class Document {
|
|
14
|
+
_lines;
|
|
15
|
+
_cursor;
|
|
16
|
+
_scrollTop = 0;
|
|
17
|
+
indentWidth;
|
|
18
|
+
constructor(text, options = {}) {
|
|
19
|
+
this._lines = text.split(/\r?\n/).map((t) => new TextLine(t));
|
|
20
|
+
this._cursor = { line: 0, logical: 0 };
|
|
21
|
+
this.indentWidth = options.indentWidth ?? 2;
|
|
22
|
+
}
|
|
23
|
+
get lineCount() {
|
|
24
|
+
return this._lines.length;
|
|
25
|
+
}
|
|
26
|
+
/** Snapshot of all line texts (for rendering; internally the model is `TextLine[]`). */
|
|
27
|
+
get lines() {
|
|
28
|
+
return this._lines.map((l) => l.text);
|
|
29
|
+
}
|
|
30
|
+
getLine(i) {
|
|
31
|
+
return this._lines[i].text;
|
|
32
|
+
}
|
|
33
|
+
get cursor() {
|
|
34
|
+
const line = this._lines[this._cursor.line];
|
|
35
|
+
return {
|
|
36
|
+
line: this._cursor.line,
|
|
37
|
+
logical: this._cursor.logical,
|
|
38
|
+
visual: line.visualAt(this._cursor.logical),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
get scrollTop() {
|
|
42
|
+
return this._scrollTop;
|
|
43
|
+
}
|
|
44
|
+
/** Gutter width = decimal digit count of the last line number (at least 1). */
|
|
45
|
+
getLineNumberWidth() {
|
|
46
|
+
return Math.max(String(this._lines.length - 1).length, 1);
|
|
47
|
+
}
|
|
48
|
+
setLine(i, text) {
|
|
49
|
+
this._lines[i] = new TextLine(text);
|
|
50
|
+
}
|
|
51
|
+
/** Insert a line at index `i` (0 <= i <= lineCount), shifting the rest down. */
|
|
52
|
+
insertLineAt(i, text) {
|
|
53
|
+
this._lines.splice(i, 0, new TextLine(text));
|
|
54
|
+
}
|
|
55
|
+
/** Remove line `i` and return its text. */
|
|
56
|
+
removeLineAt(i) {
|
|
57
|
+
return this._lines.splice(i, 1)[0].text;
|
|
58
|
+
}
|
|
59
|
+
/** Set the cursor, clamping out-of-range values (line to `[0, lineCount-1]`, column to end of line). */
|
|
60
|
+
setCursor(line, logical) {
|
|
61
|
+
const l = Math.max(0, Math.min(line, this._lines.length - 1));
|
|
62
|
+
const len = this._lines[l].text.length;
|
|
63
|
+
this._cursor = { line: l, logical: Math.max(0, Math.min(logical, len)) };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Set the cursor by terminal column instead of code unit index — the
|
|
67
|
+
* entry point for mouse clicks. Snaps to the nearest valid position
|
|
68
|
+
* (see {@link TextLine.logicalAt}).
|
|
69
|
+
*/
|
|
70
|
+
setCursorAtVisual(line, visual) {
|
|
71
|
+
const l = Math.max(0, Math.min(line, this._lines.length - 1));
|
|
72
|
+
this.setCursor(l, this._lines[l].logicalAt(visual));
|
|
73
|
+
}
|
|
74
|
+
moveLeft() {
|
|
75
|
+
const { line, logical } = this._cursor;
|
|
76
|
+
if (logical > 0) {
|
|
77
|
+
this.setCursor(line, logical - 1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
moveRight() {
|
|
81
|
+
const { line, logical } = this._cursor;
|
|
82
|
+
if (logical < this._lines[line].text.length) {
|
|
83
|
+
this.setCursor(line, logical + 1);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Vertical movement aligns by visual column, so wide-character lines do not drift. */
|
|
87
|
+
moveUp() {
|
|
88
|
+
const { line, logical } = this._cursor;
|
|
89
|
+
if (line > 0) {
|
|
90
|
+
const visual = this._lines[line].visualAt(logical);
|
|
91
|
+
this.setCursor(line - 1, this._lines[line - 1].logicalAt(visual));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
moveDown() {
|
|
95
|
+
const { line, logical } = this._cursor;
|
|
96
|
+
if (line < this._lines.length - 1) {
|
|
97
|
+
const visual = this._lines[line].visualAt(logical);
|
|
98
|
+
this.setCursor(line + 1, this._lines[line + 1].logicalAt(visual));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
moveToLineStart() {
|
|
102
|
+
this.setCursor(this._cursor.line, 0);
|
|
103
|
+
}
|
|
104
|
+
moveToLineEnd() {
|
|
105
|
+
this.setCursor(this._cursor.line, this._lines[this._cursor.line].text.length);
|
|
106
|
+
}
|
|
107
|
+
moveToDocumentStart() {
|
|
108
|
+
this.setCursor(0, 0);
|
|
109
|
+
}
|
|
110
|
+
moveToDocumentEnd() {
|
|
111
|
+
this.setCursor(this._lines.length - 1, this._lines[this._lines.length - 1].text.length);
|
|
112
|
+
}
|
|
113
|
+
/** A word is a run of non-whitespace; jump to the next word start (stays put at end of line). */
|
|
114
|
+
moveWordForward() {
|
|
115
|
+
const { line, logical } = this._cursor;
|
|
116
|
+
const text = this._lines[line].text;
|
|
117
|
+
const len = text.length;
|
|
118
|
+
let i = logical;
|
|
119
|
+
while (i < len && /\s/.test(text[i])) {
|
|
120
|
+
i++;
|
|
121
|
+
}
|
|
122
|
+
while (i < len && !/\s/.test(text[i])) {
|
|
123
|
+
i++;
|
|
124
|
+
}
|
|
125
|
+
while (i < len && /\s/.test(text[i])) {
|
|
126
|
+
i++;
|
|
127
|
+
}
|
|
128
|
+
this.setCursor(line, i);
|
|
129
|
+
}
|
|
130
|
+
/** A word is a run of non-whitespace; jump back to the current/previous word start. */
|
|
131
|
+
moveWordBackward() {
|
|
132
|
+
const { line, logical } = this._cursor;
|
|
133
|
+
const text = this._lines[line].text;
|
|
134
|
+
let i = logical;
|
|
135
|
+
while (i > 0 && /\s/.test(text[i - 1])) {
|
|
136
|
+
i--;
|
|
137
|
+
}
|
|
138
|
+
while (i > 0 && !/\s/.test(text[i - 1])) {
|
|
139
|
+
i--;
|
|
140
|
+
}
|
|
141
|
+
this.setCursor(line, i);
|
|
142
|
+
}
|
|
143
|
+
/** Page up: move the cursor up by `height - 1` lines (one line of overlap), keeping the visual column. */
|
|
144
|
+
movePageUp(height) {
|
|
145
|
+
const { line, logical } = this._cursor;
|
|
146
|
+
const visual = this._lines[line].visualAt(logical);
|
|
147
|
+
const target = Math.max(0, line - Math.max(1, height - 1));
|
|
148
|
+
this.setCursor(target, this._lines[target].logicalAt(visual));
|
|
149
|
+
}
|
|
150
|
+
movePageDown(height) {
|
|
151
|
+
const { line, logical } = this._cursor;
|
|
152
|
+
const visual = this._lines[line].visualAt(logical);
|
|
153
|
+
const target = Math.min(this._lines.length - 1, line + Math.max(1, height - 1));
|
|
154
|
+
this.setCursor(target, this._lines[target].logicalAt(visual));
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Scroll the viewport so the cursor stays visible: if the cursor is above
|
|
158
|
+
* the viewport, scroll to its line; if below, scroll so it becomes the
|
|
159
|
+
* last visible line. Returns the new scrollTop.
|
|
160
|
+
*/
|
|
161
|
+
updateScroll(height) {
|
|
162
|
+
const effectiveH = height > 0 ? height : this._lines.length;
|
|
163
|
+
let vs = this._scrollTop;
|
|
164
|
+
if (this._cursor.line < vs) {
|
|
165
|
+
vs = this._cursor.line;
|
|
166
|
+
}
|
|
167
|
+
else if (this._cursor.line >= vs + effectiveH) {
|
|
168
|
+
vs = this._cursor.line - effectiveH + 1;
|
|
169
|
+
}
|
|
170
|
+
const maxScroll = Math.max(0, this._lines.length - effectiveH);
|
|
171
|
+
vs = Math.max(0, Math.min(vs, maxScroll));
|
|
172
|
+
this._scrollTop = vs;
|
|
173
|
+
return vs;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Document } from "./document.js";
|
|
2
|
+
/**
|
|
3
|
+
* Atomic edit operations, each implementing `apply` + `invert`.
|
|
4
|
+
*
|
|
5
|
+
* `invert` is the symmetric counterpart: executing it right after `apply`
|
|
6
|
+
* restores the document. Undo (P1) replays `invert` in reverse order, so no
|
|
7
|
+
* document snapshots are needed. The convention is that `invert` runs while
|
|
8
|
+
* the cursor is at the position `apply` left it at — history will restore the
|
|
9
|
+
* cursor before inverting.
|
|
10
|
+
*/
|
|
11
|
+
export type EditOperation = {
|
|
12
|
+
apply(doc: Document): void;
|
|
13
|
+
invert(doc: Document): void;
|
|
14
|
+
};
|
|
15
|
+
/** Insert a string at the cursor; invert deletes exactly the inserted text. */
|
|
16
|
+
export declare class InsertTextOp implements EditOperation {
|
|
17
|
+
private readonly text;
|
|
18
|
+
constructor(text: string);
|
|
19
|
+
apply(doc: Document): void;
|
|
20
|
+
invert(doc: Document): void;
|
|
21
|
+
}
|
|
22
|
+
/** Backspace: delete the char before the cursor, or join with the previous line at column 0. */
|
|
23
|
+
export declare class DeleteBeforeOp implements EditOperation {
|
|
24
|
+
private _deletedChar;
|
|
25
|
+
private _joined;
|
|
26
|
+
apply(doc: Document): void;
|
|
27
|
+
invert(doc: Document): void;
|
|
28
|
+
}
|
|
29
|
+
/** Delete key: remove the char after the cursor, or join with the next line at end of line. */
|
|
30
|
+
export declare class DeleteAfterOp implements EditOperation {
|
|
31
|
+
private _deletedChar;
|
|
32
|
+
private _joined;
|
|
33
|
+
apply(doc: Document): void;
|
|
34
|
+
invert(doc: Document): void;
|
|
35
|
+
}
|
|
36
|
+
/** Enter: split the current line at the cursor; invert rejoins the two lines. */
|
|
37
|
+
export declare class SplitLineOp implements EditOperation {
|
|
38
|
+
apply(doc: Document): void;
|
|
39
|
+
invert(doc: Document): void;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Join the next line into the current one. `apply` never moves the cursor,
|
|
43
|
+
* so `invert` cannot rely on the cursor position — it records the join point
|
|
44
|
+
* (end of the original current line) instead.
|
|
45
|
+
*/
|
|
46
|
+
export declare class JoinLineOp implements EditOperation {
|
|
47
|
+
private _joinAt;
|
|
48
|
+
apply(doc: Document): void;
|
|
49
|
+
invert(doc: Document): void;
|
|
50
|
+
}
|
|
51
|
+
/** Indent the current line by `indentWidth` spaces, moving the cursor along. */
|
|
52
|
+
export declare class IndentOp implements EditOperation {
|
|
53
|
+
apply(doc: Document): void;
|
|
54
|
+
invert(doc: Document): void;
|
|
55
|
+
}
|
|
56
|
+
/** Outdent up to `indentWidth` leading spaces; invert re-adds exactly what was removed. */
|
|
57
|
+
export declare class OutdentOp implements EditOperation {
|
|
58
|
+
private _removed;
|
|
59
|
+
apply(doc: Document): void;
|
|
60
|
+
invert(doc: Document): void;
|
|
61
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/** Insert a string at the cursor; invert deletes exactly the inserted text. */
|
|
2
|
+
export class InsertTextOp {
|
|
3
|
+
text;
|
|
4
|
+
constructor(text) {
|
|
5
|
+
this.text = text;
|
|
6
|
+
}
|
|
7
|
+
apply(doc) {
|
|
8
|
+
const { line, logical } = doc.cursor;
|
|
9
|
+
const cur = doc.getLine(line);
|
|
10
|
+
doc.setLine(line, cur.slice(0, logical) + this.text + cur.slice(logical));
|
|
11
|
+
doc.setCursor(line, logical + this.text.length);
|
|
12
|
+
}
|
|
13
|
+
invert(doc) {
|
|
14
|
+
const { line, logical } = doc.cursor;
|
|
15
|
+
const cur = doc.getLine(line);
|
|
16
|
+
const start = logical - this.text.length;
|
|
17
|
+
doc.setLine(line, cur.slice(0, start) + cur.slice(logical));
|
|
18
|
+
doc.setCursor(line, start);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Backspace: delete the char before the cursor, or join with the previous line at column 0. */
|
|
22
|
+
export class DeleteBeforeOp {
|
|
23
|
+
_deletedChar = "";
|
|
24
|
+
_joined = false;
|
|
25
|
+
apply(doc) {
|
|
26
|
+
const { line, logical } = doc.cursor;
|
|
27
|
+
if (logical > 0) {
|
|
28
|
+
const cur = doc.getLine(line);
|
|
29
|
+
this._deletedChar = cur[logical - 1];
|
|
30
|
+
this._joined = false;
|
|
31
|
+
doc.setLine(line, cur.slice(0, logical - 1) + cur.slice(logical));
|
|
32
|
+
doc.setCursor(line, logical - 1);
|
|
33
|
+
}
|
|
34
|
+
else if (line > 0) {
|
|
35
|
+
const prev = doc.getLine(line - 1);
|
|
36
|
+
const cur = doc.getLine(line);
|
|
37
|
+
doc.setLine(line - 1, prev + cur);
|
|
38
|
+
doc.removeLineAt(line);
|
|
39
|
+
doc.setCursor(line - 1, prev.length);
|
|
40
|
+
this._joined = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
invert(doc) {
|
|
44
|
+
if (!this._joined) {
|
|
45
|
+
const { line, logical } = doc.cursor;
|
|
46
|
+
const cur = doc.getLine(line);
|
|
47
|
+
doc.setLine(line, cur.slice(0, logical) + this._deletedChar + cur.slice(logical));
|
|
48
|
+
doc.setCursor(line, logical + 1);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
// Cursor sits at the end of the merged line; split it back at that point.
|
|
52
|
+
const { line, logical } = doc.cursor;
|
|
53
|
+
const cur = doc.getLine(line);
|
|
54
|
+
doc.setLine(line, cur.slice(0, logical));
|
|
55
|
+
doc.insertLineAt(line + 1, cur.slice(logical));
|
|
56
|
+
doc.setCursor(line + 1, 0);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Delete key: remove the char after the cursor, or join with the next line at end of line. */
|
|
61
|
+
export class DeleteAfterOp {
|
|
62
|
+
_deletedChar = "";
|
|
63
|
+
_joined = false;
|
|
64
|
+
apply(doc) {
|
|
65
|
+
const { line, logical } = doc.cursor;
|
|
66
|
+
const cur = doc.getLine(line);
|
|
67
|
+
if (logical < cur.length) {
|
|
68
|
+
this._deletedChar = cur[logical];
|
|
69
|
+
this._joined = false;
|
|
70
|
+
doc.setLine(line, cur.slice(0, logical) + cur.slice(logical + 1));
|
|
71
|
+
}
|
|
72
|
+
else if (line < doc.lineCount - 1) {
|
|
73
|
+
const next = doc.getLine(line + 1);
|
|
74
|
+
doc.setLine(line, cur + next);
|
|
75
|
+
doc.removeLineAt(line + 1);
|
|
76
|
+
this._joined = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
invert(doc) {
|
|
80
|
+
if (!this._joined) {
|
|
81
|
+
const { line, logical } = doc.cursor;
|
|
82
|
+
const cur = doc.getLine(line);
|
|
83
|
+
doc.setLine(line, cur.slice(0, logical) + this._deletedChar + cur.slice(logical));
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const { line, logical } = doc.cursor;
|
|
87
|
+
const cur = doc.getLine(line);
|
|
88
|
+
doc.setLine(line, cur.slice(0, logical));
|
|
89
|
+
doc.insertLineAt(line + 1, cur.slice(logical));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Enter: split the current line at the cursor; invert rejoins the two lines. */
|
|
94
|
+
export class SplitLineOp {
|
|
95
|
+
apply(doc) {
|
|
96
|
+
const { line, logical } = doc.cursor;
|
|
97
|
+
const cur = doc.getLine(line);
|
|
98
|
+
doc.setLine(line, cur.slice(0, logical));
|
|
99
|
+
doc.insertLineAt(line + 1, cur.slice(logical));
|
|
100
|
+
doc.setCursor(line + 1, 0);
|
|
101
|
+
}
|
|
102
|
+
invert(doc) {
|
|
103
|
+
// Cursor is on the newly created line; the split point is its own start.
|
|
104
|
+
const { line } = doc.cursor;
|
|
105
|
+
const prev = doc.getLine(line - 1);
|
|
106
|
+
const cur = doc.getLine(line);
|
|
107
|
+
doc.setLine(line - 1, prev + cur);
|
|
108
|
+
doc.removeLineAt(line);
|
|
109
|
+
doc.setCursor(line - 1, prev.length);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Join the next line into the current one. `apply` never moves the cursor,
|
|
114
|
+
* so `invert` cannot rely on the cursor position — it records the join point
|
|
115
|
+
* (end of the original current line) instead.
|
|
116
|
+
*/
|
|
117
|
+
export class JoinLineOp {
|
|
118
|
+
_joinAt = 0;
|
|
119
|
+
apply(doc) {
|
|
120
|
+
const { line } = doc.cursor;
|
|
121
|
+
if (line >= doc.lineCount - 1) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const cur = doc.getLine(line);
|
|
125
|
+
const next = doc.getLine(line + 1);
|
|
126
|
+
this._joinAt = cur.length;
|
|
127
|
+
doc.setLine(line, cur + next);
|
|
128
|
+
doc.removeLineAt(line + 1);
|
|
129
|
+
}
|
|
130
|
+
invert(doc) {
|
|
131
|
+
const { line } = doc.cursor;
|
|
132
|
+
const cur = doc.getLine(line);
|
|
133
|
+
doc.setLine(line, cur.slice(0, this._joinAt));
|
|
134
|
+
doc.insertLineAt(line + 1, cur.slice(this._joinAt));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Indent the current line by `indentWidth` spaces, moving the cursor along. */
|
|
138
|
+
export class IndentOp {
|
|
139
|
+
apply(doc) {
|
|
140
|
+
const { line, logical } = doc.cursor;
|
|
141
|
+
const cur = doc.getLine(line);
|
|
142
|
+
const spaces = " ".repeat(doc.indentWidth);
|
|
143
|
+
doc.setLine(line, spaces + cur);
|
|
144
|
+
doc.setCursor(line, logical + doc.indentWidth);
|
|
145
|
+
}
|
|
146
|
+
invert(doc) {
|
|
147
|
+
const { line, logical } = doc.cursor;
|
|
148
|
+
const cur = doc.getLine(line);
|
|
149
|
+
doc.setLine(line, cur.slice(doc.indentWidth));
|
|
150
|
+
doc.setCursor(line, Math.max(0, logical - doc.indentWidth));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** Outdent up to `indentWidth` leading spaces; invert re-adds exactly what was removed. */
|
|
154
|
+
export class OutdentOp {
|
|
155
|
+
_removed = 0;
|
|
156
|
+
apply(doc) {
|
|
157
|
+
const { line, logical } = doc.cursor;
|
|
158
|
+
const cur = doc.getLine(line);
|
|
159
|
+
const leading = /^ */.exec(cur)?.[0].length ?? 0;
|
|
160
|
+
const remove = Math.min(leading, doc.indentWidth);
|
|
161
|
+
this._removed = remove;
|
|
162
|
+
if (remove > 0) {
|
|
163
|
+
doc.setLine(line, cur.slice(remove));
|
|
164
|
+
doc.setCursor(line, Math.max(0, logical - remove));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
invert(doc) {
|
|
168
|
+
if (this._removed === 0) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const { line, logical } = doc.cursor;
|
|
172
|
+
const cur = doc.getLine(line);
|
|
173
|
+
doc.setLine(line, " ".repeat(this._removed) + cur);
|
|
174
|
+
doc.setCursor(line, logical + this._removed);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor coordinate types.
|
|
3
|
+
*
|
|
4
|
+
* Core of the dual-column design: editing uses `logical` (a code unit index,
|
|
5
|
+
* used for slicing/splicing), while display and cross-line movement use
|
|
6
|
+
* `visual` (terminal columns). Keeping them separate prevents cursor drift
|
|
7
|
+
* on wide characters (CJK/emoji).
|
|
8
|
+
*/
|
|
9
|
+
/** Cursor position in the document. Internal state stores only these two; `visual` is derived. */
|
|
10
|
+
export type Position = {
|
|
11
|
+
line: number;
|
|
12
|
+
logical: number;
|
|
13
|
+
};
|
|
14
|
+
/** Exposed cursor state: `visual` is derived from the current line's prefix width. */
|
|
15
|
+
export type CursorState = Position & {
|
|
16
|
+
visual: number;
|
|
17
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor coordinate types.
|
|
3
|
+
*
|
|
4
|
+
* Core of the dual-column design: editing uses `logical` (a code unit index,
|
|
5
|
+
* used for slicing/splicing), while display and cross-line movement use
|
|
6
|
+
* `visual` (terminal columns). Keeping them separate prevents cursor drift
|
|
7
|
+
* on wide characters (CJK/emoji).
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single line of text with its terminal width cached.
|
|
3
|
+
*
|
|
4
|
+
* `_prefix[i]` is the cumulative terminal width of the first `i` code units
|
|
5
|
+
* (0 at line start). Surrogate pairs (e.g. emoji) span 2 code units and share
|
|
6
|
+
* the same prefix at the middle index — the cursor never sits inside a
|
|
7
|
+
* surrogate pair, but aligning the array to code units lets `visualAt` /
|
|
8
|
+
* `logicalAt` use direct indexing and binary search instead of per-character
|
|
9
|
+
* scans on every call.
|
|
10
|
+
*/
|
|
11
|
+
export declare class TextLine {
|
|
12
|
+
readonly text: string;
|
|
13
|
+
private readonly _prefix;
|
|
14
|
+
readonly width: number;
|
|
15
|
+
constructor(text: string);
|
|
16
|
+
/** Terminal column of the given code unit index, clamped to `[0, text.length]`. */
|
|
17
|
+
visualAt(logical: number): number;
|
|
18
|
+
/**
|
|
19
|
+
* Terminal column → nearest valid cursor position (code unit index).
|
|
20
|
+
* When the target column falls inside a wide character, snap left
|
|
21
|
+
* (largest prefix not exceeding the target).
|
|
22
|
+
*/
|
|
23
|
+
logicalAt(visual: number): number;
|
|
24
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import stringWidth from "string-width";
|
|
2
|
+
/**
|
|
3
|
+
* A single line of text with its terminal width cached.
|
|
4
|
+
*
|
|
5
|
+
* `_prefix[i]` is the cumulative terminal width of the first `i` code units
|
|
6
|
+
* (0 at line start). Surrogate pairs (e.g. emoji) span 2 code units and share
|
|
7
|
+
* the same prefix at the middle index — the cursor never sits inside a
|
|
8
|
+
* surrogate pair, but aligning the array to code units lets `visualAt` /
|
|
9
|
+
* `logicalAt` use direct indexing and binary search instead of per-character
|
|
10
|
+
* scans on every call.
|
|
11
|
+
*/
|
|
12
|
+
export class TextLine {
|
|
13
|
+
text;
|
|
14
|
+
_prefix;
|
|
15
|
+
width;
|
|
16
|
+
constructor(text) {
|
|
17
|
+
this.text = text;
|
|
18
|
+
const prefix = [0];
|
|
19
|
+
let sum = 0;
|
|
20
|
+
for (let i = 0; i < text.length;) {
|
|
21
|
+
const cp = text.codePointAt(i);
|
|
22
|
+
const w = stringWidth(String.fromCodePoint(cp));
|
|
23
|
+
const units = cp > 0xffff ? 2 : 1;
|
|
24
|
+
for (let k = 0; k < units; k++) {
|
|
25
|
+
prefix.push(sum + w);
|
|
26
|
+
}
|
|
27
|
+
sum += w;
|
|
28
|
+
i += units;
|
|
29
|
+
}
|
|
30
|
+
this._prefix = prefix;
|
|
31
|
+
this.width = sum;
|
|
32
|
+
}
|
|
33
|
+
/** Terminal column of the given code unit index, clamped to `[0, text.length]`. */
|
|
34
|
+
visualAt(logical) {
|
|
35
|
+
const i = Math.max(0, Math.min(logical, this.text.length));
|
|
36
|
+
return this._prefix[i];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Terminal column → nearest valid cursor position (code unit index).
|
|
40
|
+
* When the target column falls inside a wide character, snap left
|
|
41
|
+
* (largest prefix not exceeding the target).
|
|
42
|
+
*/
|
|
43
|
+
logicalAt(visual) {
|
|
44
|
+
if (visual <= 0) {
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
let lo = 0;
|
|
48
|
+
let hi = this.text.length;
|
|
49
|
+
while (lo < hi) {
|
|
50
|
+
const mid = (lo + hi + 1) >> 1;
|
|
51
|
+
if (this._prefix[mid] <= visual) {
|
|
52
|
+
lo = mid;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
hi = mid - 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return lo;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Document } from "./document/document.js";
|
|
2
|
+
export type EditorOptions = {
|
|
3
|
+
indentWidth?: number;
|
|
4
|
+
};
|
|
5
|
+
export type EditorCommandArgs = Record<string, unknown> | undefined;
|
|
6
|
+
export type EditorCommandHandler = (doc: Document, args: EditorCommandArgs) => void;
|
|
7
|
+
type ChangeListener = () => void;
|
|
8
|
+
/**
|
|
9
|
+
* Coordinator between the pure core and the keymap/render layers.
|
|
10
|
+
*
|
|
11
|
+
* Owns the document, a command registry (so P2 vim mode only swaps key
|
|
12
|
+
* bindings, never touching the core), and the change-notification channel
|
|
13
|
+
* the view subscribes to. Editing commands run operations that carry their
|
|
14
|
+
* own `invert`; the history stack (P1) will collect them.
|
|
15
|
+
*/
|
|
16
|
+
export declare class EditorController {
|
|
17
|
+
private readonly _document;
|
|
18
|
+
private readonly _commands;
|
|
19
|
+
private readonly _listeners;
|
|
20
|
+
constructor(text: string, options?: EditorOptions);
|
|
21
|
+
get document(): Document;
|
|
22
|
+
onChange(listener: ChangeListener): () => void;
|
|
23
|
+
defineCommand(id: string, handler: EditorCommandHandler): this;
|
|
24
|
+
execute(id: string, args?: EditorCommandArgs): void;
|
|
25
|
+
private _registerBuiltins;
|
|
26
|
+
}
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Document } from "./document/document.js";
|
|
2
|
+
import { DeleteAfterOp, DeleteBeforeOp, IndentOp, InsertTextOp, JoinLineOp, OutdentOp, SplitLineOp, } from "./document/operations.js";
|
|
3
|
+
/**
|
|
4
|
+
* Coordinator between the pure core and the keymap/render layers.
|
|
5
|
+
*
|
|
6
|
+
* Owns the document, a command registry (so P2 vim mode only swaps key
|
|
7
|
+
* bindings, never touching the core), and the change-notification channel
|
|
8
|
+
* the view subscribes to. Editing commands run operations that carry their
|
|
9
|
+
* own `invert`; the history stack (P1) will collect them.
|
|
10
|
+
*/
|
|
11
|
+
export class EditorController {
|
|
12
|
+
_document;
|
|
13
|
+
_commands = new Map();
|
|
14
|
+
_listeners = new Set();
|
|
15
|
+
constructor(text, options = {}) {
|
|
16
|
+
this._document = new Document(text, { indentWidth: options.indentWidth });
|
|
17
|
+
this._registerBuiltins();
|
|
18
|
+
}
|
|
19
|
+
get document() {
|
|
20
|
+
return this._document;
|
|
21
|
+
}
|
|
22
|
+
onChange(listener) {
|
|
23
|
+
this._listeners.add(listener);
|
|
24
|
+
return () => {
|
|
25
|
+
this._listeners.delete(listener);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
defineCommand(id, handler) {
|
|
29
|
+
this._commands.set(id, handler);
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
execute(id, args = undefined) {
|
|
33
|
+
const handler = this._commands.get(id);
|
|
34
|
+
if (!handler) {
|
|
35
|
+
throw new Error(`[ink-cartridge] Unknown editor command: ${id}`);
|
|
36
|
+
}
|
|
37
|
+
handler(this._document, args);
|
|
38
|
+
this._listeners.forEach((fn) => fn());
|
|
39
|
+
}
|
|
40
|
+
_registerBuiltins() {
|
|
41
|
+
this.defineCommand("editor.insertText", (doc, args) => {
|
|
42
|
+
const text = typeof args?.text === "string" ? args.text : "";
|
|
43
|
+
if (text) {
|
|
44
|
+
new InsertTextOp(text).apply(doc);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
this.defineCommand("editor.deleteBefore", (doc) => {
|
|
48
|
+
new DeleteBeforeOp().apply(doc);
|
|
49
|
+
});
|
|
50
|
+
this.defineCommand("editor.deleteAfter", (doc) => {
|
|
51
|
+
new DeleteAfterOp().apply(doc);
|
|
52
|
+
});
|
|
53
|
+
this.defineCommand("editor.splitLine", (doc) => {
|
|
54
|
+
new SplitLineOp().apply(doc);
|
|
55
|
+
});
|
|
56
|
+
this.defineCommand("editor.joinLine", (doc) => {
|
|
57
|
+
new JoinLineOp().apply(doc);
|
|
58
|
+
});
|
|
59
|
+
this.defineCommand("editor.indent", (doc) => {
|
|
60
|
+
new IndentOp().apply(doc);
|
|
61
|
+
});
|
|
62
|
+
this.defineCommand("editor.outdent", (doc) => {
|
|
63
|
+
new OutdentOp().apply(doc);
|
|
64
|
+
});
|
|
65
|
+
this.defineCommand("cursor.moveLeft", (doc) => doc.moveLeft());
|
|
66
|
+
this.defineCommand("cursor.moveRight", (doc) => doc.moveRight());
|
|
67
|
+
this.defineCommand("cursor.moveUp", (doc) => doc.moveUp());
|
|
68
|
+
this.defineCommand("cursor.moveDown", (doc) => doc.moveDown());
|
|
69
|
+
this.defineCommand("cursor.lineStart", (doc) => doc.moveToLineStart());
|
|
70
|
+
this.defineCommand("cursor.lineEnd", (doc) => doc.moveToLineEnd());
|
|
71
|
+
this.defineCommand("cursor.wordForward", (doc) => doc.moveWordForward());
|
|
72
|
+
this.defineCommand("cursor.wordBackward", (doc) => doc.moveWordBackward());
|
|
73
|
+
this.defineCommand("cursor.documentStart", (doc) => doc.moveToDocumentStart());
|
|
74
|
+
this.defineCommand("cursor.documentEnd", (doc) => doc.moveToDocumentEnd());
|
|
75
|
+
this.defineCommand("cursor.pageUp", (doc, args) => {
|
|
76
|
+
const height = typeof args?.height === "number" ? args.height : 1;
|
|
77
|
+
doc.movePageUp(height);
|
|
78
|
+
});
|
|
79
|
+
this.defineCommand("cursor.pageDown", (doc, args) => {
|
|
80
|
+
const height = typeof args?.height === "number" ? args.height : 1;
|
|
81
|
+
doc.movePageDown(height);
|
|
82
|
+
});
|
|
83
|
+
// Mouse click: position the cursor by terminal column (not code units).
|
|
84
|
+
this.defineCommand("cursor.setPosition", (doc, args) => {
|
|
85
|
+
const line = typeof args?.line === "number" ? args.line : doc.cursor.line;
|
|
86
|
+
const visual = typeof args?.visual === "number" ? args.visual : doc.cursor.visual;
|
|
87
|
+
doc.setCursorAtVisual(line, visual);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
1
2
|
import { Box, render } from "ink";
|
|
2
3
|
import React from "react";
|
|
3
|
-
import { InformationBar } from "./
|
|
4
|
+
import { InformationBar } from "./view/information-bar.js";
|
|
4
5
|
import { CurrentScreen, KeyboardProvider, registerComponent, ScenarioManagementProvider, } from "ink-cartridge";
|
|
5
|
-
import { Editor } from "./
|
|
6
|
+
import { Editor } from "./view/editor.js";
|
|
6
7
|
function App() {
|
|
7
8
|
return (React.createElement(Box, { flexDirection: "column", height: "100%", width: "100%" },
|
|
8
9
|
React.createElement(Box, { flexGrow: 1 },
|
|
@@ -12,5 +13,5 @@ function App() {
|
|
|
12
13
|
}
|
|
13
14
|
registerComponent(App, {});
|
|
14
15
|
render(React.createElement(ScenarioManagementProvider, { defaultScreen: App, fullScreen: true },
|
|
15
|
-
React.createElement(KeyboardProvider, { autoTab: false },
|
|
16
|
+
React.createElement(KeyboardProvider, { autoTab: false, mouse: true },
|
|
16
17
|
React.createElement(CurrentScreen, null))));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type ClickTarget = {
|
|
2
|
+
line: number;
|
|
3
|
+
visual: number;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Convert a 1-based terminal click (from `useMouseRegion`) into a document
|
|
7
|
+
* position, accounting for the line-number gutter and the vertical scroll
|
|
8
|
+
* offset.
|
|
9
|
+
*
|
|
10
|
+
* Assumes the region is the editor box itself with no padding, so
|
|
11
|
+
* `event.x - rect.x` is a 0-based cell column inside the region. Columns
|
|
12
|
+
* left of the gutter (clicks on the line numbers) snap to the text area's
|
|
13
|
+
* left edge; rows past the last document line clamp to it.
|
|
14
|
+
*/
|
|
15
|
+
export declare function clickToPosition(event: {
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
}, rect: {
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
}, gutterWidth: number, visibleStart: number, lineCount: number): ClickTarget;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a 1-based terminal click (from `useMouseRegion`) into a document
|
|
3
|
+
* position, accounting for the line-number gutter and the vertical scroll
|
|
4
|
+
* offset.
|
|
5
|
+
*
|
|
6
|
+
* Assumes the region is the editor box itself with no padding, so
|
|
7
|
+
* `event.x - rect.x` is a 0-based cell column inside the region. Columns
|
|
8
|
+
* left of the gutter (clicks on the line numbers) snap to the text area's
|
|
9
|
+
* left edge; rows past the last document line clamp to it.
|
|
10
|
+
*/
|
|
11
|
+
export function clickToPosition(event, rect, gutterWidth, visibleStart, lineCount) {
|
|
12
|
+
const localX = event.x - rect.x;
|
|
13
|
+
const localY = event.y - rect.y;
|
|
14
|
+
const visual = Math.max(0, localX - gutterWidth);
|
|
15
|
+
const line = Math.max(0, Math.min(visibleStart + localY, lineCount - 1));
|
|
16
|
+
return { line, visual };
|
|
17
|
+
}
|
|
@@ -7,5 +7,10 @@ export type EditorContext = {
|
|
|
7
7
|
cursorHeightOffset?: number;
|
|
8
8
|
numberOfIndentationSpaces?: number;
|
|
9
9
|
};
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Main editor view: renders line numbers + text and routes every keystroke
|
|
12
|
+
* to the controller as a named command. Rendering diffs via the controller's
|
|
13
|
+
* change notifications; the controller lives in a ref so its state survives
|
|
14
|
+
* re-renders (same pattern the old TextCalculation used).
|
|
15
|
+
*/
|
|
11
16
|
export declare function Editor({ value: initialText, lineNumberRightSpacing, cursorOffset, cursorHeightOffset, numberOfIndentationSpaces, }: EditorContext): React.JSX.Element;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Box, Text, useBoxMetrics, useCursor } from "ink";
|
|
2
|
+
import { useKeyboard, useMouseRegion } from "ink-cartridge";
|
|
3
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { EditorController } from "../core/editor-controller.js";
|
|
5
|
+
import { clickToPosition } from "./click-mapping.js";
|
|
6
|
+
/**
|
|
7
|
+
* Main editor view: renders line numbers + text and routes every keystroke
|
|
8
|
+
* to the controller as a named command. Rendering diffs via the controller's
|
|
9
|
+
* change notifications; the controller lives in a ref so its state survives
|
|
10
|
+
* re-renders (same pattern the old TextCalculation used).
|
|
11
|
+
*/
|
|
12
|
+
export function Editor({ value: initialText, lineNumberRightSpacing = 1, cursorOffset = 0, cursorHeightOffset = 2, numberOfIndentationSpaces = 2, }) {
|
|
13
|
+
const controllerRef = useRef(null);
|
|
14
|
+
if (!controllerRef.current) {
|
|
15
|
+
controllerRef.current = new EditorController(initialText, {
|
|
16
|
+
indentWidth: numberOfIndentationSpaces,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
const controller = controllerRef.current;
|
|
20
|
+
const [, forceUpdate] = useState(0);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
return controller.onChange(() => forceUpdate((n) => n + 1));
|
|
23
|
+
}, [controller]);
|
|
24
|
+
const { setCursorPosition } = useCursor();
|
|
25
|
+
const { boundKeyboard, enableWildcardPriority } = useKeyboard();
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const removeWildcard = enableWildcardPriority();
|
|
28
|
+
const unbinds = [];
|
|
29
|
+
unbinds.push(boundKeyboard(["*"], (input) => controller.execute("editor.insertText", { text: input })));
|
|
30
|
+
unbinds.push(boundKeyboard(["return"], () => controller.execute("editor.splitLine")));
|
|
31
|
+
unbinds.push(boundKeyboard(["right"], () => controller.execute("cursor.moveRight")));
|
|
32
|
+
unbinds.push(boundKeyboard(["left"], () => controller.execute("cursor.moveLeft")));
|
|
33
|
+
// backspace deletes before the cursor; delete removes after it —
|
|
34
|
+
// the old prototype treated both as backspace.
|
|
35
|
+
unbinds.push(boundKeyboard(["backspace"], () => controller.execute("editor.deleteBefore")));
|
|
36
|
+
unbinds.push(boundKeyboard(["delete"], () => controller.execute("editor.deleteAfter")));
|
|
37
|
+
unbinds.push(boundKeyboard(["up"], () => controller.execute("cursor.moveUp")));
|
|
38
|
+
unbinds.push(boundKeyboard(["down"], () => controller.execute("cursor.moveDown")));
|
|
39
|
+
unbinds.push(boundKeyboard(["tab"], () => controller.execute("editor.indent")));
|
|
40
|
+
unbinds.push(boundKeyboard(["shift+tab"], () => controller.execute("editor.outdent")));
|
|
41
|
+
return () => {
|
|
42
|
+
removeWildcard();
|
|
43
|
+
unbinds.forEach((fn) => fn());
|
|
44
|
+
};
|
|
45
|
+
}, [boundKeyboard, enableWildcardPriority, controller]);
|
|
46
|
+
const ref = useRef(null);
|
|
47
|
+
const { height } = useBoxMetrics(ref);
|
|
48
|
+
const doc = controller.document;
|
|
49
|
+
const lineNumberWidth = doc.getLineNumberWidth();
|
|
50
|
+
const visibleStart = doc.updateScroll(height);
|
|
51
|
+
const effectiveH = height > 0 ? height : doc.lineCount;
|
|
52
|
+
const visibleLines = doc.lines.slice(visibleStart, visibleStart + effectiveH);
|
|
53
|
+
const cursor = doc.cursor;
|
|
54
|
+
const cursorX = cursor.visual + lineNumberWidth + lineNumberRightSpacing + cursorOffset;
|
|
55
|
+
const cursorY = cursor.line - visibleStart + cursorHeightOffset;
|
|
56
|
+
setCursorPosition({ x: cursorX, y: cursorY });
|
|
57
|
+
// Mouse: clicks position the cursor; the wheel scrolls one line at a time.
|
|
58
|
+
// The callbacks close over this render's metrics, and useMouseRegion keeps
|
|
59
|
+
// them fresh, so scrollTop/gutter always match what the user sees.
|
|
60
|
+
const mouseRef = useMouseRegion({
|
|
61
|
+
onClick: (event, rect) => {
|
|
62
|
+
const target = clickToPosition(event, rect, lineNumberWidth + lineNumberRightSpacing, doc.scrollTop, doc.lineCount);
|
|
63
|
+
controller.execute("cursor.setPosition", {
|
|
64
|
+
line: target.line,
|
|
65
|
+
visual: target.visual,
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
onWheel: (event) => {
|
|
69
|
+
if (event.button === "wheel-up") {
|
|
70
|
+
controller.execute("cursor.pageUp", { height: 1 });
|
|
71
|
+
}
|
|
72
|
+
else if (event.button === "wheel-down") {
|
|
73
|
+
controller.execute("cursor.pageDown", { height: 1 });
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
return (React.createElement(Box, { ref: mouseRef, height: "100%", width: "100%", backgroundColor: "#1e1e1e" },
|
|
78
|
+
React.createElement(Box, { ref: ref, height: "100%", width: "100%", flexDirection: "column" }, visibleLines.map((each, i) => {
|
|
79
|
+
const lineNumber = visibleStart + i;
|
|
80
|
+
return (React.createElement(Box, { key: lineNumber, flexDirection: "row" },
|
|
81
|
+
React.createElement(Box, { width: lineNumberWidth, justifyContent: "flex-end", marginRight: lineNumberRightSpacing },
|
|
82
|
+
React.createElement(Text, { bold: cursor.line === lineNumber }, lineNumber)),
|
|
83
|
+
React.createElement(Text, null, each)));
|
|
84
|
+
}))));
|
|
85
|
+
}
|
package/package.json
CHANGED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
export type CursorCoordinates = {
|
|
2
|
-
line: number;
|
|
3
|
-
column: number;
|
|
4
|
-
};
|
|
5
|
-
export type TextCalcOptions = {
|
|
6
|
-
numberOfIndentationSpaces?: number;
|
|
7
|
-
cursorOffset?: number;
|
|
8
|
-
cursorHeightOffset?: number;
|
|
9
|
-
lineNumberRightSpacing?: number;
|
|
10
|
-
};
|
|
11
|
-
type ChangeListener = () => void;
|
|
12
|
-
export declare class TextCalculation {
|
|
13
|
-
private _lines;
|
|
14
|
-
private _cursor;
|
|
15
|
-
private _scrollTop;
|
|
16
|
-
private _listeners;
|
|
17
|
-
readonly numberOfIndentationSpaces: number;
|
|
18
|
-
readonly cursorOffset: number;
|
|
19
|
-
readonly cursorHeightOffset: number;
|
|
20
|
-
readonly lineNumberRightSpacing: number;
|
|
21
|
-
constructor(text: string, options?: TextCalcOptions);
|
|
22
|
-
onChange(listener: ChangeListener): () => void;
|
|
23
|
-
private _notify;
|
|
24
|
-
get lines(): string[];
|
|
25
|
-
get cursor(): CursorCoordinates;
|
|
26
|
-
get lineCount(): number;
|
|
27
|
-
getLineNumberWidth(): number;
|
|
28
|
-
getCurrentLineText(): string;
|
|
29
|
-
getCursorX(lineNumberWidth: number): number;
|
|
30
|
-
updateScroll(height: number): number;
|
|
31
|
-
insertChar(char: string): void;
|
|
32
|
-
newLine(): void;
|
|
33
|
-
moveRight(): void;
|
|
34
|
-
moveLeft(): void;
|
|
35
|
-
moveUp(): void;
|
|
36
|
-
moveDown(): void;
|
|
37
|
-
backspace(): void;
|
|
38
|
-
indent(): void;
|
|
39
|
-
outdent(): void;
|
|
40
|
-
}
|
|
41
|
-
export {};
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
import stringWidth from "string-width";
|
|
2
|
-
export class TextCalculation {
|
|
3
|
-
_lines;
|
|
4
|
-
_cursor;
|
|
5
|
-
_scrollTop;
|
|
6
|
-
_listeners = new Set();
|
|
7
|
-
numberOfIndentationSpaces;
|
|
8
|
-
cursorOffset;
|
|
9
|
-
cursorHeightOffset;
|
|
10
|
-
lineNumberRightSpacing;
|
|
11
|
-
constructor(text, options = {}) {
|
|
12
|
-
this._lines = text ? text.split(/\r?\n/) : [];
|
|
13
|
-
this._cursor = { line: 0, column: 0 };
|
|
14
|
-
this._scrollTop = 0;
|
|
15
|
-
this.numberOfIndentationSpaces = options.numberOfIndentationSpaces ?? 2;
|
|
16
|
-
this.cursorOffset = options.cursorOffset ?? 0;
|
|
17
|
-
this.cursorHeightOffset = options.cursorHeightOffset ?? 2;
|
|
18
|
-
this.lineNumberRightSpacing = Math.max(0, options.lineNumberRightSpacing ?? 1);
|
|
19
|
-
}
|
|
20
|
-
onChange(listener) {
|
|
21
|
-
this._listeners.add(listener);
|
|
22
|
-
return () => {
|
|
23
|
-
this._listeners.delete(listener);
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
_notify() {
|
|
27
|
-
this._listeners.forEach((fn) => fn());
|
|
28
|
-
}
|
|
29
|
-
get lines() {
|
|
30
|
-
return this._lines;
|
|
31
|
-
}
|
|
32
|
-
get cursor() {
|
|
33
|
-
return this._cursor;
|
|
34
|
-
}
|
|
35
|
-
get lineCount() {
|
|
36
|
-
return this._lines.length;
|
|
37
|
-
}
|
|
38
|
-
getLineNumberWidth() {
|
|
39
|
-
return Math.max(stringWidth(String(this._lines.length - 1)), 1);
|
|
40
|
-
}
|
|
41
|
-
getCurrentLineText() {
|
|
42
|
-
return this._lines[this._cursor.line] ?? "";
|
|
43
|
-
}
|
|
44
|
-
getCursorX(lineNumberWidth) {
|
|
45
|
-
const text = this.getCurrentLineText();
|
|
46
|
-
const clamped = Math.max(0, Math.min(text.length, this._cursor.column));
|
|
47
|
-
const visual = stringWidth(text.slice(0, clamped));
|
|
48
|
-
return (visual + lineNumberWidth + this.lineNumberRightSpacing + this.cursorOffset);
|
|
49
|
-
}
|
|
50
|
-
updateScroll(height) {
|
|
51
|
-
const effectiveH = height > 0 ? height : this._lines.length;
|
|
52
|
-
let vs = this._scrollTop;
|
|
53
|
-
if (this._cursor.line < vs) {
|
|
54
|
-
vs = this._cursor.line;
|
|
55
|
-
}
|
|
56
|
-
else if (this._cursor.line >= vs + effectiveH) {
|
|
57
|
-
vs = this._cursor.line - effectiveH + 1;
|
|
58
|
-
}
|
|
59
|
-
const maxScroll = Math.max(0, this._lines.length - effectiveH);
|
|
60
|
-
vs = Math.max(0, Math.min(vs, maxScroll));
|
|
61
|
-
this._scrollTop = vs;
|
|
62
|
-
return vs;
|
|
63
|
-
}
|
|
64
|
-
insertChar(char) {
|
|
65
|
-
const { line, column } = this._cursor;
|
|
66
|
-
this._lines[line] =
|
|
67
|
-
this._lines[line].slice(0, column) +
|
|
68
|
-
char +
|
|
69
|
-
this._lines[line].slice(column);
|
|
70
|
-
this._cursor = { line, column: column + char.length };
|
|
71
|
-
this._notify();
|
|
72
|
-
}
|
|
73
|
-
newLine() {
|
|
74
|
-
const { line, column } = this._cursor;
|
|
75
|
-
const current = this._lines[line];
|
|
76
|
-
this._lines[line] = current.slice(0, column);
|
|
77
|
-
this._lines.splice(line + 1, 0, current.slice(column));
|
|
78
|
-
this._cursor = { line: line + 1, column: 0 };
|
|
79
|
-
this._notify();
|
|
80
|
-
}
|
|
81
|
-
moveRight() {
|
|
82
|
-
const maxCol = this._lines[this._cursor.line]?.length ?? 0;
|
|
83
|
-
this._cursor = {
|
|
84
|
-
line: this._cursor.line,
|
|
85
|
-
column: Math.min(this._cursor.column + 1, maxCol),
|
|
86
|
-
};
|
|
87
|
-
this._notify();
|
|
88
|
-
}
|
|
89
|
-
moveLeft() {
|
|
90
|
-
this._cursor = {
|
|
91
|
-
line: this._cursor.line,
|
|
92
|
-
column: Math.max(this._cursor.column - 1, 0),
|
|
93
|
-
};
|
|
94
|
-
this._notify();
|
|
95
|
-
}
|
|
96
|
-
moveUp() {
|
|
97
|
-
const newLine = Math.max(this._cursor.line - 1, 0);
|
|
98
|
-
const maxCol = this._lines[newLine]?.length ?? 0;
|
|
99
|
-
this._cursor = {
|
|
100
|
-
line: newLine,
|
|
101
|
-
column: Math.min(maxCol, this._cursor.column),
|
|
102
|
-
};
|
|
103
|
-
this._notify();
|
|
104
|
-
}
|
|
105
|
-
moveDown() {
|
|
106
|
-
const newLine = Math.min(this._cursor.line + 1, this._lines.length - 1);
|
|
107
|
-
const maxCol = this._lines[newLine]?.length ?? 0;
|
|
108
|
-
this._cursor = {
|
|
109
|
-
line: newLine,
|
|
110
|
-
column: Math.min(maxCol, this._cursor.column),
|
|
111
|
-
};
|
|
112
|
-
this._notify();
|
|
113
|
-
}
|
|
114
|
-
backspace() {
|
|
115
|
-
const { line, column } = this._cursor;
|
|
116
|
-
if (column === 0 && line !== 0) {
|
|
117
|
-
const prevLen = this._lines[line - 1].length;
|
|
118
|
-
this._lines[line - 1] += this._lines[line];
|
|
119
|
-
this._lines.splice(line, 1);
|
|
120
|
-
this._cursor = { line: line - 1, column: prevLen };
|
|
121
|
-
}
|
|
122
|
-
else if (column > 0) {
|
|
123
|
-
this._lines[line] =
|
|
124
|
-
this._lines[line].slice(0, column - 1) +
|
|
125
|
-
this._lines[line].slice(column);
|
|
126
|
-
this._cursor = { line, column: column - 1 };
|
|
127
|
-
}
|
|
128
|
-
this._notify();
|
|
129
|
-
}
|
|
130
|
-
indent() {
|
|
131
|
-
const { line } = this._cursor;
|
|
132
|
-
const spaces = " ".repeat(this.numberOfIndentationSpaces);
|
|
133
|
-
this._lines[line] = spaces + this._lines[line];
|
|
134
|
-
this._cursor = {
|
|
135
|
-
line,
|
|
136
|
-
column: this._cursor.column + this.numberOfIndentationSpaces,
|
|
137
|
-
};
|
|
138
|
-
this._notify();
|
|
139
|
-
}
|
|
140
|
-
outdent() {
|
|
141
|
-
const { line } = this._cursor;
|
|
142
|
-
const current = this._lines[line];
|
|
143
|
-
const leadingSpaces = current.match(/^ */)?.[0].length || 0;
|
|
144
|
-
const n = this.numberOfIndentationSpaces;
|
|
145
|
-
if (leadingSpaces >= n) {
|
|
146
|
-
this._lines[line] = current.slice(n);
|
|
147
|
-
this._cursor = {
|
|
148
|
-
line,
|
|
149
|
-
column: Math.max(0, this._cursor.column - n),
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
else if (leadingSpaces > 0) {
|
|
153
|
-
this._lines[line] = current.slice(leadingSpaces);
|
|
154
|
-
this._cursor = {
|
|
155
|
-
line,
|
|
156
|
-
column: Math.max(0, this._cursor.column - leadingSpaces),
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
this._notify();
|
|
160
|
-
}
|
|
161
|
-
}
|
package/dist/core/view/editor.js
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { Box, Text, useBoxMetrics, useCursor } from "ink";
|
|
2
|
-
import { useKeyboard } from "ink-cartridge";
|
|
3
|
-
import React, { useEffect, useRef, useState } from "react";
|
|
4
|
-
import { TextCalculation } from "../text/text-based-calculation.js";
|
|
5
|
-
export function Editor({ value: initialText, lineNumberRightSpacing, cursorOffset, cursorHeightOffset, numberOfIndentationSpaces, }) {
|
|
6
|
-
const calcRef = useRef(null);
|
|
7
|
-
if (!calcRef.current) {
|
|
8
|
-
calcRef.current = new TextCalculation(initialText, {
|
|
9
|
-
lineNumberRightSpacing,
|
|
10
|
-
cursorOffset,
|
|
11
|
-
cursorHeightOffset,
|
|
12
|
-
numberOfIndentationSpaces,
|
|
13
|
-
});
|
|
14
|
-
}
|
|
15
|
-
const calc = calcRef.current;
|
|
16
|
-
const [, forceUpdate] = useState(0);
|
|
17
|
-
useEffect(() => {
|
|
18
|
-
return calc.onChange(() => forceUpdate((n) => n + 1));
|
|
19
|
-
}, [calc]);
|
|
20
|
-
const { setCursorPosition } = useCursor();
|
|
21
|
-
const { boundKeyboard, enableWildcardPriority } = useKeyboard();
|
|
22
|
-
useEffect(() => {
|
|
23
|
-
const removeWildcard = enableWildcardPriority();
|
|
24
|
-
const unbinds = [];
|
|
25
|
-
unbinds.push(boundKeyboard(["*"], (input) => calc.insertChar(input)));
|
|
26
|
-
unbinds.push(boundKeyboard(["return"], () => calc.newLine()));
|
|
27
|
-
unbinds.push(boundKeyboard(["right"], () => calc.moveRight()));
|
|
28
|
-
unbinds.push(boundKeyboard(["left"], () => calc.moveLeft()));
|
|
29
|
-
unbinds.push(boundKeyboard(["backspace", "delete"], () => calc.backspace()));
|
|
30
|
-
unbinds.push(boundKeyboard(["up"], () => calc.moveUp()));
|
|
31
|
-
unbinds.push(boundKeyboard(["down"], () => calc.moveDown()));
|
|
32
|
-
unbinds.push(boundKeyboard(["tab"], () => calc.indent()));
|
|
33
|
-
unbinds.push(boundKeyboard(["shift+tab"], () => calc.outdent()));
|
|
34
|
-
return () => {
|
|
35
|
-
removeWildcard();
|
|
36
|
-
unbinds.forEach((fn) => fn());
|
|
37
|
-
};
|
|
38
|
-
}, [boundKeyboard, enableWildcardPriority, calc]);
|
|
39
|
-
const ref = useRef(null);
|
|
40
|
-
const { height } = useBoxMetrics(ref);
|
|
41
|
-
const lineNumberWidth = calc.getLineNumberWidth();
|
|
42
|
-
const visibleStart = calc.updateScroll(height);
|
|
43
|
-
const effectiveH = height > 0 ? height : calc.lineCount;
|
|
44
|
-
const visibleLines = calc.lines.slice(visibleStart, visibleStart + effectiveH);
|
|
45
|
-
const cursorX = calc.getCursorX(lineNumberWidth);
|
|
46
|
-
const cursorY = calc.cursor.line - visibleStart + calc.cursorHeightOffset;
|
|
47
|
-
setCursorPosition({ x: cursorX, y: cursorY });
|
|
48
|
-
return (React.createElement(Box, { ref: ref, height: "100%", width: "100%", backgroundColor: "#1e1e1e", flexDirection: "column" }, visibleLines.map((each, i) => {
|
|
49
|
-
const lineNumber = visibleStart + i;
|
|
50
|
-
return (React.createElement(Box, { key: lineNumber, flexDirection: "row" },
|
|
51
|
-
React.createElement(Box, { width: lineNumberWidth, justifyContent: "flex-end", marginRight: calc.lineNumberRightSpacing },
|
|
52
|
-
React.createElement(Text, { bold: calc.cursor.line === lineNumber }, lineNumber)),
|
|
53
|
-
React.createElement(Text, null, each)));
|
|
54
|
-
})));
|
|
55
|
-
}
|
|
File without changes
|
|
File without changes
|