blots-editor 0.0.1 → 0.0.3
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/README.md +25 -0
- package/dist/core/view/editor.d.ts +42 -0
- package/dist/core/view/editor.js +192 -0
- package/dist/core/view/information-bar.d.ts +8 -0
- package/dist/core/view/information-bar.js +6 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +16 -3
- package/package.json +4 -2
package/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# blots-editor
|
|
2
|
+
|
|
3
|
+
> A lightweight Markdown editor for the terminal — built on [ink-cartridge](https://github.com/BAIGAOa/ink-cartridge).
|
|
4
|
+
|
|
5
|
+
## About
|
|
6
|
+
|
|
7
|
+
blots-editor is both a **real product** and a **real-world testbed** for ink-cartridge. Every screen, keyboard shortcut, and overlay exercises the framework's screen management and keyboard engine in a concrete application — catching edge cases that unit tests alone can't.
|
|
8
|
+
|
|
9
|
+
Built with [Ink](https://github.com/vadimdemedes/ink) + [React](https://react.dev).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -g blots-editor
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
blots-editor
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## License
|
|
24
|
+
|
|
25
|
+
MIT
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export type EditorContext = {
|
|
3
|
+
/**
|
|
4
|
+
* Used to externally get all current text, etc.
|
|
5
|
+
*/
|
|
6
|
+
onChance: () => void;
|
|
7
|
+
/**
|
|
8
|
+
* Original text, including some special symbols
|
|
9
|
+
*/
|
|
10
|
+
value: string;
|
|
11
|
+
/**
|
|
12
|
+
* What is the right margin of the left line number,
|
|
13
|
+
* which also affects the cursor position
|
|
14
|
+
*/
|
|
15
|
+
lineNumberRightSpacing?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Cursor offset. The default value is 1.
|
|
18
|
+
* It is also recommended to be 1. Otherwise,
|
|
19
|
+
* it will cause the cursor visual position to exceed the limit.
|
|
20
|
+
*/
|
|
21
|
+
cursorOffset?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Cursor height offset, default is 2
|
|
24
|
+
*/
|
|
25
|
+
cursorHeightOffset?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Number of indentation spaces
|
|
28
|
+
* default is 2
|
|
29
|
+
*/
|
|
30
|
+
numberOfIndentationSpaces?: number;
|
|
31
|
+
};
|
|
32
|
+
export type CursorCoordinates = {
|
|
33
|
+
/**
|
|
34
|
+
* How many rows are currently in
|
|
35
|
+
*/
|
|
36
|
+
line: number;
|
|
37
|
+
/**
|
|
38
|
+
* How many columns are currently in
|
|
39
|
+
*/
|
|
40
|
+
column: number;
|
|
41
|
+
};
|
|
42
|
+
export declare function Editor({ value: line, lineNumberRightSpacing, cursorOffset, cursorHeightOffset, numberOfIndentationSpaces, }: EditorContext): React.JSX.Element;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { Box, Text, useCursor } from "ink";
|
|
2
|
+
import { useKeyboard } from "ink-cartridge";
|
|
3
|
+
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import stringWidth from "string-width";
|
|
5
|
+
function splitTextByLines(text) {
|
|
6
|
+
if (!text)
|
|
7
|
+
return [];
|
|
8
|
+
return text.split(/\r?\n/);
|
|
9
|
+
}
|
|
10
|
+
function insertStr(original, index, insertText) {
|
|
11
|
+
return original.slice(0, index) + insertText + original.slice(index);
|
|
12
|
+
}
|
|
13
|
+
function deleteCharAt(original, index, length = 1) {
|
|
14
|
+
return original.slice(0, index) + original.slice(index + length);
|
|
15
|
+
}
|
|
16
|
+
function splitString(string, index) {
|
|
17
|
+
return [string.slice(0, index), string.slice(index)];
|
|
18
|
+
}
|
|
19
|
+
export function Editor({ value: line, lineNumberRightSpacing = 1, cursorOffset = 1, cursorHeightOffset = 2, numberOfIndentationSpaces = 2, }) {
|
|
20
|
+
const [value, setValue] = useState(() => {
|
|
21
|
+
const lines = splitTextByLines(line);
|
|
22
|
+
return lines;
|
|
23
|
+
});
|
|
24
|
+
const [cursor, setCursor] = useState({
|
|
25
|
+
line: 0,
|
|
26
|
+
column: 0,
|
|
27
|
+
});
|
|
28
|
+
const checkedLineNumberRightSpacing = useMemo(() => {
|
|
29
|
+
return Math.max(0, lineNumberRightSpacing);
|
|
30
|
+
}, [lineNumberRightSpacing]);
|
|
31
|
+
const currentLineNumberLength = useMemo(() => {
|
|
32
|
+
return stringWidth(String(value.length - 1));
|
|
33
|
+
}, [stringWidth, cursor.line]);
|
|
34
|
+
const { setCursorPosition } = useCursor();
|
|
35
|
+
const boxRef = useRef(null);
|
|
36
|
+
const { boundKeyboard, enableWildcardPriority } = useKeyboard();
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const removeWildcardPrecedence = enableWildcardPriority();
|
|
39
|
+
const unBinds = [];
|
|
40
|
+
unBinds.push(boundKeyboard(["*"], (input, _key) => {
|
|
41
|
+
const cur = cursor;
|
|
42
|
+
setValue((prev) => {
|
|
43
|
+
const newValue = [...prev];
|
|
44
|
+
const newStr = insertStr(newValue[cur.line], cur.column, input);
|
|
45
|
+
newValue[cur.line] = newStr;
|
|
46
|
+
return newValue;
|
|
47
|
+
});
|
|
48
|
+
setCursor((prev) => {
|
|
49
|
+
return {
|
|
50
|
+
line: prev.line,
|
|
51
|
+
column: prev.column + input.length,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}));
|
|
55
|
+
unBinds.push(boundKeyboard(["return"], () => {
|
|
56
|
+
const cur = cursor;
|
|
57
|
+
setValue((prev) => {
|
|
58
|
+
const newLine = [...prev];
|
|
59
|
+
const newStr = splitString(newLine[cur.line], cur.column);
|
|
60
|
+
newLine.splice(cur.line, 1);
|
|
61
|
+
newLine.splice(cur.line, 0, newStr[0]);
|
|
62
|
+
newLine.splice(cur.line + 1, 0, newStr[1]);
|
|
63
|
+
return newLine;
|
|
64
|
+
});
|
|
65
|
+
setCursor((prev) => {
|
|
66
|
+
return {
|
|
67
|
+
line: prev.line + 1,
|
|
68
|
+
column: 0,
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
}));
|
|
72
|
+
unBinds.push(boundKeyboard(["right"], () => {
|
|
73
|
+
setCursor((prev) => ({
|
|
74
|
+
line: prev.line,
|
|
75
|
+
column: Math.min(prev.column + 1, value[cursor.line].length),
|
|
76
|
+
}));
|
|
77
|
+
}));
|
|
78
|
+
unBinds.push(boundKeyboard(["left"], () => {
|
|
79
|
+
setCursor((prev) => ({
|
|
80
|
+
line: prev.line,
|
|
81
|
+
column: Math.max(prev.column - 1, 0),
|
|
82
|
+
}));
|
|
83
|
+
}));
|
|
84
|
+
unBinds.push(boundKeyboard(["backspace", "delete"], () => {
|
|
85
|
+
let wrap = false;
|
|
86
|
+
let lastLineLenth = null;
|
|
87
|
+
setValue((prev) => {
|
|
88
|
+
const newValue = [...prev];
|
|
89
|
+
if (cursor.column === 0 && cursor.line !== 0) {
|
|
90
|
+
wrap = true;
|
|
91
|
+
lastLineLenth = newValue[cursor.line - 1].length;
|
|
92
|
+
const newStr = insertStr(newValue[cursor.line - 1], newValue[cursor.line - 1].length, newValue[cursor.line]);
|
|
93
|
+
newValue[cursor.line - 1] = newStr;
|
|
94
|
+
newValue.splice(cursor.line, 1);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const newStr = cursor.column === 0
|
|
98
|
+
? newValue[cursor.line]
|
|
99
|
+
: deleteCharAt(newValue[cursor.line], cursor.column - 1);
|
|
100
|
+
newValue[cursor.line] = newStr;
|
|
101
|
+
}
|
|
102
|
+
return newValue;
|
|
103
|
+
});
|
|
104
|
+
setCursor((prev) => {
|
|
105
|
+
return {
|
|
106
|
+
line: wrap ? prev.line - 1 : prev.line,
|
|
107
|
+
column: lastLineLenth
|
|
108
|
+
? lastLineLenth
|
|
109
|
+
: Math.max(prev.column - 1, 0),
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}));
|
|
113
|
+
unBinds.push(boundKeyboard(["up"], () => {
|
|
114
|
+
setCursor((prev) => {
|
|
115
|
+
return {
|
|
116
|
+
line: Math.max(prev.line - 1, 0),
|
|
117
|
+
column: Math.min(value[Math.max(prev.line - 1, 0)].length, prev.column),
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}));
|
|
121
|
+
unBinds.push(boundKeyboard(["down"], () => {
|
|
122
|
+
setCursor((prev) => {
|
|
123
|
+
return {
|
|
124
|
+
line: Math.min(prev.line + 1, value.length - 1),
|
|
125
|
+
column: Math.min(value[Math.min(prev.line + 1, value.length - 1)].length, prev.column),
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
}));
|
|
129
|
+
unBinds.push(boundKeyboard(["tab"], () => {
|
|
130
|
+
setValue((prev) => {
|
|
131
|
+
const newValue = [...prev];
|
|
132
|
+
newValue[cursor.line] = insertStr(newValue[cursor.line], cursor.column, " ".repeat(numberOfIndentationSpaces));
|
|
133
|
+
return newValue;
|
|
134
|
+
});
|
|
135
|
+
setCursor((prev) => ({
|
|
136
|
+
line: prev.line,
|
|
137
|
+
column: prev.column + numberOfIndentationSpaces,
|
|
138
|
+
}));
|
|
139
|
+
}));
|
|
140
|
+
unBinds.push(boundKeyboard(["shift+tab"], () => {
|
|
141
|
+
setValue((prev) => {
|
|
142
|
+
const newValue = [...prev];
|
|
143
|
+
const currentLine = newValue[cursor.line];
|
|
144
|
+
const leadingSpaces = currentLine.match(/^ */)?.[0].length || 0;
|
|
145
|
+
if (leadingSpaces >= numberOfIndentationSpaces) {
|
|
146
|
+
newValue[cursor.line] = deleteCharAt(currentLine, 0, numberOfIndentationSpaces);
|
|
147
|
+
setCursor((prevCursor) => ({
|
|
148
|
+
line: prevCursor.line,
|
|
149
|
+
column: Math.max(0, prevCursor.column - numberOfIndentationSpaces),
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
else if (leadingSpaces > 0) {
|
|
153
|
+
newValue[cursor.line] = deleteCharAt(currentLine, 0, leadingSpaces);
|
|
154
|
+
setCursor((prevCursor) => ({
|
|
155
|
+
line: prevCursor.line,
|
|
156
|
+
column: Math.max(0, prevCursor.column - leadingSpaces),
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
return newValue;
|
|
160
|
+
});
|
|
161
|
+
}));
|
|
162
|
+
return () => {
|
|
163
|
+
removeWildcardPrecedence();
|
|
164
|
+
unBinds.forEach((each) => each());
|
|
165
|
+
};
|
|
166
|
+
}, [boundKeyboard, cursor.column, cursor.line, enableWildcardPriority]);
|
|
167
|
+
const currentLineText = useMemo(() => value[cursor.line] ?? "", [value, cursor.line]);
|
|
168
|
+
const clampedColumn = useMemo(() => Math.max(0, Math.min(currentLineText.length, cursor.column)), [cursor.column, currentLineText]);
|
|
169
|
+
const visualColumn = useMemo(() => stringWidth(currentLineText.slice(0, clampedColumn)), [stringWidth, currentLineText, clampedColumn]);
|
|
170
|
+
const x = useMemo(() => visualColumn +
|
|
171
|
+
currentLineNumberLength +
|
|
172
|
+
checkedLineNumberRightSpacing +
|
|
173
|
+
cursorOffset, [
|
|
174
|
+
visualColumn,
|
|
175
|
+
currentLineNumberLength,
|
|
176
|
+
checkedLineNumberRightSpacing,
|
|
177
|
+
cursorOffset,
|
|
178
|
+
]);
|
|
179
|
+
const y = useMemo(() => cursor.line + cursorHeightOffset, [cursor.line, cursorHeightOffset]);
|
|
180
|
+
// Testing has revealed a flaw in the cursor operations provided by ink.
|
|
181
|
+
// It could also be due to a specific device.
|
|
182
|
+
// On arm64-based devices—such as when running in Termux—this causes cursor misalignment.
|
|
183
|
+
// The cursor works normally everywhere else.
|
|
184
|
+
// I also don't know how to solve this problem elegantly.
|
|
185
|
+
setCursorPosition({ x: x, y: y });
|
|
186
|
+
return (React.createElement(Box, { ref: boxRef, height: "100%", width: "100%", backgroundColor: "#1e1e1e", flexDirection: "column" }, value.map((each, number) => {
|
|
187
|
+
return (React.createElement(Box, { key: number, flexDirection: "row" },
|
|
188
|
+
React.createElement(Box, { width: Math.max(currentLineNumberLength - 1, 1), justifyContent: "flex-end", marginRight: checkedLineNumberRightSpacing },
|
|
189
|
+
React.createElement(Text, { bold: true }, number)),
|
|
190
|
+
React.createElement(Text, null, each)));
|
|
191
|
+
})));
|
|
192
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Box, Text } from "ink";
|
|
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));
|
|
6
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export {};
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { Box, render } from "ink";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { InformationBar } from "./core/view/information-bar.js";
|
|
4
|
+
import { CurrentScreen, KeyboardProvider, registerComponent, ScenarioManagementProvider, } from "ink-cartridge";
|
|
5
|
+
import { Editor } from "./core/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 },
|
|
16
|
+
React.createElement(CurrentScreen, null))));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blots-editor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "A lightweight Markdown editor for the terminal, built on ink-cartridge",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"scripts": {
|
|
27
27
|
"build": "tsc",
|
|
28
|
+
"start": "tsx src/index.tsx",
|
|
28
29
|
"clean": "rm -rf dist",
|
|
29
30
|
"test": "vitest run",
|
|
30
31
|
"test:watch": "vitest"
|
|
@@ -49,7 +50,8 @@
|
|
|
49
50
|
"react": ">=18"
|
|
50
51
|
},
|
|
51
52
|
"dependencies": {
|
|
52
|
-
"ink-cartridge": "*"
|
|
53
|
+
"ink-cartridge": "*",
|
|
54
|
+
"string-width": "^8.2.2"
|
|
53
55
|
},
|
|
54
56
|
"devDependencies": {
|
|
55
57
|
"@types/node": "^20.19.39",
|