blots-editor 0.0.4 → 0.0.5

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,41 @@
1
+ export type CursorCoordinates = {
2
+ line: number;
3
+ column: number;
4
+ };
5
+ export type TextCalcOptions = {
6
+ numberOfIndentationSpaces?: number;
7
+ cursorOffset?: number;
8
+ cursorHeightOffset?: number;
9
+ lineNumberRightSpacing?: number;
10
+ };
11
+ type ChangeListener = () => void;
12
+ export declare class TextCalculation {
13
+ private _lines;
14
+ private _cursor;
15
+ private _scrollTop;
16
+ private _listeners;
17
+ readonly numberOfIndentationSpaces: number;
18
+ readonly cursorOffset: number;
19
+ readonly cursorHeightOffset: number;
20
+ readonly lineNumberRightSpacing: number;
21
+ constructor(text: string, options?: TextCalcOptions);
22
+ onChange(listener: ChangeListener): () => void;
23
+ private _notify;
24
+ get lines(): string[];
25
+ get cursor(): CursorCoordinates;
26
+ get lineCount(): number;
27
+ getLineNumberWidth(): number;
28
+ getCurrentLineText(): string;
29
+ getCursorX(lineNumberWidth: number): number;
30
+ updateScroll(height: number): number;
31
+ insertChar(char: string): void;
32
+ newLine(): void;
33
+ moveRight(): void;
34
+ moveLeft(): void;
35
+ moveUp(): void;
36
+ moveDown(): void;
37
+ backspace(): void;
38
+ indent(): void;
39
+ outdent(): void;
40
+ }
41
+ export {};
@@ -0,0 +1,161 @@
1
+ import stringWidth from "string-width";
2
+ export class TextCalculation {
3
+ _lines;
4
+ _cursor;
5
+ _scrollTop;
6
+ _listeners = new Set();
7
+ numberOfIndentationSpaces;
8
+ cursorOffset;
9
+ cursorHeightOffset;
10
+ lineNumberRightSpacing;
11
+ constructor(text, options = {}) {
12
+ this._lines = text ? text.split(/\r?\n/) : [];
13
+ this._cursor = { line: 0, column: 0 };
14
+ this._scrollTop = 0;
15
+ this.numberOfIndentationSpaces = options.numberOfIndentationSpaces ?? 2;
16
+ this.cursorOffset = options.cursorOffset ?? 0;
17
+ this.cursorHeightOffset = options.cursorHeightOffset ?? 2;
18
+ this.lineNumberRightSpacing = Math.max(0, options.lineNumberRightSpacing ?? 1);
19
+ }
20
+ onChange(listener) {
21
+ this._listeners.add(listener);
22
+ return () => {
23
+ this._listeners.delete(listener);
24
+ };
25
+ }
26
+ _notify() {
27
+ this._listeners.forEach((fn) => fn());
28
+ }
29
+ get lines() {
30
+ return this._lines;
31
+ }
32
+ get cursor() {
33
+ return this._cursor;
34
+ }
35
+ get lineCount() {
36
+ return this._lines.length;
37
+ }
38
+ getLineNumberWidth() {
39
+ return Math.max(stringWidth(String(this._lines.length - 1)), 1);
40
+ }
41
+ getCurrentLineText() {
42
+ return this._lines[this._cursor.line] ?? "";
43
+ }
44
+ getCursorX(lineNumberWidth) {
45
+ const text = this.getCurrentLineText();
46
+ const clamped = Math.max(0, Math.min(text.length, this._cursor.column));
47
+ const visual = stringWidth(text.slice(0, clamped));
48
+ return (visual + lineNumberWidth + this.lineNumberRightSpacing + this.cursorOffset);
49
+ }
50
+ updateScroll(height) {
51
+ const effectiveH = height > 0 ? height : this._lines.length;
52
+ let vs = this._scrollTop;
53
+ if (this._cursor.line < vs) {
54
+ vs = this._cursor.line;
55
+ }
56
+ else if (this._cursor.line >= vs + effectiveH) {
57
+ vs = this._cursor.line - effectiveH + 1;
58
+ }
59
+ const maxScroll = Math.max(0, this._lines.length - effectiveH);
60
+ vs = Math.max(0, Math.min(vs, maxScroll));
61
+ this._scrollTop = vs;
62
+ return vs;
63
+ }
64
+ insertChar(char) {
65
+ const { line, column } = this._cursor;
66
+ this._lines[line] =
67
+ this._lines[line].slice(0, column) +
68
+ char +
69
+ this._lines[line].slice(column);
70
+ this._cursor = { line, column: column + char.length };
71
+ this._notify();
72
+ }
73
+ newLine() {
74
+ const { line, column } = this._cursor;
75
+ const current = this._lines[line];
76
+ this._lines[line] = current.slice(0, column);
77
+ this._lines.splice(line + 1, 0, current.slice(column));
78
+ this._cursor = { line: line + 1, column: 0 };
79
+ this._notify();
80
+ }
81
+ moveRight() {
82
+ const maxCol = this._lines[this._cursor.line]?.length ?? 0;
83
+ this._cursor = {
84
+ line: this._cursor.line,
85
+ column: Math.min(this._cursor.column + 1, maxCol),
86
+ };
87
+ this._notify();
88
+ }
89
+ moveLeft() {
90
+ this._cursor = {
91
+ line: this._cursor.line,
92
+ column: Math.max(this._cursor.column - 1, 0),
93
+ };
94
+ this._notify();
95
+ }
96
+ moveUp() {
97
+ const newLine = Math.max(this._cursor.line - 1, 0);
98
+ const maxCol = this._lines[newLine]?.length ?? 0;
99
+ this._cursor = {
100
+ line: newLine,
101
+ column: Math.min(maxCol, this._cursor.column),
102
+ };
103
+ this._notify();
104
+ }
105
+ moveDown() {
106
+ const newLine = Math.min(this._cursor.line + 1, this._lines.length - 1);
107
+ const maxCol = this._lines[newLine]?.length ?? 0;
108
+ this._cursor = {
109
+ line: newLine,
110
+ column: Math.min(maxCol, this._cursor.column),
111
+ };
112
+ this._notify();
113
+ }
114
+ backspace() {
115
+ const { line, column } = this._cursor;
116
+ if (column === 0 && line !== 0) {
117
+ const prevLen = this._lines[line - 1].length;
118
+ this._lines[line - 1] += this._lines[line];
119
+ this._lines.splice(line, 1);
120
+ this._cursor = { line: line - 1, column: prevLen };
121
+ }
122
+ else if (column > 0) {
123
+ this._lines[line] =
124
+ this._lines[line].slice(0, column - 1) +
125
+ this._lines[line].slice(column);
126
+ this._cursor = { line, column: column - 1 };
127
+ }
128
+ this._notify();
129
+ }
130
+ indent() {
131
+ const { line } = this._cursor;
132
+ const spaces = " ".repeat(this.numberOfIndentationSpaces);
133
+ this._lines[line] = spaces + this._lines[line];
134
+ this._cursor = {
135
+ line,
136
+ column: this._cursor.column + this.numberOfIndentationSpaces,
137
+ };
138
+ this._notify();
139
+ }
140
+ outdent() {
141
+ const { line } = this._cursor;
142
+ const current = this._lines[line];
143
+ const leadingSpaces = current.match(/^ */)?.[0].length || 0;
144
+ const n = this.numberOfIndentationSpaces;
145
+ if (leadingSpaces >= n) {
146
+ this._lines[line] = current.slice(n);
147
+ this._cursor = {
148
+ line,
149
+ column: Math.max(0, this._cursor.column - n),
150
+ };
151
+ }
152
+ else if (leadingSpaces > 0) {
153
+ this._lines[line] = current.slice(leadingSpaces);
154
+ this._cursor = {
155
+ line,
156
+ column: Math.max(0, this._cursor.column - leadingSpaces),
157
+ };
158
+ }
159
+ this._notify();
160
+ }
161
+ }
@@ -1,42 +1,11 @@
1
1
  import React from "react";
2
2
  export type EditorContext = {
3
- /**
4
- * Used to externally get all current text, etc.
5
- */
6
3
  onChance: () => void;
7
- /**
8
- * Original text, including some special symbols
9
- */
10
4
  value: string;
11
- /**
12
- * What is the right margin of the left line number,
13
- * which also affects the cursor position
14
- */
15
5
  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
6
  cursorOffset?: number;
22
- /**
23
- * Cursor height offset, default is 2
24
- */
25
7
  cursorHeightOffset?: number;
26
- /**
27
- * Number of indentation spaces
28
- * default is 2
29
- */
30
8
  numberOfIndentationSpaces?: number;
31
9
  };
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;
10
+ export type { CursorCoordinates } from "../text/text-based-calculation.js";
11
+ export declare function Editor({ value: initialText, lineNumberRightSpacing, cursorOffset, cursorHeightOffset, numberOfIndentationSpaces, }: EditorContext): React.JSX.Element;
@@ -1,192 +1,55 @@
1
- import { Box, Text, useCursor } from "ink";
1
+ import { Box, Text, useBoxMetrics, useCursor } from "ink";
2
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 Math.max(stringWidth(String(value.length - 1)), 1);
33
- }, [stringWidth, cursor.line]);
3
+ import React, { useEffect, useRef, useState } from "react";
4
+ import { TextCalculation } from "../text/text-based-calculation.js";
5
+ export function Editor({ value: initialText, lineNumberRightSpacing, cursorOffset, cursorHeightOffset, numberOfIndentationSpaces, }) {
6
+ const calcRef = useRef(null);
7
+ if (!calcRef.current) {
8
+ calcRef.current = new TextCalculation(initialText, {
9
+ lineNumberRightSpacing,
10
+ cursorOffset,
11
+ cursorHeightOffset,
12
+ numberOfIndentationSpaces,
13
+ });
14
+ }
15
+ const calc = calcRef.current;
16
+ const [, forceUpdate] = useState(0);
17
+ useEffect(() => {
18
+ return calc.onChange(() => forceUpdate((n) => n + 1));
19
+ }, [calc]);
34
20
  const { setCursorPosition } = useCursor();
35
- const boxRef = useRef(null);
36
21
  const { boundKeyboard, enableWildcardPriority } = useKeyboard();
37
22
  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
- }));
23
+ const removeWildcard = enableWildcardPriority();
24
+ const unbinds = [];
25
+ unbinds.push(boundKeyboard(["*"], (input) => calc.insertChar(input)));
26
+ unbinds.push(boundKeyboard(["return"], () => calc.newLine()));
27
+ unbinds.push(boundKeyboard(["right"], () => calc.moveRight()));
28
+ unbinds.push(boundKeyboard(["left"], () => calc.moveLeft()));
29
+ unbinds.push(boundKeyboard(["backspace", "delete"], () => calc.backspace()));
30
+ unbinds.push(boundKeyboard(["up"], () => calc.moveUp()));
31
+ unbinds.push(boundKeyboard(["down"], () => calc.moveDown()));
32
+ unbinds.push(boundKeyboard(["tab"], () => calc.indent()));
33
+ unbinds.push(boundKeyboard(["shift+tab"], () => calc.outdent()));
162
34
  return () => {
163
- removeWildcardPrecedence();
164
- unBinds.forEach((each) => each());
35
+ removeWildcard();
36
+ unbinds.forEach((fn) => fn());
165
37
  };
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: currentLineNumberLength, justifyContent: "flex-end", marginRight: checkedLineNumberRightSpacing },
189
- React.createElement(Text, { bold: true }, number)),
38
+ }, [boundKeyboard, enableWildcardPriority, calc]);
39
+ const ref = useRef(null);
40
+ const { height } = useBoxMetrics(ref);
41
+ const lineNumberWidth = calc.getLineNumberWidth();
42
+ const visibleStart = calc.updateScroll(height);
43
+ const effectiveH = height > 0 ? height : calc.lineCount;
44
+ const visibleLines = calc.lines.slice(visibleStart, visibleStart + effectiveH);
45
+ const cursorX = calc.getCursorX(lineNumberWidth);
46
+ const cursorY = calc.cursor.line - visibleStart + calc.cursorHeightOffset;
47
+ setCursorPosition({ x: cursorX, y: cursorY });
48
+ return (React.createElement(Box, { ref: ref, height: "100%", width: "100%", backgroundColor: "#1e1e1e", flexDirection: "column" }, visibleLines.map((each, i) => {
49
+ const lineNumber = visibleStart + i;
50
+ return (React.createElement(Box, { key: lineNumber, flexDirection: "row" },
51
+ React.createElement(Box, { width: lineNumberWidth, justifyContent: "flex-end", marginRight: calc.lineNumberRightSpacing },
52
+ React.createElement(Text, { bold: calc.cursor.line === lineNumber }, lineNumber)),
190
53
  React.createElement(Text, null, each)));
191
54
  })));
192
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blots-editor",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "A lightweight Markdown editor for the terminal, built on ink-cartridge",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",