blots-editor 0.0.12 → 0.0.14
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 +16 -1
- package/asset/language/zh.json +16 -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/file-tree-model.d.ts +40 -0
- package/dist/core/file-tree-model.js +118 -0
- package/dist/core/session.d.ts +58 -0
- package/dist/core/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/view/command-bar.d.ts +6 -1
- package/dist/view/command-bar.js +11 -2
- package/dist/view/editor-setting.d.ts +8 -0
- package/dist/view/editor-setting.js +69 -0
- package/dist/view/editor.js +122 -10
- package/dist/view/file-tree.d.ts +15 -0
- package/dist/view/file-tree.js +217 -0
- package/dist/view/information-bar.d.ts +8 -2
- package/dist/view/information-bar.js +26 -4
- package/dist/view/modal-frame.js +36 -4
- package/dist/view/settings.d.ts +11 -0
- package/dist/view/settings.js +114 -7
- package/dist/view/tool-bar.d.ts +62 -0
- package/dist/view/tool-bar.js +148 -0
- package/dist/view/toolbar-store.d.ts +19 -0
- package/dist/view/toolbar-store.js +25 -0
- package/dist/view/tree-width-store.d.ts +6 -0
- package/dist/view/tree-width-store.js +31 -0
- package/package.json +1 -1
package/dist/view/modal-frame.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Box, Text, useWindowSize } from "ink";
|
|
2
2
|
import { useMouseRegion } from "ink-cartridge";
|
|
3
|
-
import React, { useRef, useState } from "react";
|
|
3
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
4
4
|
import { clampFrame } from "./frame-clamp.js";
|
|
5
5
|
/**
|
|
6
6
|
* Shared draggable modal frame used by the language and sensitivity pickers.
|
|
@@ -18,12 +18,16 @@ export function ModalFrame({ title, children, footer, width = 36, }) {
|
|
|
18
18
|
// Where inside the frame the cursor was grabbed, so the frame doesn't
|
|
19
19
|
// jump so its top-left lands under the cursor on the first drag move.
|
|
20
20
|
const grabOffsetRef = useRef({ dx: 0, dy: 0 });
|
|
21
|
+
// The frame's size, captured at grab time — used by the resize clamp
|
|
22
|
+
// below (a non-null position implies a drag happened, so this is set).
|
|
23
|
+
const frameSizeRef = useRef({ width: 0, height: 0 });
|
|
21
24
|
const frameRef = useMouseRegion({
|
|
22
25
|
onDragStart: (event, rect) => {
|
|
23
26
|
grabOffsetRef.current = {
|
|
24
27
|
dx: event.x - rect.x,
|
|
25
28
|
dy: event.y - rect.y,
|
|
26
29
|
};
|
|
30
|
+
frameSizeRef.current = { width: rect.width, height: rect.height };
|
|
27
31
|
// Switch from flex centering to absolute positioning at the
|
|
28
32
|
// current rect — seamless, no jump on the first frame.
|
|
29
33
|
setPos({ top: rect.y - 1, left: rect.x - 1 });
|
|
@@ -31,12 +35,40 @@ export function ModalFrame({ title, children, footer, width = 36, }) {
|
|
|
31
35
|
},
|
|
32
36
|
onDragMove: (event, rect) => {
|
|
33
37
|
const { dx, dy } = grabOffsetRef.current;
|
|
34
|
-
|
|
38
|
+
const clamped = clampFrame(event.x - dx - 1, event.y - dy - 1, columns, rows, rect.width, rect.height);
|
|
39
|
+
// The information bar occupies the top row — keep the frame below
|
|
40
|
+
// it, same as the toolbar.
|
|
41
|
+
setPos({ top: Math.max(1, clamped.top), left: clamped.left });
|
|
35
42
|
},
|
|
36
43
|
onDragEnd: () => setDragging(false),
|
|
37
44
|
});
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
// The drag clamp only runs while dragging — a terminal resize can shrink
|
|
46
|
+
// the viewport under a dragged frame afterwards. Re-clamp on every size
|
|
47
|
+
// change so the frame never extends past the edge (top stays ≥ 1 so it
|
|
48
|
+
// cannot overlap the information bar). The centered state (pos null) is
|
|
49
|
+
// flex-laid-out and follows the terminal automatically; a non-null pos
|
|
50
|
+
// implies a drag happened, so frameSizeRef is populated.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const { width, height } = frameSizeRef.current;
|
|
53
|
+
if (!width || !height) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
setPos((current) => {
|
|
57
|
+
if (!current) {
|
|
58
|
+
return current;
|
|
59
|
+
}
|
|
60
|
+
const clamped = clampFrame(current.left, current.top, columns, rows, width, height);
|
|
61
|
+
const next = { top: Math.max(1, clamped.top), left: clamped.left };
|
|
62
|
+
return next.left === current.left && next.top === current.top ? current : next;
|
|
63
|
+
});
|
|
64
|
+
}, [columns, rows]);
|
|
65
|
+
return (
|
|
66
|
+
// paddingTop keeps the centered frame below the information bar even
|
|
67
|
+
// in short terminals where centering would push it into row 1.
|
|
68
|
+
React.createElement(Box, { width: "100%", height: "100%", paddingTop: 1, justifyContent: "center", alignItems: "center" },
|
|
69
|
+
React.createElement(Box, { ref: frameRef, position: pos ? "absolute" : undefined, top: pos?.top, left: pos?.left, flexDirection: "column", borderStyle: "bold", borderColor: dragging ? "green" : "white",
|
|
70
|
+
// Match the editor surface so dialogs read as part of it.
|
|
71
|
+
backgroundColor: "#1e1e1e", borderBackgroundColor: "#1e1e1e", width: width, alignItems: "center", paddingX: 4, paddingY: 1, gap: 1 },
|
|
40
72
|
React.createElement(Text, { bold: true }, title),
|
|
41
73
|
children,
|
|
42
74
|
footer)));
|
package/dist/view/settings.d.ts
CHANGED
|
@@ -1,2 +1,13 @@
|
|
|
1
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;
|
|
2
13
|
export declare function Settings(): React.JSX.Element;
|
package/dist/view/settings.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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";
|
|
3
|
+
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
4
4
|
import { useSettings } from "../core/settings/useSettings.js";
|
|
5
5
|
import { ModalFrame } from "./modal-frame.js";
|
|
6
6
|
import { SensitivityBar, snapSensitivity } from "./sensitivity-bar.js";
|
|
@@ -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"),
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { XtermMouseEvent } from "@cartridge-engine/keyboard-engine";
|
|
3
|
+
import type { EditorSession } from "../core/session.js";
|
|
4
|
+
/**
|
|
5
|
+
* Props for a single tool button.
|
|
6
|
+
*
|
|
7
|
+
* @prop priority - Hit-test priority over whatever sits underneath (the editor
|
|
8
|
+
* surface). Buttons must win so clicking them does not also place the cursor.
|
|
9
|
+
* @prop onClick - Called on mouse click and on Enter selection in normal
|
|
10
|
+
* mode; the event is absent for the keyboard path.
|
|
11
|
+
* @prop text - The text to display on the button.
|
|
12
|
+
* @prop focused - Keyboard focus (normal-mode navigation); overrides hover.
|
|
13
|
+
* @prop onDragStart/onDragMove/onDragEnd - Forwarded from the toolbar. The
|
|
14
|
+
* engine captures a drag on the pressed region only, so buttons must carry
|
|
15
|
+
* the drag handlers or dragging from a button would not move the bar.
|
|
16
|
+
* @prop dragging - The bar is being dragged; buttons tint red.
|
|
17
|
+
*/
|
|
18
|
+
export type ToolButtonProps = {
|
|
19
|
+
priority: number;
|
|
20
|
+
onClick: (event?: XtermMouseEvent) => void;
|
|
21
|
+
text: string;
|
|
22
|
+
focused?: boolean;
|
|
23
|
+
onDragStart?: (event: XtermMouseEvent) => void;
|
|
24
|
+
onDragMove?: (event: XtermMouseEvent) => void;
|
|
25
|
+
onDragEnd?: (event: XtermMouseEvent) => void;
|
|
26
|
+
dragging?: boolean;
|
|
27
|
+
};
|
|
28
|
+
/** One tool entry; the list is the extension point for future tools. */
|
|
29
|
+
export type Tool = {
|
|
30
|
+
id: string;
|
|
31
|
+
/** i18n key for the button label. */
|
|
32
|
+
labelKey: string;
|
|
33
|
+
onClick: (event?: XtermMouseEvent) => void;
|
|
34
|
+
};
|
|
35
|
+
export type ToolBarProps = {
|
|
36
|
+
/** Current keyboard mode, passed down by the editor; drives focus display. */
|
|
37
|
+
mode?: string | null;
|
|
38
|
+
/** Tools to render, centered in the bar. Defaults to save + file tree. */
|
|
39
|
+
tools?: Tool[];
|
|
40
|
+
/** File session wired to the default Save tool. */
|
|
41
|
+
session?: EditorSession | null;
|
|
42
|
+
/** Toggles the file-tree pane (default File Tree tool). */
|
|
43
|
+
onToggleFileTree?: () => void;
|
|
44
|
+
/** Opens/closes the in-editor settings layer (default Settings tool). */
|
|
45
|
+
onOpenSettings?: () => void;
|
|
46
|
+
/** Whether the file-tree pane is open; the bar stays left of it. */
|
|
47
|
+
fileTreeOpen?: boolean;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Floating toolbar, bottom-center of the terminal until the user drags it.
|
|
51
|
+
* Dragging switches it to absolute positioning and clamps it inside the
|
|
52
|
+
* terminal view (clampFrame, top ≥ 1 to preserve the information bar).
|
|
53
|
+
* Purely presentational for now: one centered button with hover/focus states.
|
|
54
|
+
* The editor owns the layer that hosts this bar (and the ctrl+tab toggle);
|
|
55
|
+
* this component only adds the normal-mode keyboard navigation
|
|
56
|
+
* (ctrl+left/ctrl+right move the focus ring, enter selects). The activator
|
|
57
|
+
* is bare Enter, not ctrl+enter: terminals send the same byte (`\r`) for
|
|
58
|
+
* both, so a ctrl-modified Enter can never reach the engine. Insert mode
|
|
59
|
+
* stays mouse-only because these bindings are mode-restricted and never
|
|
60
|
+
* fire there.
|
|
61
|
+
*/
|
|
62
|
+
export declare function ToolBar({ mode, tools, session, onToggleFileTree, onOpenSettings, fileTreeOpen, }: ToolBarProps): React.JSX.Element;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Box, Text, measureElement, useWindowSize } from "ink";
|
|
2
|
+
import { useI18n, useKeyboard, useMouseRegion } from "ink-cartridge";
|
|
3
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { clampFrame } from "./frame-clamp.js";
|
|
5
|
+
import { getBarPosition, setBarPosition, useBarPosition } from "./toolbar-store.js";
|
|
6
|
+
import { useTreeWidth } from "./tree-width-store.js";
|
|
7
|
+
/**
|
|
8
|
+
* Clamp the bar's top-left: inside the terminal, below the information bar,
|
|
9
|
+
* and — while the file tree is open — left of the tree pane.
|
|
10
|
+
*/
|
|
11
|
+
function clampBarPos(left, top, width, height, columns, rows, fileTreeOpen, treeWidth) {
|
|
12
|
+
const clamped = clampFrame(left, top, columns, rows, width, height);
|
|
13
|
+
const maxLeft = fileTreeOpen ? columns - treeWidth - width : columns - width;
|
|
14
|
+
return {
|
|
15
|
+
left: Math.min(Math.max(0, clamped.left), Math.max(0, maxLeft)),
|
|
16
|
+
top: Math.max(1, clamped.top),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function ToolButton({ priority, onClick, text, focused = false, onDragStart, onDragMove, onDragEnd, dragging = false, }) {
|
|
20
|
+
const [hover, setHover] = useState(false);
|
|
21
|
+
const ref = useMouseRegion({
|
|
22
|
+
onClick: (event) => onClick(event),
|
|
23
|
+
onEnter: () => setHover(true),
|
|
24
|
+
onLeave: () => setHover(false),
|
|
25
|
+
onDragStart,
|
|
26
|
+
onDragMove,
|
|
27
|
+
onDragEnd,
|
|
28
|
+
}, { priority });
|
|
29
|
+
// Dragging outranks everything (the bar is moving), then keyboard focus,
|
|
30
|
+
// then the transient hover tint.
|
|
31
|
+
const borderColor = dragging ? "red" : focused ? "blue" : hover ? "green" : "white";
|
|
32
|
+
return (React.createElement(Box, { ref: ref, borderColor: borderColor, borderStyle: "bold",
|
|
33
|
+
// Match the editor's surface so the floating bar reads as part of it.
|
|
34
|
+
backgroundColor: "#1e1e1e", height: "100%", justifyContent: "center", alignItems: "center", borderBackgroundColor: "#1e1e1e", paddingLeft: 2, paddingRight: 2 },
|
|
35
|
+
React.createElement(Text, null, text)));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Floating toolbar, bottom-center of the terminal until the user drags it.
|
|
39
|
+
* Dragging switches it to absolute positioning and clamps it inside the
|
|
40
|
+
* terminal view (clampFrame, top ≥ 1 to preserve the information bar).
|
|
41
|
+
* Purely presentational for now: one centered button with hover/focus states.
|
|
42
|
+
* The editor owns the layer that hosts this bar (and the ctrl+tab toggle);
|
|
43
|
+
* this component only adds the normal-mode keyboard navigation
|
|
44
|
+
* (ctrl+left/ctrl+right move the focus ring, enter selects). The activator
|
|
45
|
+
* is bare Enter, not ctrl+enter: terminals send the same byte (`\r`) for
|
|
46
|
+
* both, so a ctrl-modified Enter can never reach the engine. Insert mode
|
|
47
|
+
* stays mouse-only because these bindings are mode-restricted and never
|
|
48
|
+
* fire there.
|
|
49
|
+
*/
|
|
50
|
+
export function ToolBar({ mode = null, tools, session = null, onToggleFileTree, onOpenSettings, fileTreeOpen = false, }) {
|
|
51
|
+
const { rows, columns } = useWindowSize();
|
|
52
|
+
const { t } = useI18n();
|
|
53
|
+
const { boundKeyboard } = useKeyboard();
|
|
54
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
55
|
+
// The default tools need live context, so they are built per instance
|
|
56
|
+
// instead of at module level; `tools` stays the extension point.
|
|
57
|
+
const defaultTools = useMemo(() => [
|
|
58
|
+
{
|
|
59
|
+
id: "save",
|
|
60
|
+
labelKey: "toolbar.save",
|
|
61
|
+
onClick: () => {
|
|
62
|
+
session?.save();
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "file-tree",
|
|
67
|
+
labelKey: "toolbar.fileTree",
|
|
68
|
+
onClick: onToggleFileTree ?? (() => { }),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "settings",
|
|
72
|
+
labelKey: "toolbar.settings",
|
|
73
|
+
onClick: onOpenSettings ?? (() => { }),
|
|
74
|
+
},
|
|
75
|
+
], [session, onToggleFileTree, onOpenSettings]);
|
|
76
|
+
const activeTools = tools ?? defaultTools;
|
|
77
|
+
// null = bar sits at the bottom-center via flex layout; once the user
|
|
78
|
+
// grabs it, absolute positioning takes over so it follows the cursor.
|
|
79
|
+
// Position lives in a module store so erase/re-apply remounts (prop
|
|
80
|
+
// updates) do not reset a dragged bar back to the bottom.
|
|
81
|
+
const pos = useBarPosition();
|
|
82
|
+
// Live pane width, so the drag clamp follows the tree's adaptive size.
|
|
83
|
+
const treeWidth = useTreeWidth();
|
|
84
|
+
const [dragging, setDragging] = useState(false);
|
|
85
|
+
const barRef = useRef(null);
|
|
86
|
+
// Grab offset (where inside the bar the cursor went down) plus the bar's
|
|
87
|
+
// size at grab time, kept in a ref so drag moves need no re-measurement.
|
|
88
|
+
const grabRef = useRef({ dx: 0, dy: 0, width: 0, height: 0 });
|
|
89
|
+
const startDrag = useCallback((event) => {
|
|
90
|
+
const node = barRef.current;
|
|
91
|
+
if (!node) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// Measure the bar itself, not the hit region — a button may be the
|
|
95
|
+
// captured target, and its rect would put the bar's corner at the
|
|
96
|
+
// button's corner.
|
|
97
|
+
const m = measureElement(node);
|
|
98
|
+
grabRef.current = {
|
|
99
|
+
dx: event.x - m.x - 1,
|
|
100
|
+
dy: event.y - m.y - 1,
|
|
101
|
+
width: m.width,
|
|
102
|
+
height: m.height,
|
|
103
|
+
};
|
|
104
|
+
// Switch from flex centering to absolute positioning at the current
|
|
105
|
+
// rect — seamless, no jump on the first frame.
|
|
106
|
+
setBarPosition({ top: m.y, left: m.x });
|
|
107
|
+
setDragging(true);
|
|
108
|
+
}, []);
|
|
109
|
+
const moveDrag = useCallback((event) => {
|
|
110
|
+
const { dx, dy, width, height } = grabRef.current;
|
|
111
|
+
setBarPosition(clampBarPos(event.x - dx - 1, event.y - dy - 1, width, height, columns, rows, fileTreeOpen, treeWidth));
|
|
112
|
+
}, [columns, rows, fileTreeOpen, treeWidth]);
|
|
113
|
+
const endDrag = useCallback(() => setDragging(false), []);
|
|
114
|
+
// The drag clamp only runs while dragging — a terminal resize can shrink
|
|
115
|
+
// the viewport under an absolutely-positioned bar afterwards (and opening
|
|
116
|
+
// the file tree can shrink the free area). Re-clamp whenever any of the
|
|
117
|
+
// layout inputs change so the bar never extends past its allowed region.
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
const node = barRef.current;
|
|
120
|
+
if (!node) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// Read the latest position imperatively — no `pos` dependency, so this
|
|
124
|
+
// effect only runs when a layout input actually changes.
|
|
125
|
+
const m = measureElement(node);
|
|
126
|
+
const current = getBarPosition();
|
|
127
|
+
if (!current) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const next = clampBarPos(current.left, current.top, m.width, m.height, columns, rows, fileTreeOpen, treeWidth);
|
|
131
|
+
if (next.left !== current.left || next.top !== current.top) {
|
|
132
|
+
setBarPosition(next);
|
|
133
|
+
}
|
|
134
|
+
}, [columns, rows, fileTreeOpen, treeWidth]);
|
|
135
|
+
const showFocus = mode === "normal";
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const unbinds = [
|
|
138
|
+
boundKeyboard(["ctrl+left"], () => setActiveIndex((i) => (i - 1 + activeTools.length) % activeTools.length), { mode: "normal" }),
|
|
139
|
+
boundKeyboard(["ctrl+right"], () => setActiveIndex((i) => (i + 1) % activeTools.length), { mode: "normal" }),
|
|
140
|
+
boundKeyboard(["return"], () => activeTools[activeIndex]?.onClick(), {
|
|
141
|
+
mode: "normal",
|
|
142
|
+
}),
|
|
143
|
+
];
|
|
144
|
+
return () => unbinds.forEach((fn) => fn());
|
|
145
|
+
}, [boundKeyboard, activeTools, activeIndex]);
|
|
146
|
+
return (React.createElement(Box, { width: "100%", height: "100%", justifyContent: "center", alignItems: "flex-end" },
|
|
147
|
+
React.createElement(Box, { ref: barRef, position: pos ? "absolute" : undefined, top: pos?.top, left: pos?.left, height: 3, flexDirection: "row", justifyContent: "center", alignItems: "center" }, activeTools.map((tool, index) => (React.createElement(ToolButton, { key: tool.id, text: t(tool.labelKey), priority: 1, focused: showFocus && index === activeIndex, onClick: tool.onClick, dragging: dragging, onDragStart: startDrag, onDragMove: moveDrag, onDragEnd: endDrag }))))));
|
|
148
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-level drag position of the floating toolbar.
|
|
3
|
+
*
|
|
4
|
+
* The toolbar element is erased and re-applied whenever its props change
|
|
5
|
+
* (the layer system treats re-applies of an existing element id as no-ops),
|
|
6
|
+
* which would otherwise reset the component's internal position state. An
|
|
7
|
+
* external store survives those remounts, so a dragged bar keeps its spot.
|
|
8
|
+
*/
|
|
9
|
+
type BarPosition = {
|
|
10
|
+
top: number;
|
|
11
|
+
left: number;
|
|
12
|
+
} | null;
|
|
13
|
+
/** Read the current persisted position (imperative access for effects). */
|
|
14
|
+
export declare function getBarPosition(): BarPosition;
|
|
15
|
+
/** Update the persisted bar position (drag move / initial grab). */
|
|
16
|
+
export declare function setBarPosition(next: BarPosition): void;
|
|
17
|
+
/** Reactive binding for the toolbar: null = bottom-center flex layout. */
|
|
18
|
+
export declare function useBarPosition(): BarPosition;
|
|
19
|
+
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,6 @@
|
|
|
1
|
+
/** Imperative read for drag handlers. */
|
|
2
|
+
export declare function getTreeWidth(): number;
|
|
3
|
+
/** Update the reported pane width (no-op when unchanged). */
|
|
4
|
+
export declare function setTreeWidth(next: number): void;
|
|
5
|
+
/** Reactive binding: re-renders whenever the pane width changes. */
|
|
6
|
+
export declare function useTreeWidth(): number;
|
|
@@ -0,0 +1,31 @@
|
|
|
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 width = 32;
|
|
9
|
+
const listeners = new Set();
|
|
10
|
+
function subscribe(listener) {
|
|
11
|
+
listeners.add(listener);
|
|
12
|
+
return () => {
|
|
13
|
+
listeners.delete(listener);
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** Imperative read for drag handlers. */
|
|
17
|
+
export function getTreeWidth() {
|
|
18
|
+
return width;
|
|
19
|
+
}
|
|
20
|
+
/** Update the reported pane width (no-op when unchanged). */
|
|
21
|
+
export function setTreeWidth(next) {
|
|
22
|
+
if (next === width) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
width = next;
|
|
26
|
+
listeners.forEach((fn) => fn());
|
|
27
|
+
}
|
|
28
|
+
/** Reactive binding: re-renders whenever the pane width changes. */
|
|
29
|
+
export function useTreeWidth() {
|
|
30
|
+
return useSyncExternalStore(subscribe, getTreeWidth, getTreeWidth);
|
|
31
|
+
}
|