blots-editor 0.0.16 → 0.0.18
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 +3 -2
- package/asset/language/zh.json +3 -2
- package/dist/core/document/document.d.ts +11 -3
- package/dist/core/document/document.js +20 -7
- package/dist/core/document/history.d.ts +74 -0
- package/dist/core/document/history.js +136 -0
- package/dist/core/document/operations.d.ts +24 -9
- package/dist/core/document/operations.js +29 -1
- package/dist/core/editor-controller.d.ts +16 -4
- package/dist/core/editor-controller.js +38 -26
- package/dist/core/io/file-tree-model.d.ts +7 -2
- package/dist/core/io/file-tree-model.js +9 -2
- package/dist/core/io/session.js +3 -0
- package/dist/core/settings/schema.d.ts +14 -0
- package/dist/core/settings/schema.js +23 -0
- package/dist/core/settings/useSettings.d.ts +4 -0
- package/dist/core/settings/useSettings.js +8 -0
- 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 +2 -1
- package/dist/view/page/editor.js +62 -4
- package/dist/view/page/main-menu.js +2 -1
- package/dist/view/page/settings.js +57 -2
- package/dist/view/utils/sensitivity-bar.d.ts +42 -8
- package/dist/view/utils/sensitivity-bar.js +56 -19
- package/package.json +2 -1
|
@@ -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/core/io/session.js
CHANGED
|
@@ -73,6 +73,9 @@ export class EditorSession {
|
|
|
73
73
|
return { ok: false, error: "Binary file" };
|
|
74
74
|
}
|
|
75
75
|
this._controller.document.setText(text);
|
|
76
|
+
// Recorded positions refer to the previous text, so the history is
|
|
77
|
+
// meaningless once the document is replaced.
|
|
78
|
+
this._controller.history.clear();
|
|
76
79
|
this._filePath = filePath;
|
|
77
80
|
// Hash the NORMALIZED document text, not the raw disk bytes: the
|
|
78
81
|
// document normalizes CRLF to LF, so hashing the raw text would
|
|
@@ -9,6 +9,16 @@ import { z } from "zod";
|
|
|
9
9
|
*/
|
|
10
10
|
/** Wheel sensitivity in visual lines per notch, 1.0–10.0, 0.5 steps. */
|
|
11
11
|
export declare const sensitivitySchema: z.ZodDefault<z.ZodNumber>;
|
|
12
|
+
/** Undo coalescing range in ms; `0` disables merging. */
|
|
13
|
+
export declare const MERGE_WINDOW_MIN = 0;
|
|
14
|
+
export declare const MERGE_WINDOW_MAX = 2000;
|
|
15
|
+
export declare const MERGE_WINDOW_STEP = 100;
|
|
16
|
+
export declare const MERGE_WINDOW_DEFAULT = 500;
|
|
17
|
+
/**
|
|
18
|
+
* Consecutive mergeable edits closer than this (ms) collapse into one undo
|
|
19
|
+
* step. `0` turns merging off, so every edit is its own step.
|
|
20
|
+
*/
|
|
21
|
+
export declare const mergeWindowSchema: z.ZodDefault<z.ZodNumber>;
|
|
12
22
|
/** File-tree root source: the process startup directory or a custom path. */
|
|
13
23
|
export declare const fileTreeRootSchema: z.ZodDefault<z.ZodEnum<{
|
|
14
24
|
startup: "startup";
|
|
@@ -27,10 +37,14 @@ export declare const settingsSchema: z.ZodObject<{
|
|
|
27
37
|
}>>;
|
|
28
38
|
customPath: z.ZodDefault<z.ZodString>;
|
|
29
39
|
}, z.core.$strip>>;
|
|
40
|
+
history: z.ZodDefault<z.ZodObject<{
|
|
41
|
+
mergeWindow: z.ZodDefault<z.ZodNumber>;
|
|
42
|
+
}, z.core.$strip>>;
|
|
30
43
|
}, z.core.$strip>;
|
|
31
44
|
export type EditorSettings = z.infer<typeof settingsSchema>;
|
|
32
45
|
export type WheelSensitivity = EditorSettings["wheel"];
|
|
33
46
|
export type FileTreeSettings = EditorSettings["fileTree"];
|
|
47
|
+
export type HistorySettings = EditorSettings["history"];
|
|
34
48
|
export declare const DEFAULT_SETTINGS: EditorSettings;
|
|
35
49
|
/** Parse an unknown persisted value, falling back to defaults on any failure. */
|
|
36
50
|
export declare function parseSettings(raw: unknown): EditorSettings;
|
|
@@ -14,6 +14,22 @@ export const sensitivitySchema = z
|
|
|
14
14
|
.max(10)
|
|
15
15
|
.step(0.5)
|
|
16
16
|
.default(1);
|
|
17
|
+
/** Undo coalescing range in ms; `0` disables merging. */
|
|
18
|
+
export const MERGE_WINDOW_MIN = 0;
|
|
19
|
+
export const MERGE_WINDOW_MAX = 2000;
|
|
20
|
+
export const MERGE_WINDOW_STEP = 100;
|
|
21
|
+
export const MERGE_WINDOW_DEFAULT = 500;
|
|
22
|
+
/**
|
|
23
|
+
* Consecutive mergeable edits closer than this (ms) collapse into one undo
|
|
24
|
+
* step. `0` turns merging off, so every edit is its own step.
|
|
25
|
+
*/
|
|
26
|
+
export const mergeWindowSchema = z
|
|
27
|
+
.number()
|
|
28
|
+
.int()
|
|
29
|
+
.min(MERGE_WINDOW_MIN)
|
|
30
|
+
.max(MERGE_WINDOW_MAX)
|
|
31
|
+
.multipleOf(MERGE_WINDOW_STEP)
|
|
32
|
+
.default(MERGE_WINDOW_DEFAULT);
|
|
17
33
|
const DEFAULT_WHEEL = { cursor: 1, view: 3 };
|
|
18
34
|
/** File-tree root source: the process startup directory or a custom path. */
|
|
19
35
|
export const fileTreeRootSchema = z.enum(["startup", "custom"]).default("startup");
|
|
@@ -36,11 +52,18 @@ export const settingsSchema = z.object({
|
|
|
36
52
|
customPath: z.string().default(""),
|
|
37
53
|
})
|
|
38
54
|
.default({ root: "startup", customPath: "" }),
|
|
55
|
+
history: z
|
|
56
|
+
.object({
|
|
57
|
+
/** Undo coalescing window in ms; see {@link mergeWindowSchema}. */
|
|
58
|
+
mergeWindow: mergeWindowSchema,
|
|
59
|
+
})
|
|
60
|
+
.default({ mergeWindow: MERGE_WINDOW_DEFAULT }),
|
|
39
61
|
});
|
|
40
62
|
export const DEFAULT_SETTINGS = {
|
|
41
63
|
language: "en",
|
|
42
64
|
wheel: DEFAULT_WHEEL,
|
|
43
65
|
fileTree: { root: "startup", customPath: "" },
|
|
66
|
+
history: { mergeWindow: MERGE_WINDOW_DEFAULT },
|
|
44
67
|
};
|
|
45
68
|
/** Parse an unknown persisted value, falling back to defaults on any failure. */
|
|
46
69
|
export function parseSettings(raw) {
|
|
@@ -18,6 +18,10 @@ export type SettingsApi = {
|
|
|
18
18
|
setLanguage: (code: string) => void;
|
|
19
19
|
/** Persist the file-tree root settings. */
|
|
20
20
|
setFileTree: (fileTree: EditorSettings["fileTree"]) => void;
|
|
21
|
+
/** Set the undo merge window (ms) and persist it immediately. */
|
|
22
|
+
setMergeWindow: (ms: number) => void;
|
|
23
|
+
/** Update the merge window in memory only (while dragging); commit on release. */
|
|
24
|
+
setMergeWindowDraft: (ms: number) => void;
|
|
21
25
|
};
|
|
22
26
|
/**
|
|
23
27
|
* React binding for the shared {@link settingsStore}. Re-renders subscribers
|
|
@@ -27,6 +27,14 @@ export function useSettings() {
|
|
|
27
27
|
commit: () => settingsStore.commit(),
|
|
28
28
|
setLanguage: (code) => settingsStore.persist({ ...settings, language: code }),
|
|
29
29
|
setFileTree: (fileTree) => settingsStore.persist({ ...settings, fileTree }),
|
|
30
|
+
setMergeWindow: (ms) => settingsStore.persist({
|
|
31
|
+
...settings,
|
|
32
|
+
history: { ...settings.history, mergeWindow: ms },
|
|
33
|
+
}),
|
|
34
|
+
setMergeWindowDraft: (ms) => settingsStore.update({
|
|
35
|
+
...settings,
|
|
36
|
+
history: { ...settings.history, mergeWindow: ms },
|
|
37
|
+
}),
|
|
30
38
|
};
|
|
31
39
|
}, [settings]);
|
|
32
40
|
}
|
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";
|
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);
|
|
@@ -151,6 +152,11 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
151
152
|
useEffect(() => {
|
|
152
153
|
return boundKeyboard(["ctrl+tab"], () => setToolbarOpen((open) => !open));
|
|
153
154
|
}, [boundKeyboard]);
|
|
155
|
+
// Keep the coalescing window in step with the persisted setting; edits
|
|
156
|
+
// closer together than it share one undo step (0 disables merging).
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
controller.history.setMergeWindow(settings.history.mergeWindow);
|
|
159
|
+
}, [controller, settings.history.mergeWindow]);
|
|
154
160
|
useEffect(() => {
|
|
155
161
|
const removeWildcard = enableWildcardPriority();
|
|
156
162
|
const unbinds = [];
|
|
@@ -158,7 +164,9 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
158
164
|
unbinds.push(boundKeyboard(keys, handler, options));
|
|
159
165
|
};
|
|
160
166
|
// insert mode: classic editing
|
|
161
|
-
bind(["*"], (input) => controller.execute("editor.insertText", { text: input }), {
|
|
167
|
+
bind(["*"], (input) => controller.execute("editor.insertText", { text: input }), {
|
|
168
|
+
mode: "insert",
|
|
169
|
+
});
|
|
162
170
|
bind(["return"], () => controller.execute("editor.splitLine"), { mode: "insert" });
|
|
163
171
|
bind(["right"], () => controller.execute("cursor.moveRight"), { mode: "insert" });
|
|
164
172
|
bind(["left"], () => controller.execute("cursor.moveLeft"), { mode: "insert" });
|
|
@@ -201,13 +209,63 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
201
209
|
return { ...ctx, lastFlag: null };
|
|
202
210
|
},
|
|
203
211
|
});
|
|
212
|
+
// Vim-style count prefix: digits accumulate in the chain's `value`, then a
|
|
213
|
+
// direction key spends it (`5j`, `12k`). Composition runs ahead of the
|
|
214
|
+
// screen bindings, so a bare digit arms a chain instead of moving.
|
|
215
|
+
const countKeys = [];
|
|
216
|
+
for (const digit of "1234567890") {
|
|
217
|
+
countKeys.push(digit);
|
|
218
|
+
registryCompositionKey({
|
|
219
|
+
key: digit,
|
|
220
|
+
flags: [],
|
|
221
|
+
alternativeFlag: "number",
|
|
222
|
+
needs: ["number"],
|
|
223
|
+
// `0` cannot open a count, so a bare `0` keeps meaning line-start
|
|
224
|
+
// via its binding below; it may still extend one (`10j`).
|
|
225
|
+
optional: digit !== "0",
|
|
226
|
+
mode: "normal",
|
|
227
|
+
execute: (ctx) => ({
|
|
228
|
+
...ctx,
|
|
229
|
+
value: (typeof ctx.value === "number" ? ctx.value : 0) * 10 + Number(digit),
|
|
230
|
+
lastFlag: "number",
|
|
231
|
+
}),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
// Counts only pay off on the vertical keys; h/l/left/right stay untouched.
|
|
235
|
+
const directions = [
|
|
236
|
+
["k", "cursor.moveUp"],
|
|
237
|
+
["up", "cursor.moveUp"],
|
|
238
|
+
["j", "cursor.moveDown"],
|
|
239
|
+
["down", "cursor.moveDown"],
|
|
240
|
+
];
|
|
241
|
+
for (const [key, command] of directions) {
|
|
242
|
+
countKeys.push(key);
|
|
243
|
+
registryCompositionKey({
|
|
244
|
+
key,
|
|
245
|
+
flags: [],
|
|
246
|
+
alternativeFlag: "direction",
|
|
247
|
+
needs: ["number"],
|
|
248
|
+
mode: "normal",
|
|
249
|
+
// The count is spent here and the chain ends, so swallow the key —
|
|
250
|
+
// otherwise the plain binding below would move one extra line.
|
|
251
|
+
KeyReleaseWhenChainInterrupted: true,
|
|
252
|
+
execute: (ctx) => {
|
|
253
|
+
controller.execute(command, { count: ctx.value });
|
|
254
|
+
return null;
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
}
|
|
204
258
|
unbinds.push(() => removeCompositionKey("g"));
|
|
259
|
+
unbinds.push(() => countKeys.forEach((key) => removeCompositionKey(key)));
|
|
205
260
|
// Ink marks "G" as shift+g, so the normalized key name is "shift+G".
|
|
206
261
|
bind(["shift+G"], () => controller.execute("cursor.documentEnd"), { mode: "normal" });
|
|
207
262
|
bind(["i"], () => switchMode("insert"), { mode: "normal" });
|
|
208
263
|
bind([":"], () => openCommandBar(), { mode: "normal" });
|
|
209
264
|
// Ctrl+S saves in normal mode (Vim-style; insert stays untouched).
|
|
210
265
|
bind(["ctrl+s"], () => session.save(), { mode: "normal" });
|
|
266
|
+
// Undo/redo also live in normal mode; insert stays untouched.
|
|
267
|
+
bind(["u"], () => controller.execute("history.undo"), { mode: "normal" });
|
|
268
|
+
bind(["ctrl+r"], () => controller.execute("history.redo"), { mode: "normal" });
|
|
211
269
|
return () => {
|
|
212
270
|
removeWildcard();
|
|
213
271
|
unbinds.forEach((fn) => fn());
|
|
@@ -281,7 +339,7 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
281
339
|
return null;
|
|
282
340
|
}
|
|
283
341
|
return (React.createElement(Box, { key: vline, flexDirection: "row" },
|
|
284
|
-
React.createElement(Box, { width: lineNumberWidth, justifyContent: "flex-end", marginRight: lineNumberRightSpacing }, seg.first ?
|
|
342
|
+
React.createElement(Box, { width: lineNumberWidth, justifyContent: "flex-end", marginRight: lineNumberRightSpacing }, seg.first ? React.createElement(Text, { bold: cursor.line === seg.line }, seg.line) : null),
|
|
285
343
|
React.createElement(Text, null, seg.text)));
|
|
286
344
|
})))));
|
|
287
345
|
}
|
|
@@ -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,9 +1,11 @@
|
|
|
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";
|
|
5
|
+
import { MERGE_WINDOW_MAX, MERGE_WINDOW_MIN, MERGE_WINDOW_STEP, } from "../../core/settings/schema.js";
|
|
4
6
|
import { useSettings } from "../../core/settings/useSettings.js";
|
|
5
7
|
import { ModalFrame } from "../utils/modal-frame.js";
|
|
6
|
-
import { SensitivityBar, snapSensitivity } from "../utils/sensitivity-bar.js";
|
|
8
|
+
import { SensitivityBar, Slider, snapSensitivity, snapValue, } from "../utils/sensitivity-bar.js";
|
|
7
9
|
const LANGUAGES = [
|
|
8
10
|
{ code: "en", labelKey: "settings.language.en" },
|
|
9
11
|
{ code: "zh", labelKey: "settings.language.zh" },
|
|
@@ -95,6 +97,42 @@ function SensitivityPicker({ sensitivityKey }) {
|
|
|
95
97
|
value.toFixed(1),
|
|
96
98
|
"\u00D7")));
|
|
97
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Modal slider for the undo merge window. The bar is drag/click driven;
|
|
102
|
+
* dragging updates in memory only, release/click persists. `0` disables
|
|
103
|
+
* merging, so the label reads "off" instead of the bare number.
|
|
104
|
+
*/
|
|
105
|
+
function MergeWindowPicker() {
|
|
106
|
+
const ctx = useContext(ModalLayerElementContext);
|
|
107
|
+
const { t } = useI18n();
|
|
108
|
+
const { boundKeyboard } = useKeyboard();
|
|
109
|
+
const { closeModalLayer } = useScreenSystem();
|
|
110
|
+
const { settings, setMergeWindow, setMergeWindowDraft, commit } = useSettings();
|
|
111
|
+
const value = settings.history.mergeWindow;
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!ctx) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const snap = (dir) => snapValue(value, dir, MERGE_WINDOW_MIN, MERGE_WINDOW_MAX, MERGE_WINDOW_STEP);
|
|
117
|
+
const unbinds = [
|
|
118
|
+
boundKeyboard(["left"], () => setMergeWindow(snap(-1)), {
|
|
119
|
+
elementId: ctx.id,
|
|
120
|
+
}),
|
|
121
|
+
boundKeyboard(["right"], () => setMergeWindow(snap(1)), {
|
|
122
|
+
elementId: ctx.id,
|
|
123
|
+
}),
|
|
124
|
+
boundKeyboard(["escape"], () => closeModalLayer(ctx.modalLayer.layerId), { elementId: ctx.id }),
|
|
125
|
+
];
|
|
126
|
+
return () => unbinds.forEach((fn) => fn());
|
|
127
|
+
}, [boundKeyboard, closeModalLayer, ctx, setMergeWindow, value]);
|
|
128
|
+
return (React.createElement(ModalFrame, { title: t("settings.mergeWindow"), footer: React.createElement(Text, { dimColor: true },
|
|
129
|
+
t("settings.back"),
|
|
130
|
+
" (Esc)") },
|
|
131
|
+
React.createElement(Slider, { value: value, min: MERGE_WINDOW_MIN, max: MERGE_WINDOW_MAX, step: MERGE_WINDOW_STEP, onChange: setMergeWindowDraft, onCommit: commit, hint: t("settings.slider.hint") }),
|
|
132
|
+
React.createElement(Text, null, value === MERGE_WINDOW_MIN
|
|
133
|
+
? t("settings.mergeWindow.off")
|
|
134
|
+
: `${value} ms`)));
|
|
135
|
+
}
|
|
98
136
|
/** Small clickable button used inside the file-tree-root picker. */
|
|
99
137
|
function PickerButton({ label, active, onEnter, onLeave, onClick }) {
|
|
100
138
|
const ref = useMouseRegion({ onEnter, onLeave, onClick }, { priority: 1 });
|
|
@@ -220,6 +258,13 @@ export function SettingsEntries({ onExit }) {
|
|
|
220
258
|
element: FileTreePicker,
|
|
221
259
|
});
|
|
222
260
|
}, [applyElementToModalLayer, openModalLayer]);
|
|
261
|
+
const openMergeWindowPicker = useCallback(() => {
|
|
262
|
+
openModalLayer("merge-window", 50);
|
|
263
|
+
applyElementToModalLayer("merge-window", {
|
|
264
|
+
elementId: "merge-window-picker",
|
|
265
|
+
element: MergeWindowPicker,
|
|
266
|
+
});
|
|
267
|
+
}, [applyElementToModalLayer, openModalLayer]);
|
|
223
268
|
const currentLabel = LANGUAGES.find((lang) => lang.code === currentLanguage)?.labelKey ??
|
|
224
269
|
"settings.language.en";
|
|
225
270
|
const entries = useMemo(() => [
|
|
@@ -249,6 +294,14 @@ export function SettingsEntries({ onExit }) {
|
|
|
249
294
|
: t("settings.fileTreeRoot.startup"),
|
|
250
295
|
open: openFileTreePicker,
|
|
251
296
|
},
|
|
297
|
+
{
|
|
298
|
+
id: "merge-window",
|
|
299
|
+
label: t("settings.mergeWindow"),
|
|
300
|
+
value: settings.history.mergeWindow === MERGE_WINDOW_MIN
|
|
301
|
+
? t("settings.mergeWindow.off")
|
|
302
|
+
: `${settings.history.mergeWindow} ms`,
|
|
303
|
+
open: openMergeWindowPicker,
|
|
304
|
+
},
|
|
252
305
|
], [
|
|
253
306
|
t,
|
|
254
307
|
currentLabel,
|
|
@@ -256,9 +309,11 @@ export function SettingsEntries({ onExit }) {
|
|
|
256
309
|
settings.wheel.view,
|
|
257
310
|
settings.fileTree.root,
|
|
258
311
|
settings.fileTree.customPath,
|
|
312
|
+
settings.history.mergeWindow,
|
|
259
313
|
openLanguagePicker,
|
|
260
314
|
openSensitivityPicker,
|
|
261
315
|
openFileTreePicker,
|
|
316
|
+
openMergeWindowPicker,
|
|
262
317
|
]);
|
|
263
318
|
useEffect(() => {
|
|
264
319
|
const unbinds = [
|
|
@@ -2,23 +2,57 @@ import React from "react";
|
|
|
2
2
|
export declare const SENSITIVITY_MIN = 1;
|
|
3
3
|
export declare const SENSITIVITY_MAX = 10;
|
|
4
4
|
export declare const SENSITIVITY_STEP = 0.5;
|
|
5
|
-
/**
|
|
6
|
-
export declare
|
|
7
|
-
/** Map a 0..1 ratio to a
|
|
8
|
-
export declare function valueFromRatio(ratio: number): number;
|
|
5
|
+
/** Number of steps a `min..max` range of `step` size spans. */
|
|
6
|
+
export declare function stepsFor(min: number, max: number, step: number): number;
|
|
7
|
+
/** Map a 0..1 ratio to a value snapped to `step`, clamped to `min..max`. */
|
|
8
|
+
export declare function valueFromRatio(ratio: number, min?: number, max?: number, step?: number): number;
|
|
9
9
|
/** Clamp value + dir*step to the valid 1..10 range (0.5 steps). */
|
|
10
10
|
export declare function snapSensitivity(value: number, dir: 1 | -1): number;
|
|
11
|
-
|
|
11
|
+
/** Clamp value + dir*step to an arbitrary `min..max` range. */
|
|
12
|
+
export declare function snapValue(value: number, dir: 1 | -1, min: number, max: number, step: number): number;
|
|
13
|
+
/**
|
|
14
|
+
* Value for a click on cell `localX` (0-based) of a bar that renders exactly
|
|
15
|
+
* `steps + 1` cells — one per value from `min` to `max` inclusive. The ratio
|
|
16
|
+
* therefore runs `localX / steps`, which is exactly one value per cell; using
|
|
17
|
+
* `steps` cells instead would have to skip an interior value to still reach
|
|
18
|
+
* `max` (e.g. 1000 ms, or 5.5×, would be unreachable by mouse).
|
|
19
|
+
*/
|
|
20
|
+
export declare function valueFromBarX(localX: number, min: number, max: number, step: number): number;
|
|
21
|
+
/**
|
|
22
|
+
* Filled-cell count for `value` on the `min..max` bar, matching
|
|
23
|
+
* {@link valueFromBarX}: cell `i` selects `min + i*step`, so the value's own
|
|
24
|
+
* cell is filled and everything left of it too — one cell at `min`, the whole
|
|
25
|
+
* bar at `max`. Deriving the count from that integer cell index — rather than
|
|
26
|
+
* from the value's fraction of the range — keeps the fill aligned with the
|
|
27
|
+
* click mapping: a proportional `fraction * (steps + 1)` rounds unevenly and
|
|
28
|
+
* skips a cell at the midpoint.
|
|
29
|
+
*/
|
|
30
|
+
export declare function filledCells(value: number, min: number, max: number, step: number): number;
|
|
31
|
+
type SliderProps = {
|
|
12
32
|
value: number;
|
|
33
|
+
min: number;
|
|
34
|
+
max: number;
|
|
35
|
+
step: number;
|
|
13
36
|
/** Called on click/drag with the new value (in-memory draft). */
|
|
14
37
|
onChange: (value: number) => void;
|
|
15
38
|
/** Called when the interaction ends — persist the draft. */
|
|
16
39
|
onCommit: () => void;
|
|
40
|
+
/** Localized hint rendered under the bar. */
|
|
41
|
+
hint: string;
|
|
17
42
|
};
|
|
18
43
|
/**
|
|
19
|
-
* Mouse-driven
|
|
20
|
-
*
|
|
21
|
-
* the
|
|
44
|
+
* Mouse-driven slider over an arbitrary `min..max`/`step` range. Clicking or
|
|
45
|
+
* dragging anywhere on the bar sets the value proportionally, snapped to
|
|
46
|
+
* `step`; the fuller the bar, the higher the value.
|
|
22
47
|
*/
|
|
48
|
+
export declare function Slider({ value, min, max, step, onChange, onCommit, hint }: SliderProps): React.JSX.Element;
|
|
49
|
+
type SensitivityBarProps = {
|
|
50
|
+
value: number;
|
|
51
|
+
/** Called on click/drag with the new value (in-memory draft). */
|
|
52
|
+
onChange: (value: number) => void;
|
|
53
|
+
/** Called when the interaction ends — persist the draft. */
|
|
54
|
+
onCommit: () => void;
|
|
55
|
+
};
|
|
56
|
+
/** Slider preset for the 1..10 wheel sensitivity range. */
|
|
23
57
|
export declare function SensitivityBar({ value, onChange, onCommit }: SensitivityBarProps): React.JSX.Element;
|
|
24
58
|
export {};
|
|
@@ -1,28 +1,61 @@
|
|
|
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;
|
|
6
7
|
export const SENSITIVITY_STEP = 0.5;
|
|
7
|
-
/**
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
/** Number of steps a `min..max` range of `step` size spans. */
|
|
9
|
+
export function stepsFor(min, max, step) {
|
|
10
|
+
return Math.round((max - min) / step);
|
|
11
|
+
}
|
|
12
|
+
/** Map a 0..1 ratio to a value snapped to `step`, clamped to `min..max`. */
|
|
13
|
+
export function valueFromRatio(ratio, min = SENSITIVITY_MIN, max = SENSITIVITY_MAX, step = SENSITIVITY_STEP) {
|
|
11
14
|
const r = Math.max(0, Math.min(1, ratio));
|
|
12
|
-
|
|
13
|
-
return SENSITIVITY_MIN + Math.round(r * BAR_WIDTH) * SENSITIVITY_STEP;
|
|
15
|
+
return min + Math.round(r * stepsFor(min, max, step)) * step;
|
|
14
16
|
}
|
|
15
17
|
/** Clamp value + dir*step to the valid 1..10 range (0.5 steps). */
|
|
16
18
|
export function snapSensitivity(value, dir) {
|
|
17
|
-
return
|
|
19
|
+
return snapValue(value, dir, SENSITIVITY_MIN, SENSITIVITY_MAX, SENSITIVITY_STEP);
|
|
20
|
+
}
|
|
21
|
+
/** Clamp value + dir*step to an arbitrary `min..max` range. */
|
|
22
|
+
export function snapValue(value, dir, min, max, step) {
|
|
23
|
+
return Math.min(max, Math.max(min, value + dir * step));
|
|
18
24
|
}
|
|
19
25
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
26
|
+
* Value for a click on cell `localX` (0-based) of a bar that renders exactly
|
|
27
|
+
* `steps + 1` cells — one per value from `min` to `max` inclusive. The ratio
|
|
28
|
+
* therefore runs `localX / steps`, which is exactly one value per cell; using
|
|
29
|
+
* `steps` cells instead would have to skip an interior value to still reach
|
|
30
|
+
* `max` (e.g. 1000 ms, or 5.5×, would be unreachable by mouse).
|
|
23
31
|
*/
|
|
24
|
-
export function
|
|
25
|
-
const
|
|
32
|
+
export function valueFromBarX(localX, min, max, step) {
|
|
33
|
+
const steps = stepsFor(min, max, step);
|
|
34
|
+
// A zero-width bar (single value) can only mean `min`; avoid /0.
|
|
35
|
+
return steps <= 0 ? min : valueFromRatio(localX / steps, min, max, step);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Filled-cell count for `value` on the `min..max` bar, matching
|
|
39
|
+
* {@link valueFromBarX}: cell `i` selects `min + i*step`, so the value's own
|
|
40
|
+
* cell is filled and everything left of it too — one cell at `min`, the whole
|
|
41
|
+
* bar at `max`. Deriving the count from that integer cell index — rather than
|
|
42
|
+
* from the value's fraction of the range — keeps the fill aligned with the
|
|
43
|
+
* click mapping: a proportional `fraction * (steps + 1)` rounds unevenly and
|
|
44
|
+
* skips a cell at the midpoint.
|
|
45
|
+
*/
|
|
46
|
+
export function filledCells(value, min, max, step) {
|
|
47
|
+
const total = stepsFor(min, max, step) + 1;
|
|
48
|
+
const index = Math.round((value - min) / step);
|
|
49
|
+
return Math.min(total, Math.max(1, index + 1));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Mouse-driven slider over an arbitrary `min..max`/`step` range. Clicking or
|
|
53
|
+
* dragging anywhere on the bar sets the value proportionally, snapped to
|
|
54
|
+
* `step`; the fuller the bar, the higher the value.
|
|
55
|
+
*/
|
|
56
|
+
export function Slider({ value, min, max, step, onChange, onCommit, hint }) {
|
|
57
|
+
const steps = stepsFor(min, max, step);
|
|
58
|
+
const valueFromX = (localX) => valueFromBarX(localX, min, max, step);
|
|
26
59
|
// priority 1: the bar sits inside the draggable ModalFrame, whose region
|
|
27
60
|
// overlaps it — the child control must always win the hit test, no matter
|
|
28
61
|
// the registration order after frame drags.
|
|
@@ -35,14 +68,18 @@ export function SensitivityBar({ value, onChange, onCommit }) {
|
|
|
35
68
|
onDragMove: (event, rect) => onChange(valueFromX(event.x - rect.x)),
|
|
36
69
|
onDragEnd: () => onCommit(),
|
|
37
70
|
}, { priority: 1 });
|
|
38
|
-
|
|
71
|
+
// One cell per value (min..max inclusive); the fill is derived from the
|
|
72
|
+
// value's own cell, so it matches what a click on that cell would select.
|
|
73
|
+
const total = steps + 1;
|
|
74
|
+
const filled = filledCells(value, min, max, step);
|
|
39
75
|
return (React.createElement(Box, { flexDirection: "column", alignItems: "center", gap: 1 },
|
|
40
76
|
React.createElement(Box, { ref: ref, flexDirection: "row" },
|
|
41
77
|
React.createElement(Text, null, filled > 0 ? "█".repeat(filled) : ""),
|
|
42
|
-
React.createElement(Text, { dimColor: true }, "░".repeat(
|
|
43
|
-
React.createElement(Text, { dimColor: true },
|
|
78
|
+
React.createElement(Text, { dimColor: true }, "░".repeat(total - filled))),
|
|
79
|
+
React.createElement(Text, { dimColor: true }, hint)));
|
|
44
80
|
}
|
|
45
|
-
/**
|
|
46
|
-
function
|
|
47
|
-
|
|
81
|
+
/** Slider preset for the 1..10 wheel sensitivity range. */
|
|
82
|
+
export function SensitivityBar({ value, onChange, onCommit }) {
|
|
83
|
+
const { t } = useI18n();
|
|
84
|
+
return (React.createElement(Slider, { value: value, min: SENSITIVITY_MIN, max: SENSITIVITY_MAX, step: SENSITIVITY_STEP, onChange: onChange, onCommit: onCommit, hint: t("settings.slider.hint") }));
|
|
48
85
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blots-editor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
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",
|