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.
@@ -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
@@ -1 +1,2 @@
1
+ #!/usr/bin/env node
1
2
  export {};
package/dist/index.js CHANGED
@@ -1,16 +1,15 @@
1
- import { Box, render } from "ink";
1
+ #!/usr/bin/env node
2
+ import { render } from "ink";
2
3
  import React from "react";
3
- import { InformationBar } from "./view/information-bar.js";
4
- import { CurrentScreen, KeyboardProvider, registerComponent, ScenarioManagementProvider, } from "ink-cartridge";
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
- function App() {
7
- return (React.createElement(Box, { flexDirection: "column", height: "100%", width: "100%" },
8
- React.createElement(Box, { flexGrow: 1 },
9
- React.createElement(InformationBar, { mode: "NONE" })),
10
- React.createElement(Box, { height: "100%", width: "100%" },
11
- React.createElement(Editor, { value: "\u4F60\u597D", onChance: () => { } }))));
12
- }
13
- registerComponent(App, {});
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
+ }
@@ -1,16 +1,16 @@
1
1
  import React from "react";
2
2
  export type EditorContext = {
3
- onChance: () => void;
4
- value: string;
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 and routes every keystroke
12
- * to the controller as a named command. Rendering diffs via the controller's
13
- * change notifications; the controller lives in a ref so its state survives
14
- * re-renders (same pattern the old TextCalculation used).
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;
@@ -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 and routes every keystroke
8
- * to the controller as a named command. Rendering diffs via the controller's
9
- * change notifications; the controller lives in a ref so its state survives
10
- * re-renders (same pattern the old TextCalculation used).
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
- unbinds.push(boundKeyboard(["*"], (input) => controller.execute("editor.insertText", { text: input })));
30
- unbinds.push(boundKeyboard(["return"], () => controller.execute("editor.splitLine")));
31
- unbinds.push(boundKeyboard(["right"], () => controller.execute("cursor.moveRight")));
32
- unbinds.push(boundKeyboard(["left"], () => controller.execute("cursor.moveLeft")));
33
- // backspace deletes before the cursor; delete removes after it —
34
- // the old prototype treated both as backspace.
35
- unbinds.push(boundKeyboard(["backspace"], () => controller.execute("editor.deleteBefore")));
36
- unbinds.push(boundKeyboard(["delete"], () => controller.execute("editor.deleteAfter")));
37
- unbinds.push(boundKeyboard(["up"], () => controller.execute("cursor.moveUp")));
38
- unbinds.push(boundKeyboard(["down"], () => controller.execute("cursor.moveDown")));
39
- unbinds.push(boundKeyboard(["tab"], () => controller.execute("editor.indent")));
40
- unbinds.push(boundKeyboard(["shift+tab"], () => controller.execute("editor.outdent")));
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
- }, [boundKeyboard, enableWildcardPriority, controller]);
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 line at a time.
58
- // The callbacks close over this render's metrics, and useMouseRegion keeps
59
- // them fresh, so scrollTop/gutter always match what the user sees.
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
- return (React.createElement(Box, { ref: mouseRef, height: "100%", width: "100%", backgroundColor: "#1e1e1e" },
78
- React.createElement(Box, { ref: ref, height: "100%", width: "100%", flexDirection: "column" }, visibleLines.map((each, i) => {
79
- const lineNumber = visibleStart + i;
80
- return (React.createElement(Box, { key: lineNumber, flexDirection: "row" },
81
- React.createElement(Box, { width: lineNumberWidth, justifyContent: "flex-end", marginRight: lineNumberRightSpacing },
82
- React.createElement(Text, { bold: cursor.line === lineNumber }, lineNumber)),
83
- React.createElement(Text, null, each)));
84
- }))));
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 Information = {
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
- export declare function InformationBar({ mode }: Information): React.JSX.Element;
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
- export function InformationBar({ mode }) {
4
- return React.createElement(Box, { width: "100%", height: 1, backgroundColor: "blue", flexDirection: 'row', paddingLeft: 2 },
5
- React.createElement(Text, { color: 'white', bold: true }, mode));
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,2 @@
1
+ import React from "react";
2
+ export declare function MainMenu(): React.JSX.Element;
@@ -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,2 @@
1
+ import React from "react";
2
+ export declare function Settings(): React.JSX.Element;
@@ -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.7",
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
  ],