blots-editor 0.0.7 → 0.0.9
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 +15 -0
- package/asset/language/zh.json +15 -0
- package/dist/i18n-resources.d.ts +1 -0
- package/dist/i18n-resources.js +18 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +13 -14
- package/dist/view/command-bar.d.ts +7 -0
- package/dist/view/command-bar.js +57 -0
- package/dist/view/editor.d.ts +6 -6
- package/dist/view/editor.js +100 -32
- package/dist/view/information-bar.d.ts +7 -5
- package/dist/view/information-bar.js +10 -3
- package/dist/view/logo.d.ts +9 -0
- package/dist/view/logo.js +23 -0
- package/dist/view/main-menu.d.ts +2 -0
- package/dist/view/main-menu.js +52 -0
- package/dist/view/settings.d.ts +2 -0
- package/dist/view/settings.js +97 -0
- package/package.json +2 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"menu.edit": "Edit Mode",
|
|
3
|
+
"menu.settings": "Settings",
|
|
4
|
+
"menu.other": "Other",
|
|
5
|
+
"menu.quit": "Quit",
|
|
6
|
+
"settings.title": "Settings",
|
|
7
|
+
"settings.language": "Language",
|
|
8
|
+
"settings.language.en": "English",
|
|
9
|
+
"settings.language.zh": "中文",
|
|
10
|
+
"settings.back": "Back",
|
|
11
|
+
"editor.mode.insert": "INSERT",
|
|
12
|
+
"editor.mode.normal": "NORMAL",
|
|
13
|
+
"command.prompt": "Command",
|
|
14
|
+
"command.unknown": "Unknown command: {cmd}"
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"menu.edit": "编辑模式",
|
|
3
|
+
"menu.settings": "设置",
|
|
4
|
+
"menu.other": "其他",
|
|
5
|
+
"menu.quit": "退出",
|
|
6
|
+
"settings.title": "设置",
|
|
7
|
+
"settings.language": "语言",
|
|
8
|
+
"settings.language.en": "英文",
|
|
9
|
+
"settings.language.zh": "中文",
|
|
10
|
+
"settings.back": "返回",
|
|
11
|
+
"editor.mode.insert": "插入",
|
|
12
|
+
"editor.mode.normal": "普通",
|
|
13
|
+
"command.prompt": "命令",
|
|
14
|
+
"command.unknown": "未知命令:{cmd}"
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const resources: Record<string, Record<string, string>>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* Load language packs from asset/language/*.json.
|
|
4
|
+
*
|
|
5
|
+
* We read the files at module load instead of `import x from "./x.json"`:
|
|
6
|
+
* Node ESM requires import attributes for JSON modules and tsc does not
|
|
7
|
+
* emit them. Paths resolve from this module's own location (dist/), so
|
|
8
|
+
* global installs work regardless of cwd — the JSON files ship inside the
|
|
9
|
+
* package via the `asset` entry in `files`.
|
|
10
|
+
*/
|
|
11
|
+
const LANGUAGE_DIR = new URL("../asset/language/", import.meta.url);
|
|
12
|
+
function loadPack(locale) {
|
|
13
|
+
return JSON.parse(readFileSync(new URL(`${locale}.json`, LANGUAGE_DIR), "utf8"));
|
|
14
|
+
}
|
|
15
|
+
export const resources = {
|
|
16
|
+
en: loadPack("en"),
|
|
17
|
+
zh: loadPack("zh"),
|
|
18
|
+
};
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { render } from "ink";
|
|
2
3
|
import React from "react";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { CurrentScreen, KeyboardProvider, LanguageProvider, registerComponent, ScenarioManagementProvider, } from "ink-cartridge";
|
|
5
|
+
import { resources } from "./i18n-resources.js";
|
|
6
|
+
import { MainMenu } from "./view/main-menu.js";
|
|
5
7
|
import { Editor } from "./view/editor.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
render(React.createElement(ScenarioManagementProvider, { defaultScreen: App, fullScreen: true },
|
|
15
|
-
React.createElement(KeyboardProvider, { autoTab: false, mouse: true },
|
|
16
|
-
React.createElement(CurrentScreen, null))));
|
|
8
|
+
import { Settings } from "./view/settings.js";
|
|
9
|
+
registerComponent(MainMenu, {});
|
|
10
|
+
registerComponent(Editor, {}, { parent: MainMenu });
|
|
11
|
+
registerComponent(Settings, {}, { parent: MainMenu });
|
|
12
|
+
render(React.createElement(ScenarioManagementProvider, { defaultScreen: MainMenu, fullScreen: true },
|
|
13
|
+
React.createElement(LanguageProvider, { resources: resources, defaultLanguage: "en", fallbackLanguage: "en" },
|
|
14
|
+
React.createElement(KeyboardProvider, { autoTab: false, mouse: true, modes: ["insert", "normal"], defaultMode: "insert" },
|
|
15
|
+
React.createElement(CurrentScreen, null)))));
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Modal command bar (like Helix's `:` prompt). Rendered as a modal layer
|
|
4
|
+
* element, so it exclusively owns the keyboard while open — the editor's
|
|
5
|
+
* bindings (including the insert-mode `*` wildcard) stay dormant.
|
|
6
|
+
*/
|
|
7
|
+
export declare function CommandBar(): React.JSX.Element;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { back, ModalLayerElementContext, useI18n, useKeyboard, useScreenSystem, } from "ink-cartridge";
|
|
3
|
+
import React, { useContext, useEffect, useRef, useState } from "react";
|
|
4
|
+
/**
|
|
5
|
+
* Modal command bar (like Helix's `:` prompt). Rendered as a modal layer
|
|
6
|
+
* element, so it exclusively owns the keyboard while open — the editor's
|
|
7
|
+
* bindings (including the insert-mode `*` wildcard) stay dormant.
|
|
8
|
+
*/
|
|
9
|
+
export function CommandBar() {
|
|
10
|
+
const ctx = useContext(ModalLayerElementContext);
|
|
11
|
+
const { t } = useI18n();
|
|
12
|
+
const { boundKeyboard, setMode } = useKeyboard();
|
|
13
|
+
const { closeModalLayer } = useScreenSystem();
|
|
14
|
+
const [input, setInput] = useState("");
|
|
15
|
+
const [error, setError] = useState(null);
|
|
16
|
+
// Handler closures stay stable across renders; the ref holds the truth.
|
|
17
|
+
const inputRef = useRef("");
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!ctx) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const unbinds = [];
|
|
23
|
+
unbinds.push(boundKeyboard(["*"], (ch) => {
|
|
24
|
+
inputRef.current += ch;
|
|
25
|
+
setInput(inputRef.current);
|
|
26
|
+
setError(null);
|
|
27
|
+
}, { elementId: ctx.id }));
|
|
28
|
+
unbinds.push(boundKeyboard(["backspace"], () => {
|
|
29
|
+
inputRef.current = inputRef.current.slice(0, -1);
|
|
30
|
+
setInput(inputRef.current);
|
|
31
|
+
}, { elementId: ctx.id }));
|
|
32
|
+
unbinds.push(boundKeyboard(["escape"], () => {
|
|
33
|
+
closeModalLayer(ctx.modalLayer.layerId);
|
|
34
|
+
setMode("normal");
|
|
35
|
+
}, { elementId: ctx.id }));
|
|
36
|
+
unbinds.push(boundKeyboard(["return"], () => {
|
|
37
|
+
const cmd = inputRef.current.trim();
|
|
38
|
+
if (cmd === "quit") {
|
|
39
|
+
closeModalLayer(ctx.modalLayer.layerId);
|
|
40
|
+
setMode("insert");
|
|
41
|
+
back();
|
|
42
|
+
}
|
|
43
|
+
else if (cmd) {
|
|
44
|
+
setError(t("command.unknown", { params: { cmd } }));
|
|
45
|
+
}
|
|
46
|
+
}, { elementId: ctx.id }));
|
|
47
|
+
return () => {
|
|
48
|
+
unbinds.forEach((fn) => fn());
|
|
49
|
+
};
|
|
50
|
+
}, [boundKeyboard, closeModalLayer, ctx, setMode, t]);
|
|
51
|
+
return (React.createElement(Box, { flexDirection: "row", width: "100%", height: 1, paddingLeft: 1, backgroundColor: "black" },
|
|
52
|
+
React.createElement(Text, { color: "green" }, ":"),
|
|
53
|
+
React.createElement(Text, null, input),
|
|
54
|
+
error ? React.createElement(Text, { color: "red" },
|
|
55
|
+
" ",
|
|
56
|
+
error) : null));
|
|
57
|
+
}
|
package/dist/view/editor.d.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
export type EditorContext = {
|
|
3
|
-
onChance
|
|
4
|
-
value
|
|
3
|
+
onChance?: () => void;
|
|
4
|
+
value?: string;
|
|
5
5
|
lineNumberRightSpacing?: number;
|
|
6
6
|
cursorOffset?: number;
|
|
7
7
|
cursorHeightOffset?: number;
|
|
8
8
|
numberOfIndentationSpaces?: number;
|
|
9
9
|
};
|
|
10
10
|
/**
|
|
11
|
-
* Main editor view: renders line numbers + text
|
|
12
|
-
* to the controller as a named command
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* Main editor view: renders the status bar + line numbers + text, routes
|
|
12
|
+
* every keystroke to the controller as a named command, and splits
|
|
13
|
+
* bindings by mode — insert keeps the classic editing keys, normal gets
|
|
14
|
+
* hjkl + arrow-key movement and the `:` command bar.
|
|
15
15
|
*/
|
|
16
16
|
export declare function Editor({ value: initialText, lineNumberRightSpacing, cursorOffset, cursorHeightOffset, numberOfIndentationSpaces, }: EditorContext): React.JSX.Element;
|
package/dist/view/editor.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { Box, Text, useBoxMetrics, useCursor } from "ink";
|
|
2
|
-
import { useKeyboard, useMouseRegion } from "ink-cartridge";
|
|
3
|
-
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { applyElementToModalLayer, openModalLayer, useI18n, useKeyboard, useMouseRegion, } from "ink-cartridge";
|
|
3
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
4
4
|
import { EditorController } from "../core/editor-controller.js";
|
|
5
5
|
import { clickToPosition } from "./click-mapping.js";
|
|
6
|
+
import { CommandBar } from "./command-bar.js";
|
|
7
|
+
import { InformationBar } from "./information-bar.js";
|
|
6
8
|
/**
|
|
7
|
-
* Main editor view: renders line numbers + text
|
|
8
|
-
* to the controller as a named command
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Main editor view: renders the status bar + line numbers + text, routes
|
|
10
|
+
* every keystroke to the controller as a named command, and splits
|
|
11
|
+
* bindings by mode — insert keeps the classic editing keys, normal gets
|
|
12
|
+
* hjkl + arrow-key movement and the `:` command bar.
|
|
11
13
|
*/
|
|
12
|
-
export function Editor({ value: initialText, lineNumberRightSpacing = 1, cursorOffset = 0, cursorHeightOffset = 2, numberOfIndentationSpaces = 2, }) {
|
|
14
|
+
export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cursorOffset = 0, cursorHeightOffset = 2, numberOfIndentationSpaces = 2, }) {
|
|
13
15
|
const controllerRef = useRef(null);
|
|
14
16
|
if (!controllerRef.current) {
|
|
15
17
|
controllerRef.current = new EditorController(initialText, {
|
|
@@ -22,27 +24,91 @@ export function Editor({ value: initialText, lineNumberRightSpacing = 1, cursorO
|
|
|
22
24
|
return controller.onChange(() => forceUpdate((n) => n + 1));
|
|
23
25
|
}, [controller]);
|
|
24
26
|
const { setCursorPosition } = useCursor();
|
|
25
|
-
const { boundKeyboard, enableWildcardPriority } = useKeyboard();
|
|
27
|
+
const { boundKeyboard, enableWildcardPriority, getCurrentMode, registryCompositionKey, removeCompositionKey, setMode, } = useKeyboard();
|
|
28
|
+
const { t } = useI18n();
|
|
29
|
+
// The engine keeps modes in a ref (no React notification), so we mirror
|
|
30
|
+
// switches locally to keep the status bar live.
|
|
31
|
+
const [mode, setLocalMode] = useState(() => getCurrentMode());
|
|
32
|
+
const switchMode = useCallback((next) => {
|
|
33
|
+
setMode(next);
|
|
34
|
+
setLocalMode(next);
|
|
35
|
+
}, [setMode]);
|
|
36
|
+
const openCommandBar = useCallback(() => {
|
|
37
|
+
openModalLayer("command", 100);
|
|
38
|
+
applyElementToModalLayer("command", {
|
|
39
|
+
elementId: "command-input",
|
|
40
|
+
element: CommandBar,
|
|
41
|
+
});
|
|
42
|
+
}, []);
|
|
26
43
|
useEffect(() => {
|
|
27
44
|
const removeWildcard = enableWildcardPriority();
|
|
28
45
|
const unbinds = [];
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
const bind = (keys, handler, options = {}) => {
|
|
47
|
+
unbinds.push(boundKeyboard(keys, handler, options));
|
|
48
|
+
};
|
|
49
|
+
// insert mode: classic editing
|
|
50
|
+
bind(["*"], (input) => controller.execute("editor.insertText", { text: input }), { mode: "insert" });
|
|
51
|
+
bind(["return"], () => controller.execute("editor.splitLine"), { mode: "insert" });
|
|
52
|
+
bind(["right"], () => controller.execute("cursor.moveRight"), { mode: "insert" });
|
|
53
|
+
bind(["left"], () => controller.execute("cursor.moveLeft"), { mode: "insert" });
|
|
54
|
+
// backspace deletes before the cursor; delete removes after it.
|
|
55
|
+
bind(["backspace"], () => controller.execute("editor.deleteBefore"), {
|
|
56
|
+
mode: "insert",
|
|
57
|
+
});
|
|
58
|
+
bind(["delete"], () => controller.execute("editor.deleteAfter"), { mode: "insert" });
|
|
59
|
+
bind(["up"], () => controller.execute("cursor.moveUp"), { mode: "insert" });
|
|
60
|
+
bind(["down"], () => controller.execute("cursor.moveDown"), { mode: "insert" });
|
|
61
|
+
bind(["tab"], () => controller.execute("editor.indent"), { mode: "insert" });
|
|
62
|
+
bind(["shift+tab"], () => controller.execute("editor.outdent"), { mode: "insert" });
|
|
63
|
+
bind(["escape"], () => switchMode("normal"), { mode: "insert" });
|
|
64
|
+
// normal mode: movement + command bar
|
|
65
|
+
bind(["h", "left"], () => controller.execute("cursor.moveLeft"), { mode: "normal" });
|
|
66
|
+
bind(["j"], () => controller.execute("cursor.moveDown"), { mode: "normal" });
|
|
67
|
+
bind(["down"], () => controller.execute("cursor.moveDown"), { mode: "normal" });
|
|
68
|
+
bind(["k", "up"], () => controller.execute("cursor.moveUp"), { mode: "normal" });
|
|
69
|
+
bind(["l", "right"], () => controller.execute("cursor.moveRight"), { mode: "normal" });
|
|
70
|
+
bind(["w"], () => controller.execute("cursor.wordForward"), { mode: "normal" });
|
|
71
|
+
bind(["b"], () => controller.execute("cursor.wordBackward"), { mode: "normal" });
|
|
72
|
+
bind(["0"], () => controller.execute("cursor.lineStart"), { mode: "normal" });
|
|
73
|
+
bind(["$"], () => controller.execute("cursor.lineEnd"), { mode: "normal" });
|
|
74
|
+
// `gg` via the composition engine: the first `g` arms the chain, the
|
|
75
|
+
// second fires documentStart. `mode` keeps typing `g` in insert mode
|
|
76
|
+
// untouched, since composition runs ahead of the `*` wildcard.
|
|
77
|
+
registryCompositionKey({
|
|
78
|
+
key: "g",
|
|
79
|
+
flags: [],
|
|
80
|
+
alternativeFlag: "goto",
|
|
81
|
+
needs: ["goto"],
|
|
82
|
+
optional: true,
|
|
83
|
+
mode: "normal",
|
|
84
|
+
execute: (ctx) => {
|
|
85
|
+
if (ctx.lastFlag === "goto") {
|
|
86
|
+
controller.execute("cursor.documentStart");
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
// head press: let the engine fill the flag from alternativeFlag
|
|
90
|
+
return { ...ctx, lastFlag: null };
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
unbinds.push(() => removeCompositionKey("g"));
|
|
94
|
+
// Ink marks "G" as shift+g, so the normalized key name is "shift+G".
|
|
95
|
+
bind(["shift+G"], () => controller.execute("cursor.documentEnd"), { mode: "normal" });
|
|
96
|
+
bind(["i"], () => switchMode("insert"), { mode: "normal" });
|
|
97
|
+
bind([":"], () => openCommandBar(), { mode: "normal" });
|
|
41
98
|
return () => {
|
|
42
99
|
removeWildcard();
|
|
43
100
|
unbinds.forEach((fn) => fn());
|
|
44
101
|
};
|
|
45
|
-
}, [
|
|
102
|
+
}, [
|
|
103
|
+
boundKeyboard,
|
|
104
|
+
controller,
|
|
105
|
+
enableWildcardPriority,
|
|
106
|
+
getCurrentMode,
|
|
107
|
+
openCommandBar,
|
|
108
|
+
registryCompositionKey,
|
|
109
|
+
removeCompositionKey,
|
|
110
|
+
switchMode,
|
|
111
|
+
]);
|
|
46
112
|
const ref = useRef(null);
|
|
47
113
|
const { height } = useBoxMetrics(ref);
|
|
48
114
|
const doc = controller.document;
|
|
@@ -54,9 +120,8 @@ export function Editor({ value: initialText, lineNumberRightSpacing = 1, cursorO
|
|
|
54
120
|
const cursorX = cursor.visual + lineNumberWidth + lineNumberRightSpacing + cursorOffset;
|
|
55
121
|
const cursorY = cursor.line - visibleStart + cursorHeightOffset;
|
|
56
122
|
setCursorPosition({ x: cursorX, y: cursorY });
|
|
57
|
-
// Mouse: clicks position the cursor; the wheel scrolls one
|
|
58
|
-
// The callbacks close over this render's metrics
|
|
59
|
-
// them fresh, so scrollTop/gutter always match what the user sees.
|
|
123
|
+
// Mouse: clicks position the cursor in any mode; the wheel scrolls one
|
|
124
|
+
// line at a time. The callbacks close over this render's metrics.
|
|
60
125
|
const mouseRef = useMouseRegion({
|
|
61
126
|
onClick: (event, rect) => {
|
|
62
127
|
const target = clickToPosition(event, rect, lineNumberWidth + lineNumberRightSpacing, doc.scrollTop, doc.lineCount);
|
|
@@ -74,12 +139,15 @@ export function Editor({ value: initialText, lineNumberRightSpacing = 1, cursorO
|
|
|
74
139
|
}
|
|
75
140
|
},
|
|
76
141
|
});
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
React.createElement(
|
|
84
|
-
|
|
142
|
+
const modeText = mode === "normal" ? t("editor.mode.normal") : t("editor.mode.insert");
|
|
143
|
+
return (React.createElement(Box, { flexDirection: "column", height: "100%", width: "100%" },
|
|
144
|
+
React.createElement(InformationBar, { mode: modeText, cursor: { line: cursor.line, column: cursor.visual } }),
|
|
145
|
+
React.createElement(Box, { ref: mouseRef, flexGrow: 1, width: "100%", backgroundColor: "#1e1e1e" },
|
|
146
|
+
React.createElement(Box, { ref: ref, height: "100%", width: "100%", flexDirection: "column" }, visibleLines.map((each, i) => {
|
|
147
|
+
const lineNumber = visibleStart + i;
|
|
148
|
+
return (React.createElement(Box, { key: lineNumber, flexDirection: "row" },
|
|
149
|
+
React.createElement(Box, { width: lineNumberWidth, justifyContent: "flex-end", marginRight: lineNumberRightSpacing },
|
|
150
|
+
React.createElement(Text, { bold: cursor.line === lineNumber }, lineNumber)),
|
|
151
|
+
React.createElement(Text, null, each)));
|
|
152
|
+
})))));
|
|
85
153
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
export type
|
|
3
|
-
/**
|
|
4
|
-
* current mode
|
|
5
|
-
*/
|
|
2
|
+
export type InformationBarProps = {
|
|
6
3
|
mode: string;
|
|
4
|
+
cursor?: {
|
|
5
|
+
line: number;
|
|
6
|
+
column: number;
|
|
7
|
+
};
|
|
7
8
|
};
|
|
8
|
-
|
|
9
|
+
/** Status bar: current mode (live) plus the cursor's 1-based position. */
|
|
10
|
+
export declare function InformationBar({ mode, cursor }: InformationBarProps): React.JSX.Element;
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import React from "react";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/** Status bar: current mode (live) plus the cursor's 1-based position. */
|
|
4
|
+
export function InformationBar({ mode, cursor }) {
|
|
5
|
+
return (React.createElement(Box, { width: "100%", height: 1, backgroundColor: "blue", flexDirection: "row", paddingLeft: 2 },
|
|
6
|
+
React.createElement(Text, { color: "white", bold: true }, mode),
|
|
7
|
+
cursor ? (React.createElement(Text, { color: "white" },
|
|
8
|
+
" ",
|
|
9
|
+
"Ln ",
|
|
10
|
+
cursor.line + 1,
|
|
11
|
+
", Col ",
|
|
12
|
+
cursor.column + 1)) : null));
|
|
6
13
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Blots Editor" ASCII logo — figlet box-drawing style (█ ╔ ═ ║ ╚ ╝), whose
|
|
3
|
+
* shaded edges give a pseudo-3D look. "BLOTS" renders white, "EDITOR" gray.
|
|
4
|
+
*
|
|
5
|
+
* To swap the style, replace these lines with your own art, keeping the
|
|
6
|
+
* white/gray per-line split.
|
|
7
|
+
*/
|
|
8
|
+
export declare const LOGO_WHITE: string[];
|
|
9
|
+
export declare const LOGO_GRAY: string[];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Blots Editor" ASCII logo — figlet box-drawing style (█ ╔ ═ ║ ╚ ╝), whose
|
|
3
|
+
* shaded edges give a pseudo-3D look. "BLOTS" renders white, "EDITOR" gray.
|
|
4
|
+
*
|
|
5
|
+
* To swap the style, replace these lines with your own art, keeping the
|
|
6
|
+
* white/gray per-line split.
|
|
7
|
+
*/
|
|
8
|
+
export const LOGO_WHITE = [
|
|
9
|
+
"██████╗ ██╗ ██████╗ ████████╗ ██████╗ ",
|
|
10
|
+
"██╔══██╗██║ ██╔══██╗╚══██╔══╝██╔════╝ ",
|
|
11
|
+
"██████╔╝██║ ██║ ██║ ██║ ███████╗ ",
|
|
12
|
+
"██╔══██╗██║ ██║ ██║ ██║ ╚════██║ ",
|
|
13
|
+
"██████╔╝██║ ██║ ██║ ██║ ███████║ ",
|
|
14
|
+
"╚═════╝ ╚██████╗╚██████╔╝ ╚═╝ ╚══════╝ ",
|
|
15
|
+
];
|
|
16
|
+
export const LOGO_GRAY = [
|
|
17
|
+
"███████╗ ██████╗ ████████╗ ████████╗ ██████╗ ██████╗",
|
|
18
|
+
"██╔════╝ ██╔══██╗ ██╔═╝ ╚══██╔══╝ ██╔══██╗ ██╔══██╗",
|
|
19
|
+
"█████╗ ██║ ██║ ██║ ██║ ██║ ██║ ██████╔╝",
|
|
20
|
+
"██╔══╝ ██║ ██║ ██║ ██║ ██║ ██║ ██╔══██╗",
|
|
21
|
+
"███████╗ ██║ ██║ ██║ ██║ ██║ ██║ ██║ ██║",
|
|
22
|
+
"╚══════╝ ╚█████╔╝ ╚═╝ ╚═╝ ╚██████╔╝ ╚═╝ ╚═╝",
|
|
23
|
+
];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { useI18n, useKeyboard, useMouseRegion } from "ink-cartridge";
|
|
3
|
+
import React, { useEffect, useState } from "react";
|
|
4
|
+
import { Editor } from "./editor.js";
|
|
5
|
+
import { Settings } from "./settings.js";
|
|
6
|
+
import { gotoScreen } from "ink-cartridge";
|
|
7
|
+
import { LOGO_GRAY, LOGO_WHITE } from "./logo.js";
|
|
8
|
+
/**
|
|
9
|
+
* A borderless text button: the plain Box provides the mouse hit area,
|
|
10
|
+
* hovering inverts the text as visual feedback, disabled buttons dim.
|
|
11
|
+
*/
|
|
12
|
+
function MenuButton({ label, shortcut, disabled, onClick }) {
|
|
13
|
+
const [hovered, setHovered] = useState(false);
|
|
14
|
+
const ref = useMouseRegion({
|
|
15
|
+
onEnter: () => setHovered(true),
|
|
16
|
+
onLeave: () => setHovered(false),
|
|
17
|
+
onClick: () => {
|
|
18
|
+
if (!disabled) {
|
|
19
|
+
onClick?.();
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
return (React.createElement(Box, { ref: ref, paddingLeft: 1, paddingRight: 1 },
|
|
24
|
+
React.createElement(Text, { inverse: hovered, dimColor: disabled }, label),
|
|
25
|
+
!disabled && shortcut ? React.createElement(Text, { dimColor: true },
|
|
26
|
+
" [",
|
|
27
|
+
shortcut,
|
|
28
|
+
"]") : null));
|
|
29
|
+
}
|
|
30
|
+
export function MainMenu() {
|
|
31
|
+
const { t } = useI18n();
|
|
32
|
+
const { boundKeyboard } = useKeyboard();
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const unbinds = [
|
|
35
|
+
boundKeyboard(["e"], () => gotoScreen(Editor, {})),
|
|
36
|
+
boundKeyboard(["s"], () => gotoScreen(Settings, {})),
|
|
37
|
+
boundKeyboard(["q"], () => process.exit(0)),
|
|
38
|
+
];
|
|
39
|
+
return () => unbinds.forEach((fn) => fn());
|
|
40
|
+
}, [boundKeyboard]);
|
|
41
|
+
return (React.createElement(Box, { flexDirection: "column", justifyContent: "center", alignItems: "center", width: "100%", height: "100%" },
|
|
42
|
+
React.createElement(Box, { flexDirection: "column", gap: 1, marginBottom: 6 }, LOGO_WHITE.map((line, i) => (React.createElement(Box, { key: i, flexDirection: "row" },
|
|
43
|
+
React.createElement(Text, { color: "white" },
|
|
44
|
+
line,
|
|
45
|
+
" "),
|
|
46
|
+
React.createElement(Text, { color: "gray" }, LOGO_GRAY[i]))))),
|
|
47
|
+
React.createElement(Box, { flexDirection: "row", gap: 6 },
|
|
48
|
+
React.createElement(MenuButton, { label: t("menu.edit"), shortcut: "e", onClick: () => gotoScreen(Editor, {}) }),
|
|
49
|
+
React.createElement(MenuButton, { label: t("menu.settings"), shortcut: "s", onClick: () => gotoScreen(Settings, {}) }),
|
|
50
|
+
React.createElement(MenuButton, { label: t("menu.other"), disabled: true }),
|
|
51
|
+
React.createElement(MenuButton, { label: t("menu.quit"), shortcut: "q", onClick: () => process.exit(0) }))));
|
|
52
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
2
|
+
import { back, ModalLayerElementContext, useI18n, useKeyboard, useMouseRegion, useScreenSystem, } from "ink-cartridge";
|
|
3
|
+
import React, { useCallback, useContext, useEffect, useState } from "react";
|
|
4
|
+
const LANGUAGES = [
|
|
5
|
+
{ code: "en", labelKey: "settings.language.en" },
|
|
6
|
+
{ code: "zh", labelKey: "settings.language.zh" },
|
|
7
|
+
];
|
|
8
|
+
/** Selectable language row with the same hover-highlight style as the menu. */
|
|
9
|
+
function LanguageRow({ label, selected, active, onEnter, onLeave, onClick, }) {
|
|
10
|
+
const ref = useMouseRegion({ onEnter, onLeave, onClick });
|
|
11
|
+
return (React.createElement(Box, { ref: ref, paddingLeft: 1, paddingRight: 1 },
|
|
12
|
+
React.createElement(Text, { inverse: active },
|
|
13
|
+
active ? "> " : " ",
|
|
14
|
+
label,
|
|
15
|
+
selected ? " *" : "")));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Modal language picker, opened from the settings screen. While open it owns
|
|
19
|
+
* the keyboard (modal layer takeover); picking a language applies it and
|
|
20
|
+
* closes, Esc cancels without changing anything.
|
|
21
|
+
*/
|
|
22
|
+
function LanguagePicker() {
|
|
23
|
+
const ctx = useContext(ModalLayerElementContext);
|
|
24
|
+
const { t, setLanguage, currentLanguage } = useI18n();
|
|
25
|
+
const { boundKeyboard } = useKeyboard();
|
|
26
|
+
const { closeModalLayer } = useScreenSystem();
|
|
27
|
+
const [index, setIndex] = useState(0);
|
|
28
|
+
const [hovered, setHovered] = useState(null);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!ctx) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const unbinds = [
|
|
34
|
+
boundKeyboard(["up"], () => setIndex((i) => Math.max(0, i - 1)), { elementId: ctx.id }),
|
|
35
|
+
boundKeyboard(["down"], () => setIndex((i) => Math.min(LANGUAGES.length - 1, i + 1)), { elementId: ctx.id }),
|
|
36
|
+
boundKeyboard(["return"], () => {
|
|
37
|
+
setLanguage(LANGUAGES[index].code);
|
|
38
|
+
closeModalLayer(ctx.modalLayer.layerId);
|
|
39
|
+
}, { elementId: ctx.id }),
|
|
40
|
+
boundKeyboard(["escape"], () => closeModalLayer(ctx.modalLayer.layerId), { elementId: ctx.id }),
|
|
41
|
+
];
|
|
42
|
+
return () => unbinds.forEach((fn) => fn());
|
|
43
|
+
}, [boundKeyboard, closeModalLayer, ctx, index, setLanguage]);
|
|
44
|
+
const apply = (code) => {
|
|
45
|
+
if (!ctx) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
setLanguage(code);
|
|
49
|
+
closeModalLayer(ctx.modalLayer.layerId);
|
|
50
|
+
};
|
|
51
|
+
return (React.createElement(Box, { width: "100%", height: "100%", justifyContent: "center", alignItems: "center" },
|
|
52
|
+
React.createElement(Box, { flexDirection: "column", borderStyle: "bold", borderColor: "white", backgroundColor: "black", width: 36, alignItems: "center", paddingX: 4, paddingY: 1, gap: 1 },
|
|
53
|
+
React.createElement(Text, { bold: true }, t("settings.language")),
|
|
54
|
+
LANGUAGES.map((lang, i) => (React.createElement(LanguageRow, { key: lang.code, label: t(lang.labelKey), selected: currentLanguage === lang.code, active: i === index || hovered === i, onEnter: () => setHovered(i), onLeave: () => setHovered((h) => (h === i ? null : h)), onClick: () => apply(lang.code) }))),
|
|
55
|
+
React.createElement(Text, { dimColor: true },
|
|
56
|
+
t("settings.back"),
|
|
57
|
+
" (Esc)"))));
|
|
58
|
+
}
|
|
59
|
+
/** A single settings entry: label plus its current value; opens a picker on activation. */
|
|
60
|
+
function SettingRow({ label, value, active, onEnter, onLeave, onClick, }) {
|
|
61
|
+
const ref = useMouseRegion({ onEnter, onLeave, onClick });
|
|
62
|
+
return (React.createElement(Box, { ref: ref, paddingLeft: 1, paddingRight: 1, flexDirection: "row", gap: 2 },
|
|
63
|
+
React.createElement(Text, { inverse: active },
|
|
64
|
+
active ? "> " : " ",
|
|
65
|
+
label),
|
|
66
|
+
React.createElement(Text, { dimColor: true }, value)));
|
|
67
|
+
}
|
|
68
|
+
export function Settings() {
|
|
69
|
+
const { t, currentLanguage } = useI18n();
|
|
70
|
+
const { boundKeyboard } = useKeyboard();
|
|
71
|
+
const { openModalLayer, applyElementToModalLayer } = useScreenSystem();
|
|
72
|
+
const [hovered, setHovered] = useState(false);
|
|
73
|
+
const openLanguagePicker = useCallback(() => {
|
|
74
|
+
openModalLayer("language", 50);
|
|
75
|
+
applyElementToModalLayer("language", {
|
|
76
|
+
elementId: "language-picker",
|
|
77
|
+
element: LanguagePicker,
|
|
78
|
+
});
|
|
79
|
+
}, [applyElementToModalLayer, openModalLayer]);
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
const unbinds = [
|
|
82
|
+
boundKeyboard(["return"], () => openLanguagePicker()),
|
|
83
|
+
boundKeyboard(["escape", "backspace"], () => back()),
|
|
84
|
+
];
|
|
85
|
+
return () => unbinds.forEach((fn) => fn());
|
|
86
|
+
}, [boundKeyboard, openLanguagePicker]);
|
|
87
|
+
const currentLabel = LANGUAGES.find((lang) => lang.code === currentLanguage)?.labelKey ??
|
|
88
|
+
"settings.language.en";
|
|
89
|
+
return (React.createElement(Box, { flexDirection: "column", justifyContent: "center", alignItems: "center", width: "100%", height: "100%", gap: 2 },
|
|
90
|
+
React.createElement(Text, { bold: true }, t("settings.title")),
|
|
91
|
+
React.createElement(Box, { flexDirection: "column", gap: 2, marginTop: 2 },
|
|
92
|
+
React.createElement(SettingRow, { label: t("settings.language"), value: t(currentLabel), active: hovered, onEnter: () => setHovered(true), onLeave: () => setHovered(false), onClick: openLanguagePicker })),
|
|
93
|
+
React.createElement(Box, { marginTop: 4 },
|
|
94
|
+
React.createElement(Text, { dimColor: true },
|
|
95
|
+
t("settings.back"),
|
|
96
|
+
" (Esc)"))));
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blots-editor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"description": "A lightweight Markdown editor for the terminal, built on ink-cartridge",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
18
|
"dist",
|
|
19
|
+
"asset",
|
|
19
20
|
"README.md",
|
|
20
21
|
"LICENSE"
|
|
21
22
|
],
|