blots-editor 0.0.12 → 0.0.13

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.
@@ -14,5 +14,6 @@
14
14
  "editor.mode.insert": "INSERT",
15
15
  "editor.mode.normal": "NORMAL",
16
16
  "command.prompt": "Command",
17
- "command.unknown": "Unknown command: {cmd}"
17
+ "command.unknown": "Unknown command: {cmd}",
18
+ "toolbar.fileTree": "File Tree"
18
19
  }
@@ -14,5 +14,6 @@
14
14
  "editor.mode.insert": "插入",
15
15
  "editor.mode.normal": "普通",
16
16
  "command.prompt": "命令",
17
- "command.unknown": "未知命令:{cmd}"
17
+ "command.unknown": "未知命令:{cmd}",
18
+ "toolbar.fileTree": "文件树"
18
19
  }
@@ -1,11 +1,16 @@
1
1
  import { Box, Text, useBoxMetrics, useCursor } from "ink";
2
- import { applyElementToModalLayer, openModalLayer, useI18n, useKeyboard, useMouseRegion, } from "ink-cartridge";
2
+ import { applyElement, applyElementToModalLayer, closeLayer, openLayer, openModalLayer, useI18n, useKeyboard, useMouseRegion, } from "ink-cartridge";
3
3
  import React, { useCallback, useEffect, useRef, useState } from "react";
4
4
  import { EditorController } from "../core/editor-controller.js";
5
5
  import { useSettings } from "../core/settings/useSettings.js";
6
6
  import { clickToPosition } from "./click-mapping.js";
7
7
  import { CommandBar } from "./command-bar.js";
8
8
  import { InformationBar } from "./information-bar.js";
9
+ import { ToolBar } from "./tool-bar.js";
10
+ /** Layer hosting the floating toolbar; below modal layers so it never
11
+ * intercepts keys meant for a modal (e.g. the command bar). */
12
+ const TOOLBAR_LAYER_ID = "toolbar";
13
+ const TOOLBAR_Z_INDEX = 1;
9
14
  /**
10
15
  * Main editor view: renders the status bar + line numbers + text, routes
11
16
  * every keystroke to the controller as a named command, and splits
@@ -42,6 +47,37 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
42
47
  element: CommandBar,
43
48
  });
44
49
  }, []);
50
+ // The toolbar lives in a regular layer (below modal layers) so it floats
51
+ // above the document without taking over the keyboard. The editor owns the
52
+ // layer lifecycle and the ctrl+tab toggle because the layer's element
53
+ // unmounts when it closes — a binding inside it could never re-open it.
54
+ const [toolbarOpen, setToolbarOpen] = useState(true);
55
+ useEffect(() => {
56
+ return () => closeLayer(TOOLBAR_LAYER_ID);
57
+ }, []);
58
+ // Opening must wait one tick: passive effects run child-first on mount, so
59
+ // when the editor IS the initial screen this effect races the provider's
60
+ // dispatcher registration and openLayer would throw. ctrl+tab toggles hit
61
+ // the same deferred path — the one-tick delay is imperceptible. Closing is
62
+ // safe immediately because the dispatcher is registered by then.
63
+ useEffect(() => {
64
+ if (!toolbarOpen) {
65
+ closeLayer(TOOLBAR_LAYER_ID);
66
+ return;
67
+ }
68
+ const timer = setTimeout(() => {
69
+ openLayer(TOOLBAR_LAYER_ID, TOOLBAR_Z_INDEX);
70
+ applyElement(TOOLBAR_LAYER_ID, {
71
+ elementId: "toolbar",
72
+ element: ToolBar,
73
+ props: { mode },
74
+ });
75
+ }, 0);
76
+ return () => clearTimeout(timer);
77
+ }, [toolbarOpen, mode]);
78
+ useEffect(() => {
79
+ return boundKeyboard(["ctrl+tab"], () => setToolbarOpen((open) => !open));
80
+ }, [boundKeyboard]);
45
81
  useEffect(() => {
46
82
  const removeWildcard = enableWildcardPriority();
47
83
  const unbinds = [];
@@ -0,0 +1,51 @@
1
+ import React from "react";
2
+ import type { XtermMouseEvent } from "@cartridge-engine/keyboard-engine";
3
+ /**
4
+ * Props for a single tool button.
5
+ *
6
+ * @prop priority - Hit-test priority over whatever sits underneath (the editor
7
+ * surface). Buttons must win so clicking them does not also place the cursor.
8
+ * @prop onClick - Called on mouse click and on ctrl+enter selection; the event
9
+ * is absent for the keyboard path.
10
+ * @prop text - The text to display on the button.
11
+ * @prop focused - Keyboard focus (normal-mode navigation); overrides hover.
12
+ * @prop onDragStart/onDragMove/onDragEnd - Forwarded from the toolbar. The
13
+ * engine captures a drag on the pressed region only, so buttons must carry
14
+ * the drag handlers or dragging from a button would not move the bar.
15
+ * @prop dragging - The bar is being dragged; buttons tint green like hover.
16
+ */
17
+ export type ToolButtonProps = {
18
+ priority: number;
19
+ onClick: (event?: XtermMouseEvent) => void;
20
+ text: string;
21
+ focused?: boolean;
22
+ onDragStart?: (event: XtermMouseEvent) => void;
23
+ onDragMove?: (event: XtermMouseEvent) => void;
24
+ onDragEnd?: (event: XtermMouseEvent) => void;
25
+ dragging?: boolean;
26
+ };
27
+ /** One tool entry; the list is the extension point for future tools. */
28
+ export type Tool = {
29
+ id: string;
30
+ /** i18n key for the button label. */
31
+ labelKey: string;
32
+ /** Placeholder until the file-tree feature lands. */
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 the file-tree entry. */
39
+ tools?: Tool[];
40
+ };
41
+ /**
42
+ * Floating toolbar, bottom-center of the terminal until the user drags it.
43
+ * Dragging switches it to absolute positioning and clamps it inside the
44
+ * terminal view (clampFrame). Purely presentational for now: one centered
45
+ * button with hover/focus states. The editor owns the layer that hosts this
46
+ * bar (and the ctrl+tab toggle); this component only adds the normal-mode
47
+ * keyboard navigation (ctrl+left/ctrl+right move the focus ring, ctrl+enter
48
+ * selects). Insert mode stays mouse-only because those bindings are
49
+ * mode-restricted and never fire there.
50
+ */
51
+ export declare function ToolBar({ mode, tools }: ToolBarProps): React.JSX.Element;
@@ -0,0 +1,92 @@
1
+ import { Box, Text, measureElement, useWindowSize } from "ink";
2
+ import { useI18n, useKeyboard, useMouseRegion } from "ink-cartridge";
3
+ import React, { useCallback, useEffect, useRef, useState } from "react";
4
+ import { clampFrame } from "./frame-clamp.js";
5
+ function ToolButton({ priority, onClick, text, focused = false, onDragStart, onDragMove, onDragEnd, dragging = false, }) {
6
+ const [hover, setHover] = useState(false);
7
+ const ref = useMouseRegion({
8
+ onClick: (event) => onClick(event),
9
+ onEnter: () => setHover(true),
10
+ onLeave: () => setHover(false),
11
+ onDragStart,
12
+ onDragMove,
13
+ onDragEnd,
14
+ }, { priority });
15
+ // Focus is the keyboard state, so it outranks the transient hover color.
16
+ const borderColor = focused ? "blue" : hover || dragging ? "green" : "white";
17
+ return (React.createElement(Box, { ref: ref, borderColor: borderColor, borderStyle: "bold",
18
+ // Match the editor's surface so the floating bar reads as part of it.
19
+ backgroundColor: "#1e1e1e", height: "100%", justifyContent: "center", alignItems: "center", paddingLeft: 2, paddingRight: 2 },
20
+ React.createElement(Text, null, text)));
21
+ }
22
+ const DEFAULT_TOOLS = [
23
+ {
24
+ id: "file-tree",
25
+ labelKey: "toolbar.fileTree",
26
+ onClick: () => {
27
+ // Business logic (opening the file tree) lands here later.
28
+ },
29
+ },
30
+ ];
31
+ /**
32
+ * Floating toolbar, bottom-center of the terminal until the user drags it.
33
+ * Dragging switches it to absolute positioning and clamps it inside the
34
+ * terminal view (clampFrame). Purely presentational for now: one centered
35
+ * button with hover/focus states. The editor owns the layer that hosts this
36
+ * bar (and the ctrl+tab toggle); this component only adds the normal-mode
37
+ * keyboard navigation (ctrl+left/ctrl+right move the focus ring, ctrl+enter
38
+ * selects). Insert mode stays mouse-only because those bindings are
39
+ * mode-restricted and never fire there.
40
+ */
41
+ export function ToolBar({ mode = null, tools = DEFAULT_TOOLS }) {
42
+ const { rows, columns } = useWindowSize();
43
+ const { t } = useI18n();
44
+ const { boundKeyboard } = useKeyboard();
45
+ const [activeIndex, setActiveIndex] = useState(0);
46
+ // null = bar sits at the bottom-center via flex layout; once the user
47
+ // grabs it, absolute positioning takes over so it follows the cursor.
48
+ const [pos, setPos] = useState(null);
49
+ const [dragging, setDragging] = useState(false);
50
+ const barRef = useRef(null);
51
+ // Grab offset (where inside the bar the cursor went down) plus the bar's
52
+ // size at grab time, kept in a ref so drag moves need no re-measurement.
53
+ const grabRef = useRef({ dx: 0, dy: 0, width: 0, height: 0 });
54
+ const startDrag = useCallback((event) => {
55
+ const node = barRef.current;
56
+ if (!node) {
57
+ return;
58
+ }
59
+ // Measure the bar itself, not the hit region — a button may be the
60
+ // captured target, and its rect would put the bar's corner at the
61
+ // button's corner.
62
+ const m = measureElement(node);
63
+ grabRef.current = {
64
+ dx: event.x - m.x - 1,
65
+ dy: event.y - m.y - 1,
66
+ width: m.width,
67
+ height: m.height,
68
+ };
69
+ // Switch from flex centering to absolute positioning at the current
70
+ // rect — seamless, no jump on the first frame.
71
+ setPos({ top: m.y, left: m.x });
72
+ setDragging(true);
73
+ }, []);
74
+ const moveDrag = useCallback((event) => {
75
+ const { dx, dy, width, height } = grabRef.current;
76
+ setPos(clampFrame(event.x - dx - 1, event.y - dy - 1, columns, rows, width, height));
77
+ }, [columns, rows]);
78
+ const endDrag = useCallback(() => setDragging(false), []);
79
+ const showFocus = mode === "normal";
80
+ useEffect(() => {
81
+ const unbinds = [
82
+ boundKeyboard(["ctrl+left"], () => setActiveIndex((i) => (i - 1 + tools.length) % tools.length), { mode: "normal" }),
83
+ boundKeyboard(["ctrl+right"], () => setActiveIndex((i) => (i + 1) % tools.length), { mode: "normal" }),
84
+ boundKeyboard(["ctrl+return"], () => tools[activeIndex]?.onClick(), {
85
+ mode: "normal",
86
+ }),
87
+ ];
88
+ return () => unbinds.forEach((fn) => fn());
89
+ }, [boundKeyboard, tools, activeIndex]);
90
+ return (React.createElement(Box, { width: "100%", height: "100%", justifyContent: "center", alignItems: "flex-end" },
91
+ React.createElement(Box, { ref: barRef, position: pos ? "absolute" : undefined, top: pos?.top, left: pos?.left, height: 4, flexDirection: "row", justifyContent: "center", alignItems: "center" }, tools.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 }))))));
92
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blots-editor",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "description": "A lightweight Markdown editor for the terminal, built on ink-cartridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",