blots-editor 0.0.15 → 0.0.17
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/asset/language/en.json +0 -1
- package/asset/language/zh.json +0 -1
- package/dist/core/document/document.d.ts +52 -5
- package/dist/core/document/document.js +133 -43
- package/dist/core/editor-controller.js +8 -2
- package/dist/core/io/file-tree-model.d.ts +7 -2
- package/dist/core/io/file-tree-model.js +9 -2
- package/dist/index.js +2 -1
- package/dist/view/editor/command-bar.js +2 -1
- package/dist/view/editor/editor-setting.js +2 -1
- package/dist/view/editor/file-tree.d.ts +0 -4
- package/dist/view/editor/file-tree.js +4 -7
- package/dist/view/editor/tool-bar.js +3 -2
- package/dist/view/page/editor.js +54 -4
- package/dist/view/page/main-menu.js +2 -1
- package/dist/view/page/settings.js +2 -1
- package/dist/view/utils/sensitivity-bar.js +2 -1
- package/package.json +2 -1
package/asset/language/en.json
CHANGED
package/asset/language/zh.json
CHANGED
|
@@ -20,6 +20,23 @@ export declare class Document {
|
|
|
20
20
|
private _wrapWidth;
|
|
21
21
|
private _viewScrollTop;
|
|
22
22
|
private _scrollRemainder;
|
|
23
|
+
/**
|
|
24
|
+
* Per-line soft-wrap segment cache. Segments are derived purely from
|
|
25
|
+
* `_lines` + `_wrapWidth`, but recomputing them is expensive (binary
|
|
26
|
+
* searches + per-character tab expansion), and the render path queries
|
|
27
|
+
* them O(viewport) times per frame — so identical inputs must not be
|
|
28
|
+
* recomputed. null while invalidated; rebuilt lazily on first use.
|
|
29
|
+
*/
|
|
30
|
+
private _segmentCache;
|
|
31
|
+
/**
|
|
32
|
+
* Prefix sums of segment counts: `prefix[i]` = visual lines occupied by
|
|
33
|
+
* the first `i` logical lines (so `prefix[line]` is the global visual
|
|
34
|
+
* line where logical line `line` starts). Lets `visualLineAt` /
|
|
35
|
+
* `cursorVisualLine` / `visualLineCount` answer in O(log n)/O(1)
|
|
36
|
+
* instead of scanning every line. Invalidated together with
|
|
37
|
+
* `_segmentCache`.
|
|
38
|
+
*/
|
|
39
|
+
private _segPrefix;
|
|
23
40
|
readonly indentWidth: number;
|
|
24
41
|
constructor(text: string, options?: DocumentOptions);
|
|
25
42
|
get lineCount(): number;
|
|
@@ -45,14 +62,36 @@ export declare class Document {
|
|
|
45
62
|
get wrapWidth(): number;
|
|
46
63
|
/** Set the soft-wrap width (clamped to at least 1 cell). */
|
|
47
64
|
setWrapWidth(width: number): void;
|
|
48
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* The soft-wrap segments of one logical line (an empty line is one empty
|
|
67
|
+
* segment). Cached: the render path queries these O(viewport) times per
|
|
68
|
+
* frame, and every hit would otherwise re-run `segmentFrom` (binary
|
|
69
|
+
* search + per-character tab expansion) on unchanged input.
|
|
70
|
+
*/
|
|
49
71
|
private lineSegments;
|
|
72
|
+
/**
|
|
73
|
+
* Prefix sums of segment counts, built lazily from the segment cache.
|
|
74
|
+
* Both caches are invalidated together (see {@link invalidateIndex}), so
|
|
75
|
+
* they can never disagree once built.
|
|
76
|
+
*/
|
|
77
|
+
private buildSegmentPrefix;
|
|
78
|
+
/**
|
|
79
|
+
* Drop the segment cache and the visual-line index. Every mutation entry
|
|
80
|
+
* point (`setText`, `setLine`, `insertLineAt`, `removeLineAt`, and a
|
|
81
|
+
* *changed* `setWrapWidth`) must call this, or queries would keep serving
|
|
82
|
+
* stale segments. Rebuilding is lazy — the next query pays O(total
|
|
83
|
+
* segments) once.
|
|
84
|
+
*/
|
|
85
|
+
private invalidateIndex;
|
|
50
86
|
/** Total number of visual (soft-wrapped) lines across the document. */
|
|
51
87
|
get visualLineCount(): number;
|
|
52
88
|
/**
|
|
53
89
|
* Map a global visual line to its logical line and the segment's logical
|
|
54
90
|
* span. `first` marks the segment that starts at the logical line start
|
|
55
|
-
* (the one that carries the line number).
|
|
91
|
+
* (the one that carries the line number). Binary search over the segment
|
|
92
|
+
* prefix sums keeps this O(log n) — the render loop calls it once per
|
|
93
|
+
* visible line, so a linear scan here would be O(viewport × lines) every
|
|
94
|
+
* frame.
|
|
56
95
|
*/
|
|
57
96
|
visualLineAt(vline: number): {
|
|
58
97
|
line: number;
|
|
@@ -92,9 +131,17 @@ export declare class Document {
|
|
|
92
131
|
visualAtLogical(line: number, logical: number): number;
|
|
93
132
|
moveLeft(): void;
|
|
94
133
|
moveRight(): void;
|
|
95
|
-
/**
|
|
96
|
-
|
|
97
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Move up by `count` visual lines (default 1), aligning by visual column so
|
|
136
|
+
* wide-character lines do not drift. Overshooting the top clamps to the
|
|
137
|
+
* first visual line.
|
|
138
|
+
*/
|
|
139
|
+
moveUp(count?: number): void;
|
|
140
|
+
/**
|
|
141
|
+
* Move down by `count` visual lines (default 1), aligning by visual column.
|
|
142
|
+
* Overshooting the bottom clamps to the last visual line.
|
|
143
|
+
*/
|
|
144
|
+
moveDown(count?: number): void;
|
|
98
145
|
moveToLineStart(): void;
|
|
99
146
|
moveToLineEnd(): void;
|
|
100
147
|
moveToDocumentStart(): void;
|
|
@@ -20,6 +20,23 @@ export class Document {
|
|
|
20
20
|
_viewScrollTop = null;
|
|
21
21
|
// Fractional view-scroll remainder, so e.g. 3.5 cells/notch scrolls 3,4,3,4…
|
|
22
22
|
_scrollRemainder = 0;
|
|
23
|
+
/**
|
|
24
|
+
* Per-line soft-wrap segment cache. Segments are derived purely from
|
|
25
|
+
* `_lines` + `_wrapWidth`, but recomputing them is expensive (binary
|
|
26
|
+
* searches + per-character tab expansion), and the render path queries
|
|
27
|
+
* them O(viewport) times per frame — so identical inputs must not be
|
|
28
|
+
* recomputed. null while invalidated; rebuilt lazily on first use.
|
|
29
|
+
*/
|
|
30
|
+
_segmentCache = null;
|
|
31
|
+
/**
|
|
32
|
+
* Prefix sums of segment counts: `prefix[i]` = visual lines occupied by
|
|
33
|
+
* the first `i` logical lines (so `prefix[line]` is the global visual
|
|
34
|
+
* line where logical line `line` starts). Lets `visualLineAt` /
|
|
35
|
+
* `cursorVisualLine` / `visualLineCount` answer in O(log n)/O(1)
|
|
36
|
+
* instead of scanning every line. Invalidated together with
|
|
37
|
+
* `_segmentCache`.
|
|
38
|
+
*/
|
|
39
|
+
_segPrefix = null;
|
|
23
40
|
indentWidth;
|
|
24
41
|
constructor(text, options = {}) {
|
|
25
42
|
this._lines = text.split(/\r?\n/).map((t) => new TextLine(t));
|
|
@@ -56,6 +73,7 @@ export class Document {
|
|
|
56
73
|
this._scrollTop = 0;
|
|
57
74
|
this._viewScrollTop = null;
|
|
58
75
|
this._scrollRemainder = 0;
|
|
76
|
+
this.invalidateIndex();
|
|
59
77
|
}
|
|
60
78
|
get cursor() {
|
|
61
79
|
const line = this._lines[this._cursor.line];
|
|
@@ -74,68 +92,123 @@ export class Document {
|
|
|
74
92
|
}
|
|
75
93
|
/** Set the soft-wrap width (clamped to at least 1 cell). */
|
|
76
94
|
setWrapWidth(width) {
|
|
77
|
-
|
|
95
|
+
const w = Math.max(1, width);
|
|
96
|
+
// The view calls this on every render with the measured width; only a
|
|
97
|
+
// real change invalidates the cache, or it would be rebuilt (and
|
|
98
|
+
// discarded) every frame.
|
|
99
|
+
if (w !== this._wrapWidth) {
|
|
100
|
+
this._wrapWidth = w;
|
|
101
|
+
this.invalidateIndex();
|
|
102
|
+
}
|
|
78
103
|
}
|
|
79
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* The soft-wrap segments of one logical line (an empty line is one empty
|
|
106
|
+
* segment). Cached: the render path queries these O(viewport) times per
|
|
107
|
+
* frame, and every hit would otherwise re-run `segmentFrom` (binary
|
|
108
|
+
* search + per-character tab expansion) on unchanged input.
|
|
109
|
+
*/
|
|
80
110
|
lineSegments(line) {
|
|
111
|
+
const cached = this._segmentCache?.[line];
|
|
112
|
+
if (cached) {
|
|
113
|
+
return cached;
|
|
114
|
+
}
|
|
81
115
|
const tl = this._lines[line];
|
|
116
|
+
const segs = [];
|
|
82
117
|
if (tl.text.length === 0) {
|
|
83
|
-
|
|
118
|
+
segs.push({ start: 0, end: 0, text: "" });
|
|
84
119
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
120
|
+
else {
|
|
121
|
+
let segVisual = 0;
|
|
122
|
+
let segStart = 0;
|
|
123
|
+
while (segStart < tl.text.length) {
|
|
124
|
+
const seg = tl.segmentFrom(segVisual, this._wrapWidth);
|
|
125
|
+
segs.push({ start: segStart, end: seg.endLogical, text: seg.text });
|
|
126
|
+
if (seg.text === "") {
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
segStart = seg.endLogical;
|
|
130
|
+
segVisual = seg.endVisual;
|
|
93
131
|
}
|
|
94
|
-
segStart = seg.endLogical;
|
|
95
|
-
segVisual = seg.endVisual;
|
|
96
132
|
}
|
|
133
|
+
if (!this._segmentCache) {
|
|
134
|
+
this._segmentCache = [];
|
|
135
|
+
}
|
|
136
|
+
this._segmentCache[line] = segs;
|
|
97
137
|
return segs;
|
|
98
138
|
}
|
|
99
|
-
/**
|
|
100
|
-
|
|
139
|
+
/**
|
|
140
|
+
* Prefix sums of segment counts, built lazily from the segment cache.
|
|
141
|
+
* Both caches are invalidated together (see {@link invalidateIndex}), so
|
|
142
|
+
* they can never disagree once built.
|
|
143
|
+
*/
|
|
144
|
+
buildSegmentPrefix() {
|
|
145
|
+
if (this._segPrefix) {
|
|
146
|
+
return this._segPrefix;
|
|
147
|
+
}
|
|
148
|
+
const prefix = [0];
|
|
101
149
|
let total = 0;
|
|
102
150
|
for (let i = 0; i < this._lines.length; i++) {
|
|
103
151
|
total += this.lineSegments(i).length;
|
|
152
|
+
prefix.push(total);
|
|
104
153
|
}
|
|
105
|
-
|
|
154
|
+
this._segPrefix = prefix;
|
|
155
|
+
return prefix;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Drop the segment cache and the visual-line index. Every mutation entry
|
|
159
|
+
* point (`setText`, `setLine`, `insertLineAt`, `removeLineAt`, and a
|
|
160
|
+
* *changed* `setWrapWidth`) must call this, or queries would keep serving
|
|
161
|
+
* stale segments. Rebuilding is lazy — the next query pays O(total
|
|
162
|
+
* segments) once.
|
|
163
|
+
*/
|
|
164
|
+
invalidateIndex() {
|
|
165
|
+
this._segmentCache = null;
|
|
166
|
+
this._segPrefix = null;
|
|
167
|
+
}
|
|
168
|
+
/** Total number of visual (soft-wrapped) lines across the document. */
|
|
169
|
+
get visualLineCount() {
|
|
170
|
+
return this.buildSegmentPrefix()[this._lines.length];
|
|
106
171
|
}
|
|
107
172
|
/**
|
|
108
173
|
* Map a global visual line to its logical line and the segment's logical
|
|
109
174
|
* span. `first` marks the segment that starts at the logical line start
|
|
110
|
-
* (the one that carries the line number).
|
|
175
|
+
* (the one that carries the line number). Binary search over the segment
|
|
176
|
+
* prefix sums keeps this O(log n) — the render loop calls it once per
|
|
177
|
+
* visible line, so a linear scan here would be O(viewport × lines) every
|
|
178
|
+
* frame.
|
|
111
179
|
*/
|
|
112
180
|
visualLineAt(vline) {
|
|
113
181
|
if (vline < 0) {
|
|
114
182
|
return null;
|
|
115
183
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
184
|
+
const prefix = this.buildSegmentPrefix();
|
|
185
|
+
if (vline >= prefix[this._lines.length]) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
// Largest line index whose prefix start is <= vline.
|
|
189
|
+
let lo = 0;
|
|
190
|
+
let hi = this._lines.length - 1;
|
|
191
|
+
while (lo < hi) {
|
|
192
|
+
const mid = (lo + hi + 1) >> 1;
|
|
193
|
+
if (prefix[mid] <= vline) {
|
|
194
|
+
lo = mid;
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
hi = mid - 1;
|
|
127
198
|
}
|
|
128
|
-
remaining -= segs.length;
|
|
129
199
|
}
|
|
130
|
-
|
|
200
|
+
const seg = this.lineSegments(lo)[vline - prefix[lo]];
|
|
201
|
+
return {
|
|
202
|
+
line: lo,
|
|
203
|
+
start: seg.start,
|
|
204
|
+
end: seg.end,
|
|
205
|
+
first: vline === prefix[lo],
|
|
206
|
+
text: seg.text,
|
|
207
|
+
};
|
|
131
208
|
}
|
|
132
209
|
/** Global visual line the cursor sits on. */
|
|
133
210
|
get cursorVisualLine() {
|
|
134
211
|
const { line, logical } = this._cursor;
|
|
135
|
-
let v = 0;
|
|
136
|
-
for (let i = 0; i < line; i++) {
|
|
137
|
-
v += this.lineSegments(i).length;
|
|
138
|
-
}
|
|
139
212
|
const segs = this.lineSegments(line);
|
|
140
213
|
let idx = 0;
|
|
141
214
|
// A cursor exactly on a segment boundary belongs to the segment
|
|
@@ -144,7 +217,7 @@ export class Document {
|
|
|
144
217
|
while (idx < segs.length - 1 && logical >= segs[idx + 1].start) {
|
|
145
218
|
idx++;
|
|
146
219
|
}
|
|
147
|
-
return
|
|
220
|
+
return this.buildSegmentPrefix()[line] + idx;
|
|
148
221
|
}
|
|
149
222
|
/** Visual offset of the cursor within its current soft-wrap segment. */
|
|
150
223
|
get cursorSegmentVisual() {
|
|
@@ -175,14 +248,18 @@ export class Document {
|
|
|
175
248
|
}
|
|
176
249
|
setLine(i, text) {
|
|
177
250
|
this._lines[i] = new TextLine(text);
|
|
251
|
+
this.invalidateIndex();
|
|
178
252
|
}
|
|
179
253
|
/** Insert a line at index `i` (0 <= i <= lineCount), shifting the rest down. */
|
|
180
254
|
insertLineAt(i, text) {
|
|
181
255
|
this._lines.splice(i, 0, new TextLine(text));
|
|
256
|
+
this.invalidateIndex();
|
|
182
257
|
}
|
|
183
258
|
/** Remove line `i` and return its text. */
|
|
184
259
|
removeLineAt(i) {
|
|
185
|
-
|
|
260
|
+
const removed = this._lines.splice(i, 1)[0].text;
|
|
261
|
+
this.invalidateIndex();
|
|
262
|
+
return removed;
|
|
186
263
|
}
|
|
187
264
|
/** Set the cursor, clamping out-of-range values (line to `[0, lineCount-1]`, column to end of line). */
|
|
188
265
|
setCursor(line, logical) {
|
|
@@ -248,10 +325,15 @@ export class Document {
|
|
|
248
325
|
this.setCursor(line, target);
|
|
249
326
|
}
|
|
250
327
|
}
|
|
251
|
-
/**
|
|
252
|
-
|
|
328
|
+
/**
|
|
329
|
+
* Move up by `count` visual lines (default 1), aligning by visual column so
|
|
330
|
+
* wide-character lines do not drift. Overshooting the top clamps to the
|
|
331
|
+
* first visual line.
|
|
332
|
+
*/
|
|
333
|
+
moveUp(count = 1) {
|
|
253
334
|
const { line, logical } = this._cursor;
|
|
254
|
-
|
|
335
|
+
const current = this.cursorVisualLine;
|
|
336
|
+
if (current === 0) {
|
|
255
337
|
// Already on the first visual line: still move to the start of the
|
|
256
338
|
// current segment (like up-to-line-start in vim).
|
|
257
339
|
const segs = this.lineSegments(line);
|
|
@@ -262,17 +344,25 @@ export class Document {
|
|
|
262
344
|
this.setCursor(line, segs[idx].start);
|
|
263
345
|
return;
|
|
264
346
|
}
|
|
265
|
-
const
|
|
347
|
+
const step = Math.max(1, Math.round(count));
|
|
348
|
+
const prev = this.visualLineAt(Math.max(0, current - step));
|
|
266
349
|
if (!prev) {
|
|
267
350
|
return;
|
|
268
351
|
}
|
|
269
352
|
this.moveToSegment(prev);
|
|
270
353
|
}
|
|
271
|
-
|
|
272
|
-
|
|
354
|
+
/**
|
|
355
|
+
* Move down by `count` visual lines (default 1), aligning by visual column.
|
|
356
|
+
* Overshooting the bottom clamps to the last visual line.
|
|
357
|
+
*/
|
|
358
|
+
moveDown(count = 1) {
|
|
359
|
+
const current = this.cursorVisualLine;
|
|
360
|
+
const last = this.visualLineCount - 1;
|
|
361
|
+
if (current >= last) {
|
|
273
362
|
return;
|
|
274
363
|
}
|
|
275
|
-
const
|
|
364
|
+
const step = Math.max(1, Math.round(count));
|
|
365
|
+
const next = this.visualLineAt(Math.min(last, current + step));
|
|
276
366
|
if (!next) {
|
|
277
367
|
return;
|
|
278
368
|
}
|
|
@@ -64,8 +64,14 @@ export class EditorController {
|
|
|
64
64
|
});
|
|
65
65
|
this.defineCommand("cursor.moveLeft", (doc) => doc.moveLeft());
|
|
66
66
|
this.defineCommand("cursor.moveRight", (doc) => doc.moveRight());
|
|
67
|
-
this.defineCommand("cursor.moveUp", (doc) =>
|
|
68
|
-
|
|
67
|
+
this.defineCommand("cursor.moveUp", (doc, args) => {
|
|
68
|
+
const count = typeof args?.count === "number" ? args.count : 1;
|
|
69
|
+
doc.moveUp(count);
|
|
70
|
+
});
|
|
71
|
+
this.defineCommand("cursor.moveDown", (doc, args) => {
|
|
72
|
+
const count = typeof args?.count === "number" ? args.count : 1;
|
|
73
|
+
doc.moveDown(count);
|
|
74
|
+
});
|
|
69
75
|
this.defineCommand("cursor.lineStart", (doc) => doc.moveToLineStart());
|
|
70
76
|
this.defineCommand("cursor.lineEnd", (doc) => doc.moveToLineEnd());
|
|
71
77
|
this.defineCommand("cursor.wordForward", (doc) => doc.moveWordForward());
|
|
@@ -24,8 +24,13 @@ export type FileNode = {
|
|
|
24
24
|
};
|
|
25
25
|
/** Expand a leading `~` to the user's home directory. */
|
|
26
26
|
export declare function expandHome(path: string): string;
|
|
27
|
-
/**
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the file-tree root from settings: the custom path, or the startup
|
|
29
|
+
* directory. A custom mode with a blank path falls back to the startup
|
|
30
|
+
* directory — the picker lets you select custom before typing anything, and an
|
|
31
|
+
* empty root should not read as "no directory".
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveFileTreeRoot(settings: FileTreeSettings, startupDir: string): string;
|
|
29
34
|
/**
|
|
30
35
|
* Recursively scan a directory into a tree. Symlinks are never followed
|
|
31
36
|
* (they can loop), dotfiles and known dependency/VCS dirs are skipped.
|
|
@@ -43,11 +43,18 @@ const isHidden = (name) => name.startsWith(".");
|
|
|
43
43
|
export function expandHome(path) {
|
|
44
44
|
return path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
|
|
45
45
|
}
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the file-tree root from settings: the custom path, or the startup
|
|
48
|
+
* directory. A custom mode with a blank path falls back to the startup
|
|
49
|
+
* directory — the picker lets you select custom before typing anything, and an
|
|
50
|
+
* empty root should not read as "no directory".
|
|
51
|
+
*/
|
|
47
52
|
export function resolveFileTreeRoot(settings, startupDir) {
|
|
48
53
|
if (settings.root === "custom") {
|
|
49
54
|
const custom = expandHome(settings.customPath.trim());
|
|
50
|
-
|
|
55
|
+
if (custom.length > 0) {
|
|
56
|
+
return custom;
|
|
57
|
+
}
|
|
51
58
|
}
|
|
52
59
|
return startupDir;
|
|
53
60
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { render } from "ink";
|
|
3
3
|
import React from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { LanguageProvider } from "@cartridge-engine/i18n";
|
|
5
|
+
import { CurrentScreen, KeyboardProvider, registerComponent, ScenarioManagementProvider, } from "ink-cartridge";
|
|
5
6
|
import { resources } from "./utils/view/i18n-resources.js";
|
|
6
7
|
import { settingsStore } from "./core/settings/useSettings.js";
|
|
7
8
|
import { MainMenu } from "./view/page/main-menu.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { useI18n } from "@cartridge-engine/i18n";
|
|
1
2
|
import { Box, Text } from "ink";
|
|
2
|
-
import { back, ModalLayerElementContext,
|
|
3
|
+
import { back, ModalLayerElementContext, useKeyboard, useScreenSystem, } from "ink-cartridge";
|
|
3
4
|
import React, { useContext, useEffect, useRef, useState } from "react";
|
|
4
5
|
/**
|
|
5
6
|
* Modal command bar (like Helix's `:` prompt). Rendered as a modal layer
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { useI18n } from "@cartridge-engine/i18n";
|
|
1
2
|
import { Box, Text } from "ink";
|
|
2
|
-
import { applyElement, back, closeLayer, LayerElementContext, openLayer,
|
|
3
|
+
import { applyElement, back, closeLayer, LayerElementContext, openLayer, useKeyboard, useMouseRegion, } from "ink-cartridge";
|
|
3
4
|
import React, { useCallback, useContext, useEffect, useState } from "react";
|
|
4
5
|
import { SettingsEntries } from "../page/settings.js";
|
|
5
6
|
import { ModalFrame } from "../utils/modal-frame.js";
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import type { FileTreeState } from "../../core/io/file-tree-model.js";
|
|
3
2
|
import type { EditorSession } from "../../core/io/session.js";
|
|
4
3
|
export type FileTreeProps = {
|
|
5
4
|
/** The shared file session; clicking a file opens it here. */
|
|
6
5
|
session: EditorSession;
|
|
7
6
|
};
|
|
8
|
-
export type FileTreeDirState = FileTreeState | {
|
|
9
|
-
noDir?: boolean;
|
|
10
|
-
};
|
|
11
7
|
/**
|
|
12
8
|
* VSCode-style file tree pinned to the right edge of the terminal (regular
|
|
13
9
|
* layer element). Recursively scans the configured root directory once per
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { useI18n } from "@cartridge-engine/i18n";
|
|
1
2
|
import { Box, Text, useWindowSize } from "ink";
|
|
2
|
-
import { applyElementToModalLayer, ModalLayerElementContext, openModalLayer,
|
|
3
|
+
import { applyElementToModalLayer, ModalLayerElementContext, openModalLayer, useKeyboard, useMouseRegion, useScreenSystem, } from "ink-cartridge";
|
|
3
4
|
import React, { useCallback, useContext, useEffect, useMemo, useState, } from "react";
|
|
4
5
|
import { basename } from "node:path";
|
|
5
6
|
import stringWidth from "string-width";
|
|
@@ -36,10 +37,7 @@ export function FileTree({ session }) {
|
|
|
36
37
|
setRoot({ scanning: true });
|
|
37
38
|
(async () => {
|
|
38
39
|
const resolved = resolveFileTreeRoot(settings.fileTree, process.cwd());
|
|
39
|
-
|
|
40
|
-
? await scanDirectoryCached(resolved)
|
|
41
|
-
: { noDir: true };
|
|
42
|
-
setRoot(tree);
|
|
40
|
+
setRoot(await scanDirectoryCached(resolved));
|
|
43
41
|
})();
|
|
44
42
|
}, [settings.fileTree, scanTick]);
|
|
45
43
|
// A new scan (root changed) starts collapsed at the top — reset during
|
|
@@ -114,8 +112,7 @@ export function FileTree({ session }) {
|
|
|
114
112
|
React.createElement(Box, { ref: refreshRef, marginLeft: 1 },
|
|
115
113
|
React.createElement(Text, { color: refreshHovered ? "green" : "gray" }, "\u21BB"))),
|
|
116
114
|
"scanning" in root ? (React.createElement(Box, { paddingLeft: 1 },
|
|
117
|
-
React.createElement(Text, { dimColor: true }, t("fileTree.scanning")))) : "
|
|
118
|
-
React.createElement(Text, { dimColor: true }, t("fileTree.noDir")))) : "fail" in root ? (React.createElement(Box, { paddingLeft: 1 },
|
|
115
|
+
React.createElement(Text, { dimColor: true }, t("fileTree.scanning")))) : "fail" in root ? (React.createElement(Box, { paddingLeft: 1 },
|
|
119
116
|
React.createElement(Text, { dimColor: true }, t("fileTree.fail")))) : (visible.map((row) => (React.createElement(TreeRow, { key: row.node.path, node: row.node, depth: row.depth, expanded: expanded.has(row.node.path), hovered: hoveredPath === row.node.path, onEnter: () => setHoveredPath(row.node.path), onLeave: () => setHoveredPath((h) => (h === row.node.path ? null : h)), onWheel: handleWheel, onClick: () => {
|
|
120
117
|
if (row.node.isDir) {
|
|
121
118
|
toggleDir(row.node.path);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useI18n
|
|
1
|
+
import { useI18n } from "@cartridge-engine/i18n";
|
|
2
|
+
import { useKeyboard, useMouseRegion } from "ink-cartridge";
|
|
2
3
|
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
|
3
4
|
import { useState } from "react";
|
|
4
5
|
import { Box, measureElement, Text, useWindowSize } from "ink";
|
|
@@ -122,7 +123,7 @@ export function ToolBar({ currentMode, session, openFileTree, openSettings, file
|
|
|
122
123
|
}),
|
|
123
124
|
boundKeyboard(["return"], () => {
|
|
124
125
|
items[index].onClick();
|
|
125
|
-
}),
|
|
126
|
+
}, { mode: "normal" }),
|
|
126
127
|
];
|
|
127
128
|
return () => {
|
|
128
129
|
unbinds.forEach((unbind) => unbind());
|
package/dist/view/page/editor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { useI18n } from "@cartridge-engine/i18n";
|
|
1
2
|
import { Box, Text, useBoxMetrics, useCursor } from "ink";
|
|
2
|
-
import { applyElement, applyElementToModalLayer, closeLayer, eraseElement, openLayer, openModalLayer,
|
|
3
|
+
import { applyElement, applyElementToModalLayer, closeLayer, eraseElement, openLayer, openModalLayer, useKeyboard, useMouseRegion, } from "ink-cartridge";
|
|
3
4
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
4
5
|
import { EditorSession } from "../../core/io/session.js";
|
|
5
6
|
import { useSettings } from "../../core/settings/useSettings.js";
|
|
@@ -104,7 +105,7 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
104
105
|
session,
|
|
105
106
|
openFileTree: () => setFileTreeOpen((open) => !open),
|
|
106
107
|
openSettings: () => setSettingsOpen((open) => !open),
|
|
107
|
-
fileTreeOpen
|
|
108
|
+
fileTreeOpen,
|
|
108
109
|
},
|
|
109
110
|
});
|
|
110
111
|
}, 0);
|
|
@@ -158,7 +159,9 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
158
159
|
unbinds.push(boundKeyboard(keys, handler, options));
|
|
159
160
|
};
|
|
160
161
|
// insert mode: classic editing
|
|
161
|
-
bind(["*"], (input) => controller.execute("editor.insertText", { text: input }), {
|
|
162
|
+
bind(["*"], (input) => controller.execute("editor.insertText", { text: input }), {
|
|
163
|
+
mode: "insert",
|
|
164
|
+
});
|
|
162
165
|
bind(["return"], () => controller.execute("editor.splitLine"), { mode: "insert" });
|
|
163
166
|
bind(["right"], () => controller.execute("cursor.moveRight"), { mode: "insert" });
|
|
164
167
|
bind(["left"], () => controller.execute("cursor.moveLeft"), { mode: "insert" });
|
|
@@ -201,7 +204,54 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
201
204
|
return { ...ctx, lastFlag: null };
|
|
202
205
|
},
|
|
203
206
|
});
|
|
207
|
+
// Vim-style count prefix: digits accumulate in the chain's `value`, then a
|
|
208
|
+
// direction key spends it (`5j`, `12k`). Composition runs ahead of the
|
|
209
|
+
// screen bindings, so a bare digit arms a chain instead of moving.
|
|
210
|
+
const countKeys = [];
|
|
211
|
+
for (const digit of "1234567890") {
|
|
212
|
+
countKeys.push(digit);
|
|
213
|
+
registryCompositionKey({
|
|
214
|
+
key: digit,
|
|
215
|
+
flags: [],
|
|
216
|
+
alternativeFlag: "number",
|
|
217
|
+
needs: ["number"],
|
|
218
|
+
// `0` cannot open a count, so a bare `0` keeps meaning line-start
|
|
219
|
+
// via its binding below; it may still extend one (`10j`).
|
|
220
|
+
optional: digit !== "0",
|
|
221
|
+
mode: "normal",
|
|
222
|
+
execute: (ctx) => ({
|
|
223
|
+
...ctx,
|
|
224
|
+
value: (typeof ctx.value === "number" ? ctx.value : 0) * 10 + Number(digit),
|
|
225
|
+
lastFlag: "number",
|
|
226
|
+
}),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// Counts only pay off on the vertical keys; h/l/left/right stay untouched.
|
|
230
|
+
const directions = [
|
|
231
|
+
["k", "cursor.moveUp"],
|
|
232
|
+
["up", "cursor.moveUp"],
|
|
233
|
+
["j", "cursor.moveDown"],
|
|
234
|
+
["down", "cursor.moveDown"],
|
|
235
|
+
];
|
|
236
|
+
for (const [key, command] of directions) {
|
|
237
|
+
countKeys.push(key);
|
|
238
|
+
registryCompositionKey({
|
|
239
|
+
key,
|
|
240
|
+
flags: [],
|
|
241
|
+
alternativeFlag: "direction",
|
|
242
|
+
needs: ["number"],
|
|
243
|
+
mode: "normal",
|
|
244
|
+
// The count is spent here and the chain ends, so swallow the key —
|
|
245
|
+
// otherwise the plain binding below would move one extra line.
|
|
246
|
+
KeyReleaseWhenChainInterrupted: true,
|
|
247
|
+
execute: (ctx) => {
|
|
248
|
+
controller.execute(command, { count: ctx.value });
|
|
249
|
+
return null;
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
}
|
|
204
253
|
unbinds.push(() => removeCompositionKey("g"));
|
|
254
|
+
unbinds.push(() => countKeys.forEach((key) => removeCompositionKey(key)));
|
|
205
255
|
// Ink marks "G" as shift+g, so the normalized key name is "shift+G".
|
|
206
256
|
bind(["shift+G"], () => controller.execute("cursor.documentEnd"), { mode: "normal" });
|
|
207
257
|
bind(["i"], () => switchMode("insert"), { mode: "normal" });
|
|
@@ -281,7 +331,7 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
281
331
|
return null;
|
|
282
332
|
}
|
|
283
333
|
return (React.createElement(Box, { key: vline, flexDirection: "row" },
|
|
284
|
-
React.createElement(Box, { width: lineNumberWidth, justifyContent: "flex-end", marginRight: lineNumberRightSpacing }, seg.first ?
|
|
334
|
+
React.createElement(Box, { width: lineNumberWidth, justifyContent: "flex-end", marginRight: lineNumberRightSpacing }, seg.first ? React.createElement(Text, { bold: cursor.line === seg.line }, seg.line) : null),
|
|
285
335
|
React.createElement(Text, null, seg.text)));
|
|
286
336
|
})))));
|
|
287
337
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { useI18n } from "@cartridge-engine/i18n";
|
|
1
2
|
import { Box, Text, useWindowSize } from "ink";
|
|
2
|
-
import {
|
|
3
|
+
import { useKeyboard, useMouseRegion } from "ink-cartridge";
|
|
3
4
|
import React, { useEffect, useState } from "react";
|
|
4
5
|
import { Editor } from "./editor.js";
|
|
5
6
|
import { Settings } from "./settings.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { useI18n } from "@cartridge-engine/i18n";
|
|
1
2
|
import { Box, Text } from "ink";
|
|
2
|
-
import { back, ModalLayerElementContext,
|
|
3
|
+
import { back, ModalLayerElementContext, useKeyboard, useMouseRegion, useScreenSystem, } from "ink-cartridge";
|
|
3
4
|
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
4
5
|
import { useSettings } from "../../core/settings/useSettings.js";
|
|
5
6
|
import { ModalFrame } from "../utils/modal-frame.js";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { useI18n } from "@cartridge-engine/i18n";
|
|
1
2
|
import { Box, Text } from "ink";
|
|
2
|
-
import {
|
|
3
|
+
import { useMouseRegion } from "ink-cartridge";
|
|
3
4
|
import React from "react";
|
|
4
5
|
export const SENSITIVITY_MIN = 1;
|
|
5
6
|
export const SENSITIVITY_MAX = 10;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blots-editor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
4
4
|
"description": "A lightweight Markdown editor for the terminal, built on ink-cartridge",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"react": ">=18"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
+
"@cartridge-engine/i18n": "*",
|
|
54
55
|
"cfonts": "^3.3.1",
|
|
55
56
|
"chalk": "^4.1.2",
|
|
56
57
|
"gradient-string": "^2.0.2",
|