blots-editor 0.0.13 → 0.0.15
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 +19 -1
- package/asset/language/zh.json +19 -1
- package/dist/core/document/document.d.ts +14 -0
- package/dist/core/document/document.js +24 -2
- package/dist/core/document/text-line.d.ts +16 -1
- package/dist/core/document/text-line.js +36 -3
- package/dist/core/io/file-tree-model.d.ts +49 -0
- package/dist/core/io/file-tree-model.js +129 -0
- package/dist/core/io/session.d.ts +58 -0
- package/dist/core/io/session.js +116 -0
- package/dist/core/settings/schema.d.ts +13 -0
- package/dist/core/settings/schema.js +11 -0
- package/dist/core/settings/useSettings.d.ts +2 -0
- package/dist/core/settings/useSettings.js +17 -12
- package/dist/index.js +4 -4
- package/dist/{view → utils/view}/click-mapping.d.ts +1 -1
- package/dist/{view → utils/view}/frame-clamp.d.ts +4 -0
- package/dist/{view → utils/view}/frame-clamp.js +8 -0
- package/dist/{i18n-resources.js → utils/view/i18n-resources.js} +1 -1
- package/dist/view/editor/command-bar.d.ts +12 -0
- package/dist/view/{command-bar.js → editor/command-bar.js} +11 -2
- package/dist/view/editor/editor-setting.d.ts +8 -0
- package/dist/view/editor/editor-setting.js +69 -0
- package/dist/view/editor/file-tree.d.ts +19 -0
- package/dist/view/editor/file-tree.js +235 -0
- package/dist/view/editor/information-bar.d.ts +16 -0
- package/dist/view/editor/information-bar.js +35 -0
- package/dist/view/editor/tool-bar.d.ts +36 -0
- package/dist/view/editor/tool-bar.js +134 -0
- package/dist/view/event/subscription/toolbar-store.d.ts +18 -0
- package/dist/view/event/subscription/toolbar-store.js +25 -0
- package/dist/view/event/subscription/tree-store.d.ts +10 -0
- package/dist/view/event/subscription/tree-store.js +28 -0
- package/dist/view/{editor.js → page/editor.js} +94 -18
- package/dist/view/{main-menu.js → page/main-menu.js} +1 -1
- package/dist/view/page/settings.d.ts +13 -0
- package/dist/view/{settings.js → page/settings.js} +117 -10
- package/dist/view/utils/modal-frame.js +75 -0
- package/package.json +1 -1
- package/dist/view/command-bar.d.ts +0 -7
- package/dist/view/information-bar.d.ts +0 -10
- package/dist/view/information-bar.js +0 -13
- package/dist/view/modal-frame.js +0 -43
- package/dist/view/settings.d.ts +0 -2
- package/dist/view/tool-bar.d.ts +0 -51
- package/dist/view/tool-bar.js +0 -92
- /package/dist/{view → utils/view}/click-mapping.js +0 -0
- /package/dist/{i18n-resources.d.ts → utils/view/i18n-resources.d.ts} +0 -0
- /package/dist/view/{editor.d.ts → page/editor.d.ts} +0 -0
- /package/dist/view/{main-menu.d.ts → page/main-menu.d.ts} +0 -0
- /package/dist/view/{modal-frame.d.ts → utils/modal-frame.d.ts} +0 -0
- /package/dist/view/{sensitivity-bar.d.ts → utils/sensitivity-bar.d.ts} +0 -0
- /package/dist/view/{sensitivity-bar.js → utils/sensitivity-bar.js} +0 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useI18n, useKeyboard, useMouseRegion } from "ink-cartridge";
|
|
2
|
+
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { Box, measureElement, Text, useWindowSize } from "ink";
|
|
5
|
+
import { useTree } from "../event/subscription/tree-store.js";
|
|
6
|
+
import { getBarPosition, setBarPosition, useBarPosition, } from "../event/subscription/toolbar-store.js";
|
|
7
|
+
import { clampBarPos } from "../../utils/view/frame-clamp.js";
|
|
8
|
+
function ToolButton({ priority, onClick, text, focused = false, onDragStart, onDragMove, onDragEnd, }) {
|
|
9
|
+
const [flush, setFlush] = useState(false);
|
|
10
|
+
const timerRef = React.useRef(null);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
return () => {
|
|
13
|
+
if (timerRef.current) {
|
|
14
|
+
clearTimeout(timerRef.current);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}, []);
|
|
18
|
+
const ref = useMouseRegion({
|
|
19
|
+
onClick: (event) => {
|
|
20
|
+
setFlush(true);
|
|
21
|
+
if (timerRef.current) {
|
|
22
|
+
clearTimeout(timerRef.current);
|
|
23
|
+
}
|
|
24
|
+
timerRef.current = setTimeout(() => {
|
|
25
|
+
setFlush(false);
|
|
26
|
+
}, 150);
|
|
27
|
+
onClick(event);
|
|
28
|
+
},
|
|
29
|
+
onDragEnd,
|
|
30
|
+
onDragMove,
|
|
31
|
+
onDragStart,
|
|
32
|
+
}, { priority });
|
|
33
|
+
return (React.createElement(Box, { ref: ref, borderColor: flush ? "whiteBright" : focused ? "blue" : "white", borderStyle: "bold",
|
|
34
|
+
// Match the editor's surface so the floating bar reads as part of it.
|
|
35
|
+
backgroundColor: "#1e1e1e", height: "100%", justifyContent: "center", alignItems: "center", borderBackgroundColor: "#1e1e1e", paddingLeft: 2, paddingRight: 2 },
|
|
36
|
+
React.createElement(Text, null, text)));
|
|
37
|
+
}
|
|
38
|
+
export function ToolBar({ currentMode, session, openFileTree, openSettings, fileTreeOpen, }) {
|
|
39
|
+
const { t } = useI18n();
|
|
40
|
+
const { boundKeyboard } = useKeyboard();
|
|
41
|
+
const [index, setIndex] = useState(0);
|
|
42
|
+
const items = useMemo(() => [
|
|
43
|
+
{
|
|
44
|
+
id: "save",
|
|
45
|
+
onClick: () => {
|
|
46
|
+
session.save();
|
|
47
|
+
},
|
|
48
|
+
text: t("toolbar.save"),
|
|
49
|
+
priority: 1,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: "file-tree",
|
|
53
|
+
onClick: () => {
|
|
54
|
+
openFileTree();
|
|
55
|
+
},
|
|
56
|
+
text: t("toolbar.fileTree"),
|
|
57
|
+
priority: 1,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "settings",
|
|
61
|
+
onClick: () => {
|
|
62
|
+
openSettings();
|
|
63
|
+
},
|
|
64
|
+
text: t("toolbar.settings"),
|
|
65
|
+
priority: 1,
|
|
66
|
+
},
|
|
67
|
+
], [session, t, openFileTree, openSettings]);
|
|
68
|
+
const { rows, columns } = useWindowSize();
|
|
69
|
+
const pos = useBarPosition();
|
|
70
|
+
const treePos = useTree();
|
|
71
|
+
const barRef = useRef(null);
|
|
72
|
+
// Grab offset (where inside the bar the cursor went down) plus the bar's
|
|
73
|
+
// size at grab time, kept in a ref so drag moves need no re-measurement.
|
|
74
|
+
const grabRef = useRef({ dx: 0, dy: 0, width: 0, height: 0 });
|
|
75
|
+
const treeWidth = treePos.width;
|
|
76
|
+
const startDrag = useCallback((event) => {
|
|
77
|
+
const node = barRef.current;
|
|
78
|
+
if (!node) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Measure the bar itself, not the hit region — a button may be the
|
|
82
|
+
// captured target, and its rect would put the bar's corner at the
|
|
83
|
+
// button's corner.
|
|
84
|
+
const m = measureElement(node);
|
|
85
|
+
grabRef.current = {
|
|
86
|
+
dx: event.x - m.x - 1,
|
|
87
|
+
dy: event.y - m.y - 1,
|
|
88
|
+
width: m.width,
|
|
89
|
+
height: m.height,
|
|
90
|
+
};
|
|
91
|
+
setBarPosition({ top: m.y, left: m.x });
|
|
92
|
+
}, []);
|
|
93
|
+
const moveDrag = useCallback((event) => {
|
|
94
|
+
const { dx, dy, width, height } = grabRef.current;
|
|
95
|
+
setBarPosition(clampBarPos(event.x - dx - 1, event.y - dy - 1, width, height, columns, rows, fileTreeOpen, treeWidth));
|
|
96
|
+
}, [columns, rows, fileTreeOpen, treeWidth]);
|
|
97
|
+
const endDrag = useCallback(() => { }, []);
|
|
98
|
+
// Re-clamp when a layout input changes (resize, tree toggled): a bar
|
|
99
|
+
// positioned earlier can end up outside the allowed region.
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const node = barRef.current;
|
|
102
|
+
if (!node) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const m = measureElement(node);
|
|
106
|
+
const current = getBarPosition();
|
|
107
|
+
if (!current) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const next = clampBarPos(current.left, current.top, m.width, m.height, columns, rows, fileTreeOpen, treeWidth);
|
|
111
|
+
if (next.left !== current.left || next.top !== current.top) {
|
|
112
|
+
setBarPosition(next);
|
|
113
|
+
}
|
|
114
|
+
}, [columns, rows, fileTreeOpen, treeWidth]);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const unbinds = [
|
|
117
|
+
boundKeyboard(["ctrl+left"], () => {
|
|
118
|
+
setIndex((prev) => (prev - 1 + items.length) % items.length);
|
|
119
|
+
}),
|
|
120
|
+
boundKeyboard(["ctrl+right"], () => {
|
|
121
|
+
setIndex((prev) => (prev + 1) % items.length);
|
|
122
|
+
}),
|
|
123
|
+
boundKeyboard(["return"], () => {
|
|
124
|
+
items[index].onClick();
|
|
125
|
+
}),
|
|
126
|
+
];
|
|
127
|
+
return () => {
|
|
128
|
+
unbinds.forEach((unbind) => unbind());
|
|
129
|
+
};
|
|
130
|
+
}, [boundKeyboard, index, items]);
|
|
131
|
+
const showFocus = currentMode === "normal";
|
|
132
|
+
return (React.createElement(Box, { width: "100%", height: "100%", justifyContent: "center", alignItems: "flex-end" },
|
|
133
|
+
React.createElement(Box, { ref: barRef, position: pos ? "absolute" : undefined, top: pos?.top, left: pos?.left, height: 3, flexDirection: "row", justifyContent: "center", alignItems: "center" }, items.map((item, itemIndex) => (React.createElement(ToolButton, { key: item.id, onClick: item.onClick, text: item.text, priority: item.priority, focused: showFocus && index === itemIndex, onDragStart: startDrag, onDragMove: moveDrag, onDragEnd: endDrag }))))));
|
|
134
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level drag position of the floating toolbar. null = the bar sits
|
|
3
|
+
* at the bottom-center via flex layout; once the user grabs it, absolute
|
|
4
|
+
* positioning takes over so it follows the cursor. An external store
|
|
5
|
+
* survives the layer system's erase/re-apply remounts, so a dragged bar
|
|
6
|
+
* keeps its spot.
|
|
7
|
+
*/
|
|
8
|
+
type BarPosition = {
|
|
9
|
+
top: number;
|
|
10
|
+
left: number;
|
|
11
|
+
} | null;
|
|
12
|
+
/** Read the current persisted position (imperative access for effects). */
|
|
13
|
+
export declare function getBarPosition(): BarPosition;
|
|
14
|
+
/** Update the persisted bar position (drag move / initial grab). */
|
|
15
|
+
export declare function setBarPosition(next: BarPosition): void;
|
|
16
|
+
/** Reactive binding for the toolbar: null = bottom-center flex layout. */
|
|
17
|
+
export declare function useBarPosition(): BarPosition;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
let position = null;
|
|
3
|
+
const listeners = new Set();
|
|
4
|
+
function getPosition() {
|
|
5
|
+
return position;
|
|
6
|
+
}
|
|
7
|
+
function subscribe(listener) {
|
|
8
|
+
listeners.add(listener);
|
|
9
|
+
return () => {
|
|
10
|
+
listeners.delete(listener);
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/** Read the current persisted position (imperative access for effects). */
|
|
14
|
+
export function getBarPosition() {
|
|
15
|
+
return position;
|
|
16
|
+
}
|
|
17
|
+
/** Update the persisted bar position (drag move / initial grab). */
|
|
18
|
+
export function setBarPosition(next) {
|
|
19
|
+
position = next;
|
|
20
|
+
listeners.forEach((fn) => fn());
|
|
21
|
+
}
|
|
22
|
+
/** Reactive binding for the toolbar: null = bottom-center flex layout. */
|
|
23
|
+
export function useBarPosition() {
|
|
24
|
+
return useSyncExternalStore(subscribe, getPosition, getPosition);
|
|
25
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type Rect = {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
};
|
|
7
|
+
export declare function getTreePos(): Rect;
|
|
8
|
+
export declare function setTreePos(newPos: Rect): void;
|
|
9
|
+
/** Reactive binding: re-renders whenever the pane width changes. */
|
|
10
|
+
export declare function useTree(): Rect;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Current width of the file-tree pane, shared with the toolbar.
|
|
4
|
+
*
|
|
5
|
+
* The tree sizes itself to its content, so its width is dynamic; the toolbar
|
|
6
|
+
* must not overlap it, so it reads the live width instead of a constant.
|
|
7
|
+
*/
|
|
8
|
+
let pos = { x: 0, y: 0, width: 0, height: 0 };
|
|
9
|
+
const listeners = new Set();
|
|
10
|
+
function subscribe(listener) {
|
|
11
|
+
listeners.add(listener);
|
|
12
|
+
return () => {
|
|
13
|
+
listeners.delete(listener);
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function getTreePos() {
|
|
17
|
+
return pos;
|
|
18
|
+
}
|
|
19
|
+
export function setTreePos(newPos) {
|
|
20
|
+
pos = newPos;
|
|
21
|
+
for (const listener of listeners) {
|
|
22
|
+
listener();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Reactive binding: re-renders whenever the pane width changes. */
|
|
26
|
+
export function useTree() {
|
|
27
|
+
return useSyncExternalStore(subscribe, getTreePos, getTreePos);
|
|
28
|
+
}
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import { Box, Text, useBoxMetrics, useCursor } from "ink";
|
|
2
|
-
import { applyElement, applyElementToModalLayer, closeLayer, openLayer, openModalLayer, useI18n, useKeyboard, useMouseRegion, } from "ink-cartridge";
|
|
2
|
+
import { applyElement, applyElementToModalLayer, closeLayer, eraseElement, openLayer, openModalLayer, useI18n, useKeyboard, useMouseRegion, } from "ink-cartridge";
|
|
3
3
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
-
import {
|
|
5
|
-
import { useSettings } from "
|
|
6
|
-
import { clickToPosition } from "
|
|
7
|
-
import { CommandBar } from "
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
4
|
+
import { EditorSession } from "../../core/io/session.js";
|
|
5
|
+
import { useSettings } from "../../core/settings/useSettings.js";
|
|
6
|
+
import { clickToPosition } from "../../utils/view/click-mapping.js";
|
|
7
|
+
import { CommandBar } from "../editor/command-bar.js";
|
|
8
|
+
import { EditorSetting } from "../editor/editor-setting.js";
|
|
9
|
+
import { FileTree } from "../editor/file-tree.js";
|
|
10
|
+
import { ToolBar } from "../editor/tool-bar.js";
|
|
11
|
+
import { InformationBar } from "../editor/information-bar.js";
|
|
10
12
|
/** Layer hosting the floating toolbar; below modal layers so it never
|
|
11
13
|
* intercepts keys meant for a modal (e.g. the command bar). */
|
|
12
14
|
const TOOLBAR_LAYER_ID = "toolbar";
|
|
13
15
|
const TOOLBAR_Z_INDEX = 1;
|
|
16
|
+
/** Layer hosting the file tree; a regular layer like the toolbar. */
|
|
17
|
+
const FILETREE_LAYER_ID = "file-tree";
|
|
18
|
+
const FILETREE_Z_INDEX = 1;
|
|
19
|
+
/** Layer hosting the in-editor settings overlay (above the other panes). */
|
|
20
|
+
const EDITOR_SETTINGS_LAYER_ID = "editor-settings";
|
|
21
|
+
const EDITOR_SETTINGS_Z_INDEX = 2;
|
|
14
22
|
/**
|
|
15
23
|
* Main editor view: renders the status bar + line numbers + text, routes
|
|
16
24
|
* every keystroke to the controller as a named command, and splits
|
|
@@ -18,17 +26,26 @@ const TOOLBAR_Z_INDEX = 1;
|
|
|
18
26
|
* hjkl + arrow-key movement and the `:` command bar.
|
|
19
27
|
*/
|
|
20
28
|
export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cursorOffset = 0, cursorHeightOffset = 2, numberOfIndentationSpaces = 2, }) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
// One file session per editor mount; `Document.setText` keeps the
|
|
30
|
+
// controller instance stable across file opens, so bindings stay live.
|
|
31
|
+
const sessionRef = useRef(null);
|
|
32
|
+
if (!sessionRef.current) {
|
|
33
|
+
sessionRef.current = new EditorSession(initialText, {
|
|
24
34
|
indentWidth: numberOfIndentationSpaces,
|
|
25
35
|
});
|
|
26
36
|
}
|
|
27
|
-
const
|
|
37
|
+
const session = sessionRef.current;
|
|
38
|
+
const controller = session.controller;
|
|
28
39
|
const [, forceUpdate] = useState(0);
|
|
29
40
|
useEffect(() => {
|
|
30
|
-
|
|
31
|
-
|
|
41
|
+
const unbindDoc = controller.onChange(() => forceUpdate((n) => n + 1));
|
|
42
|
+
// File opens/saves notify the session, not the controller.
|
|
43
|
+
const unbindSession = session.onChange(() => forceUpdate((n) => n + 1));
|
|
44
|
+
return () => {
|
|
45
|
+
unbindDoc();
|
|
46
|
+
unbindSession();
|
|
47
|
+
};
|
|
48
|
+
}, [controller, session]);
|
|
32
49
|
const { setCursorPosition } = useCursor();
|
|
33
50
|
const { boundKeyboard, enableWildcardPriority, getCurrentMode, registryCompositionKey, removeCompositionKey, setMode, } = useKeyboard();
|
|
34
51
|
const { t } = useI18n();
|
|
@@ -45,8 +62,9 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
45
62
|
applyElementToModalLayer("command", {
|
|
46
63
|
elementId: "command-input",
|
|
47
64
|
element: CommandBar,
|
|
65
|
+
props: { session },
|
|
48
66
|
});
|
|
49
|
-
}, []);
|
|
67
|
+
}, [session]);
|
|
50
68
|
// The toolbar lives in a regular layer (below modal layers) so it floats
|
|
51
69
|
// above the document without taking over the keyboard. The editor owns the
|
|
52
70
|
// layer lifecycle and the ctrl+tab toggle because the layer's element
|
|
@@ -60,6 +78,12 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
60
78
|
// dispatcher registration and openLayer would throw. ctrl+tab toggles hit
|
|
61
79
|
// the same deferred path — the one-tick delay is imperceptible. Closing is
|
|
62
80
|
// safe immediately because the dispatcher is registered by then.
|
|
81
|
+
// The file tree pane (right side) toggles via the toolbar's File Tree
|
|
82
|
+
// button; it defaults to open so the editor greets the user with a tree.
|
|
83
|
+
const [fileTreeOpen, setFileTreeOpen] = useState(true);
|
|
84
|
+
// The in-editor settings overlay toggles via the toolbar's Settings
|
|
85
|
+
// button; the overlay itself closes via its Exit button or Esc.
|
|
86
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
63
87
|
useEffect(() => {
|
|
64
88
|
if (!toolbarOpen) {
|
|
65
89
|
closeLayer(TOOLBAR_LAYER_ID);
|
|
@@ -67,14 +91,63 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
67
91
|
}
|
|
68
92
|
const timer = setTimeout(() => {
|
|
69
93
|
openLayer(TOOLBAR_LAYER_ID, TOOLBAR_Z_INDEX);
|
|
94
|
+
// applyElement ignores re-applies of an existing element id, so
|
|
95
|
+
// erase first — otherwise prop updates (mode, fileTreeOpen) would
|
|
96
|
+
// never reach the mounted toolbar. The toolbar's drag position
|
|
97
|
+
// survives the remount via the external position store.
|
|
98
|
+
eraseElement(TOOLBAR_LAYER_ID, "toolbar");
|
|
70
99
|
applyElement(TOOLBAR_LAYER_ID, {
|
|
71
100
|
elementId: "toolbar",
|
|
72
101
|
element: ToolBar,
|
|
73
|
-
props: {
|
|
102
|
+
props: {
|
|
103
|
+
currentMode: mode,
|
|
104
|
+
session,
|
|
105
|
+
openFileTree: () => setFileTreeOpen((open) => !open),
|
|
106
|
+
openSettings: () => setSettingsOpen((open) => !open),
|
|
107
|
+
fileTreeOpen
|
|
108
|
+
},
|
|
74
109
|
});
|
|
75
110
|
}, 0);
|
|
76
111
|
return () => clearTimeout(timer);
|
|
77
|
-
}, [toolbarOpen, mode]);
|
|
112
|
+
}, [toolbarOpen, mode, session, fileTreeOpen, settingsOpen]);
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!fileTreeOpen) {
|
|
115
|
+
closeLayer(FILETREE_LAYER_ID);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
openLayer(FILETREE_LAYER_ID, FILETREE_Z_INDEX);
|
|
120
|
+
applyElement(FILETREE_LAYER_ID, {
|
|
121
|
+
elementId: "file-tree",
|
|
122
|
+
element: FileTree,
|
|
123
|
+
props: { session },
|
|
124
|
+
});
|
|
125
|
+
}, 0);
|
|
126
|
+
return () => clearTimeout(timer);
|
|
127
|
+
}, [fileTreeOpen, session]);
|
|
128
|
+
// Both branches are deferred: settingsOpen starts false, and an immediate
|
|
129
|
+
// closeLayer on the first mount would race the provider's dispatcher
|
|
130
|
+
// registration (same child-first effect ordering as the toolbar open).
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const timer = setTimeout(() => {
|
|
133
|
+
if (!settingsOpen) {
|
|
134
|
+
closeLayer(EDITOR_SETTINGS_LAYER_ID);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
openLayer(EDITOR_SETTINGS_LAYER_ID, EDITOR_SETTINGS_Z_INDEX);
|
|
138
|
+
applyElement(EDITOR_SETTINGS_LAYER_ID, {
|
|
139
|
+
elementId: "editor-settings",
|
|
140
|
+
element: EditorSetting,
|
|
141
|
+
});
|
|
142
|
+
}, 0);
|
|
143
|
+
return () => clearTimeout(timer);
|
|
144
|
+
}, [settingsOpen]);
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
return () => closeLayer(EDITOR_SETTINGS_LAYER_ID);
|
|
147
|
+
}, []);
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
return () => closeLayer(FILETREE_LAYER_ID);
|
|
150
|
+
}, []);
|
|
78
151
|
useEffect(() => {
|
|
79
152
|
return boundKeyboard(["ctrl+tab"], () => setToolbarOpen((open) => !open));
|
|
80
153
|
}, [boundKeyboard]);
|
|
@@ -133,6 +206,8 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
133
206
|
bind(["shift+G"], () => controller.execute("cursor.documentEnd"), { mode: "normal" });
|
|
134
207
|
bind(["i"], () => switchMode("insert"), { mode: "normal" });
|
|
135
208
|
bind([":"], () => openCommandBar(), { mode: "normal" });
|
|
209
|
+
// Ctrl+S saves in normal mode (Vim-style; insert stays untouched).
|
|
210
|
+
bind(["ctrl+s"], () => session.save(), { mode: "normal" });
|
|
136
211
|
return () => {
|
|
137
212
|
removeWildcard();
|
|
138
213
|
unbinds.forEach((fn) => fn());
|
|
@@ -146,6 +221,7 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
146
221
|
registryCompositionKey,
|
|
147
222
|
removeCompositionKey,
|
|
148
223
|
switchMode,
|
|
224
|
+
session,
|
|
149
225
|
]);
|
|
150
226
|
const ref = useRef(null);
|
|
151
227
|
const { height, width } = useBoxMetrics(ref);
|
|
@@ -196,7 +272,7 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
196
272
|
});
|
|
197
273
|
const modeText = mode === "normal" ? t("editor.mode.normal") : t("editor.mode.insert");
|
|
198
274
|
return (React.createElement(Box, { flexDirection: "column", height: "100%", width: "100%" },
|
|
199
|
-
React.createElement(InformationBar, { mode: modeText, cursor: { line: cursor.line, column: cursor.visual } }),
|
|
275
|
+
React.createElement(InformationBar, { mode: modeText, cursor: { line: cursor.line, column: cursor.visual }, session: session }),
|
|
200
276
|
React.createElement(Box, { ref: mouseRef, flexGrow: 1, width: "100%", backgroundColor: "#1e1e1e" },
|
|
201
277
|
React.createElement(Box, { ref: ref, height: "100%", width: "100%", flexDirection: "column" }, Array.from({ length: effectiveH }, (_, i) => {
|
|
202
278
|
const vline = visibleStart + i;
|
|
@@ -206,6 +282,6 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
206
282
|
}
|
|
207
283
|
return (React.createElement(Box, { key: vline, flexDirection: "row" },
|
|
208
284
|
React.createElement(Box, { width: lineNumberWidth, justifyContent: "flex-end", marginRight: lineNumberRightSpacing }, seg.first ? (React.createElement(Text, { bold: cursor.line === seg.line }, seg.line)) : null),
|
|
209
|
-
React.createElement(Text, null,
|
|
285
|
+
React.createElement(Text, null, seg.text)));
|
|
210
286
|
})))));
|
|
211
287
|
}
|
|
@@ -4,7 +4,7 @@ import React, { useEffect, useState } from "react";
|
|
|
4
4
|
import { Editor } from "./editor.js";
|
|
5
5
|
import { Settings } from "./settings.js";
|
|
6
6
|
import { gotoScreen } from "ink-cartridge";
|
|
7
|
-
import { getLogo } from "
|
|
7
|
+
import { getLogo } from "../logo.js";
|
|
8
8
|
/**
|
|
9
9
|
* A borderless text button: the plain Box provides the mouse hit area,
|
|
10
10
|
* hovering inverts the text as visual feedback, disabled buttons dim.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export type SettingsEntriesProps = {
|
|
3
|
+
/** Called on Esc/Backspace — the screen navigates back, the in-editor
|
|
4
|
+
* settings layer closes itself. */
|
|
5
|
+
onExit: () => void;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* The setting rows with their keyboard navigation and modal pickers.
|
|
9
|
+
* Shared by the settings screen and the in-editor settings layer; the only
|
|
10
|
+
* difference is what `onExit` does.
|
|
11
|
+
*/
|
|
12
|
+
export declare function SettingsEntries({ onExit }: SettingsEntriesProps): React.JSX.Element;
|
|
13
|
+
export declare function Settings(): React.JSX.Element;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import { back, ModalLayerElementContext, useI18n, useKeyboard, useMouseRegion, useScreenSystem, } from "ink-cartridge";
|
|
3
|
-
import React, { useCallback, useContext, useEffect, useMemo, useState, } from "react";
|
|
4
|
-
import { useSettings } from "
|
|
5
|
-
import { ModalFrame } from "
|
|
6
|
-
import { SensitivityBar, snapSensitivity } from "
|
|
3
|
+
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
4
|
+
import { useSettings } from "../../core/settings/useSettings.js";
|
|
5
|
+
import { ModalFrame } from "../utils/modal-frame.js";
|
|
6
|
+
import { SensitivityBar, snapSensitivity } from "../utils/sensitivity-bar.js";
|
|
7
7
|
const LANGUAGES = [
|
|
8
8
|
{ code: "en", labelKey: "settings.language.en" },
|
|
9
9
|
{ code: "zh", labelKey: "settings.language.zh" },
|
|
@@ -95,16 +95,101 @@ function SensitivityPicker({ sensitivityKey }) {
|
|
|
95
95
|
value.toFixed(1),
|
|
96
96
|
"\u00D7")));
|
|
97
97
|
}
|
|
98
|
-
/**
|
|
98
|
+
/** Small clickable button used inside the file-tree-root picker. */
|
|
99
|
+
function PickerButton({ label, active, onEnter, onLeave, onClick }) {
|
|
100
|
+
const ref = useMouseRegion({ onEnter, onLeave, onClick }, { priority: 1 });
|
|
101
|
+
return (React.createElement(Box, { ref: ref, paddingLeft: 1, paddingRight: 1 },
|
|
102
|
+
React.createElement(Text, { inverse: active }, label)));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Modal picker for the file-tree root: startup directory or a custom path.
|
|
106
|
+
* Up/down switches the mode; while "custom" is active the row is a text
|
|
107
|
+
* input (any key appends, backspace deletes). Return confirms, Esc cancels.
|
|
108
|
+
*/
|
|
109
|
+
function FileTreePicker() {
|
|
110
|
+
const ctx = useContext(ModalLayerElementContext);
|
|
111
|
+
const { t } = useI18n();
|
|
112
|
+
const { boundKeyboard } = useKeyboard();
|
|
113
|
+
const { closeModalLayer } = useScreenSystem();
|
|
114
|
+
const { settings, setFileTree } = useSettings();
|
|
115
|
+
const [root, setRoot] = useState(settings.fileTree.root);
|
|
116
|
+
const [path, setPath] = useState(settings.fileTree.customPath);
|
|
117
|
+
const [hovered, setHovered] = useState(null);
|
|
118
|
+
// The wildcard handler needs the current mode without rebinding on
|
|
119
|
+
// every keystroke; a ref keeps it fresh.
|
|
120
|
+
const rootRef = useRef(root);
|
|
121
|
+
rootRef.current = root;
|
|
122
|
+
const apply = useCallback(() => {
|
|
123
|
+
if (!ctx) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
setFileTree({
|
|
127
|
+
root,
|
|
128
|
+
customPath: root === "custom" ? path : "",
|
|
129
|
+
});
|
|
130
|
+
closeModalLayer(ctx.modalLayer.layerId);
|
|
131
|
+
}, [closeModalLayer, ctx, path, root, setFileTree]);
|
|
132
|
+
const close = useCallback(() => {
|
|
133
|
+
if (ctx) {
|
|
134
|
+
closeModalLayer(ctx.modalLayer.layerId);
|
|
135
|
+
}
|
|
136
|
+
}, [closeModalLayer, ctx]);
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!ctx) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const unbinds = [
|
|
142
|
+
boundKeyboard(["up"], () => setRoot((r) => (r === "custom" ? "startup" : "custom")), { elementId: ctx.id }),
|
|
143
|
+
boundKeyboard(["down"], () => setRoot((r) => (r === "custom" ? "startup" : "custom")), { elementId: ctx.id }),
|
|
144
|
+
boundKeyboard(["*"], (ch) => {
|
|
145
|
+
// Typing only makes sense while editing a custom path.
|
|
146
|
+
if (rootRef.current === "custom") {
|
|
147
|
+
setPath((p) => p + ch);
|
|
148
|
+
}
|
|
149
|
+
}, { elementId: ctx.id }),
|
|
150
|
+
boundKeyboard(["backspace"], () => {
|
|
151
|
+
if (rootRef.current === "custom") {
|
|
152
|
+
setPath((p) => p.slice(0, -1));
|
|
153
|
+
}
|
|
154
|
+
}, { elementId: ctx.id }),
|
|
155
|
+
boundKeyboard(["return"], apply, { elementId: ctx.id }),
|
|
156
|
+
boundKeyboard(["escape"], close, { elementId: ctx.id }),
|
|
157
|
+
];
|
|
158
|
+
return () => unbinds.forEach((fn) => fn());
|
|
159
|
+
}, [apply, boundKeyboard, close, ctx]);
|
|
160
|
+
return (React.createElement(ModalFrame, { title: t("settings.fileTreeRoot") },
|
|
161
|
+
React.createElement(Box, { flexDirection: "column" },
|
|
162
|
+
React.createElement(Text, { inverse: root === "startup" },
|
|
163
|
+
root === "startup" ? "▸ " : " ",
|
|
164
|
+
t("settings.fileTreeRoot.startup")),
|
|
165
|
+
React.createElement(Text, { inverse: root === "custom" },
|
|
166
|
+
root === "custom" ? "▸ " : " ",
|
|
167
|
+
t("settings.fileTreeRoot.custom"),
|
|
168
|
+
": ",
|
|
169
|
+
React.createElement(Text, { color: root === "custom" ? undefined : "gray" },
|
|
170
|
+
path,
|
|
171
|
+
root === "custom" ? "▌" : ""))),
|
|
172
|
+
React.createElement(Box, { flexDirection: "row", gap: 2 },
|
|
173
|
+
React.createElement(PickerButton, { label: t("settings.ok"), active: hovered === "ok", onEnter: () => setHovered("ok"), onLeave: () => setHovered((h) => (h === "ok" ? null : h)), onClick: apply }),
|
|
174
|
+
React.createElement(PickerButton, { label: t("settings.cancel"), active: hovered === "cancel", onEnter: () => setHovered("cancel"), onLeave: () => setHovered((h) => (h === "cancel" ? null : h)), onClick: close }))));
|
|
175
|
+
}
|
|
176
|
+
/** A single settings entry: label plus its current value; opens a picker on
|
|
177
|
+
* activation. priority 1: rows live inside the draggable ModalFrame in the
|
|
178
|
+
* in-editor settings layer and must win over the frame region. */
|
|
99
179
|
function SettingRow({ label, value, active, onEnter, onLeave, onClick, }) {
|
|
100
|
-
const ref = useMouseRegion({ onEnter, onLeave, onClick });
|
|
180
|
+
const ref = useMouseRegion({ onEnter, onLeave, onClick }, { priority: 1 });
|
|
101
181
|
return (React.createElement(Box, { ref: ref, paddingLeft: 1, paddingRight: 1, flexDirection: "row", gap: 2 },
|
|
102
182
|
React.createElement(Text, { inverse: active },
|
|
103
183
|
active ? "> " : " ",
|
|
104
184
|
label),
|
|
105
185
|
React.createElement(Text, { dimColor: true }, value)));
|
|
106
186
|
}
|
|
107
|
-
|
|
187
|
+
/**
|
|
188
|
+
* The setting rows with their keyboard navigation and modal pickers.
|
|
189
|
+
* Shared by the settings screen and the in-editor settings layer; the only
|
|
190
|
+
* difference is what `onExit` does.
|
|
191
|
+
*/
|
|
192
|
+
export function SettingsEntries({ onExit }) {
|
|
108
193
|
const { t, currentLanguage } = useI18n();
|
|
109
194
|
const { boundKeyboard } = useKeyboard();
|
|
110
195
|
const { openModalLayer, applyElementToModalLayer } = useScreenSystem();
|
|
@@ -128,6 +213,13 @@ export function Settings() {
|
|
|
128
213
|
element: Picker,
|
|
129
214
|
});
|
|
130
215
|
}, [applyElementToModalLayer, openModalLayer]);
|
|
216
|
+
const openFileTreePicker = useCallback(() => {
|
|
217
|
+
openModalLayer("file-tree-root", 50);
|
|
218
|
+
applyElementToModalLayer("file-tree-root", {
|
|
219
|
+
elementId: "file-tree-root-picker",
|
|
220
|
+
element: FileTreePicker,
|
|
221
|
+
});
|
|
222
|
+
}, [applyElementToModalLayer, openModalLayer]);
|
|
131
223
|
const currentLabel = LANGUAGES.find((lang) => lang.code === currentLanguage)?.labelKey ??
|
|
132
224
|
"settings.language.en";
|
|
133
225
|
const entries = useMemo(() => [
|
|
@@ -149,26 +241,41 @@ export function Settings() {
|
|
|
149
241
|
value: `${settings.wheel.view.toFixed(1)}×`,
|
|
150
242
|
open: () => openSensitivityPicker("view"),
|
|
151
243
|
},
|
|
244
|
+
{
|
|
245
|
+
id: "file-tree-root",
|
|
246
|
+
label: t("settings.fileTreeRoot"),
|
|
247
|
+
value: settings.fileTree.root === "custom" && settings.fileTree.customPath
|
|
248
|
+
? settings.fileTree.customPath
|
|
249
|
+
: t("settings.fileTreeRoot.startup"),
|
|
250
|
+
open: openFileTreePicker,
|
|
251
|
+
},
|
|
152
252
|
], [
|
|
153
253
|
t,
|
|
154
254
|
currentLabel,
|
|
155
255
|
settings.wheel.cursor,
|
|
156
256
|
settings.wheel.view,
|
|
257
|
+
settings.fileTree.root,
|
|
258
|
+
settings.fileTree.customPath,
|
|
157
259
|
openLanguagePicker,
|
|
158
260
|
openSensitivityPicker,
|
|
261
|
+
openFileTreePicker,
|
|
159
262
|
]);
|
|
160
263
|
useEffect(() => {
|
|
161
264
|
const unbinds = [
|
|
162
265
|
boundKeyboard(["up"], () => setIndex((i) => Math.max(0, i - 1))),
|
|
163
266
|
boundKeyboard(["down"], () => setIndex((i) => Math.min(entries.length - 1, i + 1))),
|
|
164
267
|
boundKeyboard(["return"], () => entries[index].open()),
|
|
165
|
-
boundKeyboard(["escape", "backspace"], () =>
|
|
268
|
+
boundKeyboard(["escape", "backspace"], () => onExit()),
|
|
166
269
|
];
|
|
167
270
|
return () => unbinds.forEach((fn) => fn());
|
|
168
|
-
}, [boundKeyboard, entries, index]);
|
|
271
|
+
}, [boundKeyboard, entries, index, onExit]);
|
|
272
|
+
return (React.createElement(Box, { flexDirection: "column", gap: 2, marginTop: 2 }, entries.map((entry, i) => (React.createElement(SettingRow, { key: entry.id, label: entry.label, value: entry.value, active: i === index || hovered === i, onEnter: () => setHovered(i), onLeave: () => setHovered((h) => (h === i ? null : h)), onClick: entry.open })))));
|
|
273
|
+
}
|
|
274
|
+
export function Settings() {
|
|
275
|
+
const { t } = useI18n();
|
|
169
276
|
return (React.createElement(Box, { flexDirection: "column", justifyContent: "center", alignItems: "center", width: "100%", height: "100%", gap: 2 },
|
|
170
277
|
React.createElement(Text, { bold: true }, t("settings.title")),
|
|
171
|
-
React.createElement(
|
|
278
|
+
React.createElement(SettingsEntries, { onExit: () => back() }),
|
|
172
279
|
React.createElement(Box, { marginTop: 4 },
|
|
173
280
|
React.createElement(Text, { dimColor: true },
|
|
174
281
|
t("settings.back"),
|