Skip to content

Commit a06d97d

Browse files
committed
make TS input handling scalable for editor-like features
1 parent 3d02bbc commit a06d97d

4 files changed

Lines changed: 188 additions & 64 deletions

File tree

src/components.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { resetInputEditorText } from "./input-editor";
12
import { $, type Signal } from "./signals";
23
import { normalizeStyledText, prepareTextInput } from "./text-spans";
34
import { NODE_TYPE } from "./types";
@@ -327,12 +328,13 @@ export function Input(input: InputProps): InputNode {
327328
children: undefined,
328329
setChildren: undefined,
329330
setStyle: makeSetStyle(props),
330-
setText: (v) => props.text(prepareTextInput(v).text),
331+
setText: (v) => resetInputEditorText(node, v),
331332
focus: () => focusNode(node),
332333
blur: () => blurNode(node),
333334
isFocused: () => focusedNode === node,
334335
};
335336

337+
resetInputEditorText(node, "");
336338
return node;
337339
}
338340

src/input-dispatch.ts

Lines changed: 27 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,41 @@
1+
import {
2+
reduceEditor,
3+
type EditorCommand,
4+
type EditorState,
5+
} from "./input-editor";
16
import { prepareTextInput } from "./text-spans";
27

38
const IGNORED_INPUT_CONTROL_PATTERN = /[\x00-\x09\x0B\x0C\x0E-\x1F]/;
49

5-
type InputChunkOp =
6-
| { type: "insert"; text: string }
7-
| { type: "backspace" }
8-
| { type: "newline" };
9-
1010
export type InputDispatchTarget = {
11-
getText: () => string;
12-
setText: (value: string) => void;
11+
getState: () => EditorState;
12+
setState: (state: EditorState) => void;
1313
multiline: boolean;
1414
onChange?: (value: string) => void;
1515
onSubmit?: (value: string) => void;
1616
};
1717

18-
function deletePreviousCodepoint(text: string): string {
19-
const chars = Array.from(text);
20-
chars.pop();
21-
return chars.join("");
22-
}
23-
24-
function pushInsertOp(ops: InputChunkOp[], buffer: string[]): void {
18+
function pushInsertCommand(ops: EditorCommand[], buffer: string[]): void {
2519
if (buffer.length === 0) return;
26-
ops.push({ type: "insert", text: buffer.join("") });
20+
ops.push({ type: "insertText", text: buffer.join("") });
2721
buffer.length = 0;
2822
}
2923

30-
export function parseInputChunk(data: string): InputChunkOp[] {
24+
export function parseInputCommands(data: string): EditorCommand[] {
3125
const normalized = prepareTextInput(data).text;
32-
const ops: InputChunkOp[] = [];
26+
const ops: EditorCommand[] = [];
3327
const buffer: string[] = [];
3428

3529
for (const ch of normalized) {
3630
if (ch === "\x7f") {
37-
pushInsertOp(ops, buffer);
38-
ops.push({ type: "backspace" });
31+
pushInsertCommand(ops, buffer);
32+
ops.push({ type: "deleteBackward" });
3933
continue;
4034
}
4135

4236
if (ch === "\n") {
43-
pushInsertOp(ops, buffer);
44-
ops.push({ type: "newline" });
37+
pushInsertCommand(ops, buffer);
38+
ops.push({ type: "insertLineBreak" });
4539
continue;
4640
}
4741

@@ -52,7 +46,7 @@ export function parseInputChunk(data: string): InputChunkOp[] {
5246
buffer.push(ch);
5347
}
5448

55-
pushInsertOp(ops, buffer);
49+
pushInsertCommand(ops, buffer);
5650
return ops;
5751
}
5852

@@ -64,56 +58,28 @@ export function dispatchInputChunk(
6458
return false;
6559
}
6660

67-
const ops = parseInputChunk(data);
61+
const ops = parseInputCommands(data);
6862
if (ops.length === 0) {
6963
return false;
7064
}
7165

72-
let draft = target.getText();
7366
let handled = false;
74-
let hasPendingInsert = false;
75-
76-
const commitDraft = (): void => {
77-
if (!hasPendingInsert) {
78-
return;
79-
}
80-
81-
target.setText(draft);
82-
target.onChange?.(target.getText());
83-
draft = target.getText();
84-
hasPendingInsert = false;
85-
};
8667

8768
for (const op of ops) {
8869
handled = true;
70+
const result = reduceEditor(target.getState(), op, {
71+
multiline: target.multiline,
72+
});
73+
74+
if (result.changed) {
75+
target.setState(result.state);
76+
target.onChange?.(result.state.text);
77+
}
8978

90-
switch (op.type) {
91-
case "insert":
92-
draft += op.text;
93-
hasPendingInsert = true;
94-
break;
95-
case "backspace":
96-
commitDraft();
97-
target.setText(deletePreviousCodepoint(target.getText()));
98-
target.onChange?.(target.getText());
99-
draft = target.getText();
100-
break;
101-
case "newline":
102-
if (target.multiline) {
103-
commitDraft();
104-
target.setText(target.getText() + "\n");
105-
target.onChange?.(target.getText());
106-
draft = target.getText();
107-
break;
108-
}
109-
110-
commitDraft();
111-
target.onSubmit?.(target.getText());
112-
draft = target.getText();
113-
break;
79+
if (result.submit !== undefined) {
80+
target.onSubmit?.(result.submit);
11481
}
11582
}
11683

117-
commitDraft();
11884
return handled;
11985
}

src/input-editor.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { prepareTextInput } from "./text-spans";
2+
import type { InputNode } from "./types";
3+
4+
export type EditorState = {
5+
text: string;
6+
cursor: number;
7+
anchor: number | null;
8+
};
9+
10+
export type EditorCommand =
11+
| { type: "insertText"; text: string }
12+
| { type: "deleteBackward" }
13+
| { type: "insertLineBreak" };
14+
15+
export type EditorResult = {
16+
state: EditorState;
17+
changed: boolean;
18+
submit?: string;
19+
};
20+
21+
const editorStateByNode = new WeakMap<InputNode, EditorState>();
22+
23+
function codepointLength(text: string): number {
24+
return Array.from(text).length;
25+
}
26+
27+
function splitCodepoints(text: string): string[] {
28+
return Array.from(text);
29+
}
30+
31+
function selectionRange(
32+
state: EditorState,
33+
): { start: number; end: number } | null {
34+
if (state.anchor === null || state.anchor === state.cursor) {
35+
return null;
36+
}
37+
38+
return {
39+
start: Math.min(state.anchor, state.cursor),
40+
end: Math.max(state.anchor, state.cursor),
41+
};
42+
}
43+
44+
function createCollapsedState(text: string, cursor: number): EditorState {
45+
return { text, cursor, anchor: null };
46+
}
47+
48+
function replaceSelection(
49+
state: EditorState,
50+
nextText: string,
51+
): EditorState {
52+
const chars = splitCodepoints(state.text);
53+
const range = selectionRange(state);
54+
55+
if (range === null) {
56+
const nextCursor = state.cursor + codepointLength(nextText);
57+
chars.splice(state.cursor, 0, ...splitCodepoints(nextText));
58+
return createCollapsedState(chars.join(""), nextCursor);
59+
}
60+
61+
chars.splice(
62+
range.start,
63+
range.end - range.start,
64+
...splitCodepoints(nextText),
65+
);
66+
return createCollapsedState(
67+
chars.join(""),
68+
range.start + codepointLength(nextText),
69+
);
70+
}
71+
72+
function deleteSelection(state: EditorState): EditorState {
73+
const range = selectionRange(state);
74+
if (range === null) {
75+
return state;
76+
}
77+
78+
const chars = splitCodepoints(state.text);
79+
chars.splice(range.start, range.end - range.start);
80+
return createCollapsedState(chars.join(""), range.start);
81+
}
82+
83+
function deletePreviousCodepoint(state: EditorState): EditorState {
84+
const selected = deleteSelection(state);
85+
if (selected !== state) {
86+
return selected;
87+
}
88+
89+
if (state.cursor === 0) {
90+
return state;
91+
}
92+
93+
const chars = splitCodepoints(state.text);
94+
chars.splice(state.cursor - 1, 1);
95+
return createCollapsedState(chars.join(""), state.cursor - 1);
96+
}
97+
98+
export function createEditorState(text: string): EditorState {
99+
const normalized = prepareTextInput(text).text;
100+
return createCollapsedState(normalized, codepointLength(normalized));
101+
}
102+
103+
export function getInputEditorState(node: InputNode): EditorState {
104+
return editorStateByNode.get(node) ?? createEditorState(node.props.text());
105+
}
106+
107+
export function setInputEditorState(
108+
node: InputNode,
109+
state: EditorState,
110+
): void {
111+
editorStateByNode.set(node, state);
112+
node.props.text(state.text);
113+
}
114+
115+
export function resetInputEditorText(node: InputNode, text: string): void {
116+
setInputEditorState(node, createEditorState(text));
117+
}
118+
119+
export function reduceEditor(
120+
state: EditorState,
121+
command: EditorCommand,
122+
opts: { multiline: boolean },
123+
): EditorResult {
124+
switch (command.type) {
125+
case "insertText": {
126+
const nextState = replaceSelection(state, command.text);
127+
return {
128+
state: nextState,
129+
changed: nextState.text !== state.text,
130+
};
131+
}
132+
case "deleteBackward": {
133+
const nextState = deletePreviousCodepoint(state);
134+
return {
135+
state: nextState,
136+
changed: nextState.text !== state.text,
137+
};
138+
}
139+
case "insertLineBreak": {
140+
if (!opts.multiline) {
141+
return {
142+
state,
143+
changed: false,
144+
submit: state.text,
145+
};
146+
}
147+
148+
const nextState = replaceSelection(state, "\n");
149+
return {
150+
state: nextState,
151+
changed: nextState.text !== state.text,
152+
};
153+
}
154+
}
155+
}

src/runtime.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { dirname } from "path";
44
import api from "./ffi";
55
import { $, ff, type Signal } from "./signals";
66
import { getFocusedNode, getFocusVersion } from "./components";
7+
import { getInputEditorState, setInputEditorState } from "./input-editor";
78
import { dispatchInputChunk } from "./input-dispatch";
89
import {
910
NODE_TYPE,
@@ -575,8 +576,8 @@ function dispatchToNode(node: Node, data: string): boolean {
575576
if (node.type === NODE_TYPE.Input) {
576577
return dispatchInputChunk(
577578
{
578-
getText: () => node.props.text(),
579-
setText: (value) => node.setText(value),
579+
getState: () => getInputEditorState(node),
580+
setState: (state) => setInputEditorState(node, state),
580581
multiline: node.props.multiline() === true,
581582
onChange: node.handlers.onChange,
582583
onSubmit: node.handlers.onSubmit,

0 commit comments

Comments
 (0)