@termaxjs/editor-react 0.1.1
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/LICENSE +201 -0
- package/README.md +23 -0
- package/dist/EditorContext.d.ts +91 -0
- package/dist/EditorContext.d.ts.map +1 -0
- package/dist/EditorContext.js +672 -0
- package/dist/EditorContext.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/useBufferRange.d.ts +12 -0
- package/dist/useBufferRange.d.ts.map +1 -0
- package/dist/useBufferRange.js +22 -0
- package/dist/useBufferRange.js.map +1 -0
- package/dist/useCursors.d.ts +18 -0
- package/dist/useCursors.d.ts.map +1 -0
- package/dist/useCursors.js +22 -0
- package/dist/useCursors.js.map +1 -0
- package/dist/useEditor.d.ts +19 -0
- package/dist/useEditor.d.ts.map +1 -0
- package/dist/useEditor.js +23 -0
- package/dist/useEditor.js.map +1 -0
- package/dist/useHighlight.d.ts +6 -0
- package/dist/useHighlight.d.ts.map +1 -0
- package/dist/useHighlight.js +14 -0
- package/dist/useHighlight.js.map +1 -0
- package/dist/useUndoRedo.d.ts +7 -0
- package/dist/useUndoRedo.d.ts.map +1 -0
- package/dist/useUndoRedo.js +11 -0
- package/dist/useUndoRedo.js.map +1 -0
- package/package.json +46 -0
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useState, useCallback, useEffect, useRef, } from "react";
|
|
3
|
+
import { normalizeSelection, isSelectionCollapsed, applyMultiCursorInsert, applyMultiCursorDelete, deduplicateSelections, getTextInRange, getWordRangeAt, positionToOffset, offsetToPosition, isLeadSurrogate, isTrailSurrogate, findWordBoundary, toggleLinesComment, isPairedBracketDelete, isPositionBefore, computeColumnSelection, DEFAULT_UNDO_DEPTH, } from "@termaxjs/editor-core";
|
|
4
|
+
const EDITOR_CONTEXT_KEY = Symbol.for("__termax_editor_context__");
|
|
5
|
+
const editorContextScope = globalThis;
|
|
6
|
+
if (!editorContextScope[EDITOR_CONTEXT_KEY]) {
|
|
7
|
+
editorContextScope[EDITOR_CONTEXT_KEY] = createContext(null);
|
|
8
|
+
}
|
|
9
|
+
const sharedEditorContext = editorContextScope[EDITOR_CONTEXT_KEY];
|
|
10
|
+
if (!sharedEditorContext) {
|
|
11
|
+
throw new Error("Failed to initialize the editor context");
|
|
12
|
+
}
|
|
13
|
+
export const EditorContext = sharedEditorContext;
|
|
14
|
+
const MAX_UNDO_DEPTH = DEFAULT_UNDO_DEPTH;
|
|
15
|
+
export function EditorProvider({ initialContent = "", adapter, bufferId = "default", onChange, children, }) {
|
|
16
|
+
const [lines, setLines] = useState(() => {
|
|
17
|
+
const raw = initialContent || "";
|
|
18
|
+
return raw.split(/\r?\n/);
|
|
19
|
+
});
|
|
20
|
+
// ---------------------------------------------------------------------
|
|
21
|
+
// Adapter tier wiring (.plan/goal.md E2.2)
|
|
22
|
+
//
|
|
23
|
+
// React state remains the synchronous source of truth for rendering — the
|
|
24
|
+
// adapter contract is async, and blocking a keystroke on a Promise would
|
|
25
|
+
// regress typing latency. The adapter is instead driven as an authoritative
|
|
26
|
+
// mirror: every committed mutation is forwarded to it as an offset-based
|
|
27
|
+
// edit, in order.
|
|
28
|
+
//
|
|
29
|
+
// That makes the tier real rather than decorative: with a Tauri/WASM adapter
|
|
30
|
+
// the Rust rope receives every edit and owns save/large-file handling, while
|
|
31
|
+
// Tier 0 (no adapter) simply skips the mirror and behaves as before.
|
|
32
|
+
// ---------------------------------------------------------------------
|
|
33
|
+
const adapterReady = useRef(false);
|
|
34
|
+
// Last `lines` value already mirrored to the adapter.
|
|
35
|
+
const lastSyncedRef = useRef(lines);
|
|
36
|
+
// Serializes adapter writes. Edits must land in the order they were made,
|
|
37
|
+
// and the contract is Promise-based, so we chain rather than fire-and-forget.
|
|
38
|
+
const adapterQueue = useRef(Promise.resolve());
|
|
39
|
+
const adapterFailed = useRef(false);
|
|
40
|
+
const enqueueAdapter = useCallback((op) => {
|
|
41
|
+
if (!adapter || adapterFailed.current)
|
|
42
|
+
return;
|
|
43
|
+
adapterQueue.current = adapterQueue.current
|
|
44
|
+
.then(() => (adapterReady.current ? op(adapter) : undefined))
|
|
45
|
+
.catch((err) => {
|
|
46
|
+
// A failing adapter must not silently diverge from the UI. Latch off
|
|
47
|
+
// and surface it once, rather than logging on every keystroke.
|
|
48
|
+
adapterFailed.current = true;
|
|
49
|
+
console.error("[termax-editor] host adapter detached after an error; " +
|
|
50
|
+
"continuing with in-memory editing only.", err);
|
|
51
|
+
});
|
|
52
|
+
}, [adapter]);
|
|
53
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: initialContent seeds the buffer once; re-opening on every content change would discard the adapter's history.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!adapter) {
|
|
56
|
+
adapterReady.current = false;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
adapterFailed.current = false;
|
|
60
|
+
let cancelled = false;
|
|
61
|
+
adapterQueue.current = adapterQueue.current
|
|
62
|
+
.then(() => adapter.openBuffer(bufferId, initialContent ?? ""))
|
|
63
|
+
.then((snap) => {
|
|
64
|
+
if (!cancelled) {
|
|
65
|
+
adapterReady.current = true;
|
|
66
|
+
setSnapshot(snap ?? null);
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
.catch((err) => {
|
|
70
|
+
adapterFailed.current = true;
|
|
71
|
+
console.error("[termax-editor] host adapter failed to open buffer.", err);
|
|
72
|
+
});
|
|
73
|
+
return () => {
|
|
74
|
+
cancelled = true;
|
|
75
|
+
adapterReady.current = false;
|
|
76
|
+
setSnapshot(null);
|
|
77
|
+
adapterQueue.current = adapterQueue.current
|
|
78
|
+
.then(() => adapter.closeBuffer(bufferId))
|
|
79
|
+
.catch(() => {
|
|
80
|
+
/* teardown: nothing useful to do with a close failure */
|
|
81
|
+
});
|
|
82
|
+
};
|
|
83
|
+
// initialContent is intentionally excluded: it seeds the buffer once.
|
|
84
|
+
// Re-opening on every content change would discard the adapter's history.
|
|
85
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
86
|
+
}, [adapter, bufferId]);
|
|
87
|
+
/**
|
|
88
|
+
* Mirror a committed state transition into the adapter as one offset edit.
|
|
89
|
+
*
|
|
90
|
+
* Deliberately diff-based rather than per-operation: it derives the edit from
|
|
91
|
+
* (prev, next) so a single call site covers insert, delete, tab, bulk
|
|
92
|
+
* replace, undo and redo alike. A per-operation mirror would need wiring in
|
|
93
|
+
* each mutation and would silently miss undo/redo, letting the adapter drift
|
|
94
|
+
* out of sync with the UI — the exact failure this is meant to prevent.
|
|
95
|
+
*/
|
|
96
|
+
const syncAdapter = useCallback((prevLines, nextLines) => {
|
|
97
|
+
if (!adapter)
|
|
98
|
+
return;
|
|
99
|
+
const prev = prevLines.join("\n");
|
|
100
|
+
const next = nextLines.join("\n");
|
|
101
|
+
if (prev === next)
|
|
102
|
+
return;
|
|
103
|
+
// Narrow to the changed span: common prefix, then common suffix.
|
|
104
|
+
let start = 0;
|
|
105
|
+
const maxStart = Math.min(prev.length, next.length);
|
|
106
|
+
while (start < maxStart && prev[start] === next[start])
|
|
107
|
+
start++;
|
|
108
|
+
let fromEnd = 0;
|
|
109
|
+
const maxEnd = Math.min(prev.length - start, next.length - start);
|
|
110
|
+
while (fromEnd < maxEnd &&
|
|
111
|
+
prev[prev.length - 1 - fromEnd] === next[next.length - 1 - fromEnd]) {
|
|
112
|
+
fromEnd++;
|
|
113
|
+
}
|
|
114
|
+
const deleteLen = prev.length - start - fromEnd;
|
|
115
|
+
const insertText = next.slice(start, next.length - fromEnd);
|
|
116
|
+
enqueueAdapter((a) => a.applyEdit(bufferId, start, deleteLen, insertText));
|
|
117
|
+
}, [adapter, bufferId, enqueueAdapter]);
|
|
118
|
+
const [selections, setSelectionsState] = useState([
|
|
119
|
+
{ anchor: { line: 0, column: 0 }, head: { line: 0, column: 0 } },
|
|
120
|
+
]);
|
|
121
|
+
const selectionsRef = useRef([
|
|
122
|
+
{ anchor: { line: 0, column: 0 }, head: { line: 0, column: 0 } },
|
|
123
|
+
]);
|
|
124
|
+
const [cursor, setCursorState] = useState({ line: 0, column: 0 });
|
|
125
|
+
const cursorRef = useRef({ line: 0, column: 0 });
|
|
126
|
+
const [selection, setSelectionState] = useState(null);
|
|
127
|
+
const selectionRef = useRef(null);
|
|
128
|
+
const updateSelections = useCallback((nextSelections) => {
|
|
129
|
+
const deduped = deduplicateSelections(nextSelections);
|
|
130
|
+
const valid = deduped.length > 0
|
|
131
|
+
? deduped
|
|
132
|
+
: [{ anchor: { line: 0, column: 0 }, head: { line: 0, column: 0 } }];
|
|
133
|
+
selectionsRef.current = valid;
|
|
134
|
+
setSelectionsState(valid);
|
|
135
|
+
// Sync primary cursor
|
|
136
|
+
cursorRef.current = valid[0].head;
|
|
137
|
+
setCursorState(valid[0].head);
|
|
138
|
+
// Sync primary selection
|
|
139
|
+
const primRange = isSelectionCollapsed(valid[0]) ? null : normalizeSelection(valid[0]);
|
|
140
|
+
selectionRef.current = primRange;
|
|
141
|
+
setSelectionState(primRange);
|
|
142
|
+
}, []);
|
|
143
|
+
const updateCursor = useCallback((pos) => {
|
|
144
|
+
updateSelections([{ anchor: pos, head: pos }]);
|
|
145
|
+
}, [updateSelections]);
|
|
146
|
+
const setSelections = useCallback((next) => {
|
|
147
|
+
updateSelections(next);
|
|
148
|
+
}, [updateSelections]);
|
|
149
|
+
const addCursor = useCallback((pos) => {
|
|
150
|
+
updateSelections([...selectionsRef.current, { anchor: pos, head: pos }]);
|
|
151
|
+
}, [updateSelections]);
|
|
152
|
+
const clearSecondaryCursors = useCallback(() => {
|
|
153
|
+
updateSelections([selectionsRef.current[0]]);
|
|
154
|
+
}, [updateSelections]);
|
|
155
|
+
const setSelection = useCallback((valOrFn) => {
|
|
156
|
+
const prev = selectionRef.current;
|
|
157
|
+
const next = typeof valOrFn === "function" ? valOrFn(prev) : valOrFn;
|
|
158
|
+
if (!next) {
|
|
159
|
+
const cur = cursorRef.current;
|
|
160
|
+
updateSelections([{ anchor: cur, head: cur }]);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
updateSelections([{ anchor: next.start, head: next.end }]);
|
|
164
|
+
}
|
|
165
|
+
}, [updateSelections]);
|
|
166
|
+
const [isDirty, setIsDirty] = useState(false);
|
|
167
|
+
const [visibleRange, setVisibleRange] = useState({ startLine: 0, lineCount: 50 });
|
|
168
|
+
const [snapshot, setSnapshot] = useState(null);
|
|
169
|
+
// History stack
|
|
170
|
+
const undoStack = useRef([]);
|
|
171
|
+
const redoStack = useRef([]);
|
|
172
|
+
const pushUndo = useCallback((prevLines, prevSelections) => {
|
|
173
|
+
undoStack.current.push({ lines: prevLines, selections: prevSelections });
|
|
174
|
+
if (undoStack.current.length > MAX_UNDO_DEPTH) {
|
|
175
|
+
undoStack.current.shift();
|
|
176
|
+
}
|
|
177
|
+
redoStack.current = [];
|
|
178
|
+
}, []);
|
|
179
|
+
// Loads a new document: history is intentionally discarded, because the
|
|
180
|
+
// previous buffer's undo entries do not describe this one.
|
|
181
|
+
const setContent = useCallback((newContent) => {
|
|
182
|
+
const nextLines = newContent.split(/\r?\n/);
|
|
183
|
+
setLines(nextLines);
|
|
184
|
+
updateCursor({ line: 0, column: 0 });
|
|
185
|
+
setIsDirty(false);
|
|
186
|
+
undoStack.current = [];
|
|
187
|
+
redoStack.current = [];
|
|
188
|
+
// Re-seed the adapter: this is a different document, so its edit history
|
|
189
|
+
// must be dropped there too rather than mirrored as an incremental edit.
|
|
190
|
+
if (adapter) {
|
|
191
|
+
lastSyncedRef.current = nextLines;
|
|
192
|
+
enqueueAdapter((a) => a.openBuffer(bufferId, newContent).then((snap) => {
|
|
193
|
+
setSnapshot(snap ?? null);
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
}, [updateCursor, adapter, bufferId, enqueueAdapter]);
|
|
197
|
+
// Edits the current document wholesale, preserving history so the change is
|
|
198
|
+
// undoable as one step.
|
|
199
|
+
const replaceContent = useCallback((newContent) => {
|
|
200
|
+
setLines((prevLines) => {
|
|
201
|
+
pushUndo(prevLines, selectionsRef.current);
|
|
202
|
+
const nextLines = newContent.split(/\r?\n/);
|
|
203
|
+
// Keep the cursor in the document; content length may have shrunk.
|
|
204
|
+
const cur = cursorRef.current;
|
|
205
|
+
const line = Math.min(cur.line, Math.max(0, nextLines.length - 1));
|
|
206
|
+
const column = Math.min(cur.column, (nextLines[line] ?? "").length);
|
|
207
|
+
updateCursor({ line, column });
|
|
208
|
+
setIsDirty(true);
|
|
209
|
+
onChange?.(newContent);
|
|
210
|
+
return nextLines;
|
|
211
|
+
});
|
|
212
|
+
}, [pushUndo, updateCursor, onChange]);
|
|
213
|
+
// Single mirror point for every mutation. Watching committed `lines` catches
|
|
214
|
+
// insert, delete, tab, replaceContent, undo and redo without each having to
|
|
215
|
+
// remember to notify the adapter.
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
if (!adapter) {
|
|
218
|
+
lastSyncedRef.current = lines;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const prev = lastSyncedRef.current;
|
|
222
|
+
if (prev !== lines) {
|
|
223
|
+
syncAdapter(prev, lines);
|
|
224
|
+
lastSyncedRef.current = lines;
|
|
225
|
+
}
|
|
226
|
+
}, [adapter, lines, syncAdapter]);
|
|
227
|
+
const getContent = useCallback(() => {
|
|
228
|
+
return lines.join("\n");
|
|
229
|
+
}, [lines]);
|
|
230
|
+
/**
|
|
231
|
+
* Read the document back from the adapter. On Tier 1/2 this is the Rust
|
|
232
|
+
* rope's view; on Tier 0 it falls back to local state. Use this rather than
|
|
233
|
+
* `getContent` when the adapter is authoritative (e.g. before a save).
|
|
234
|
+
*/
|
|
235
|
+
const getAdapterContent = useCallback(async () => {
|
|
236
|
+
if (!adapter || adapterFailed.current || !adapterReady.current) {
|
|
237
|
+
return lines.join("\n");
|
|
238
|
+
}
|
|
239
|
+
await adapterQueue.current;
|
|
240
|
+
return adapter.getContent(bufferId);
|
|
241
|
+
}, [adapter, bufferId, lines]);
|
|
242
|
+
const insertText = useCallback((text) => {
|
|
243
|
+
setLines((prevLines) => {
|
|
244
|
+
const curSelections = selectionsRef.current;
|
|
245
|
+
pushUndo(prevLines, curSelections);
|
|
246
|
+
const { newLines, newSelections } = applyMultiCursorInsert(prevLines, curSelections, text);
|
|
247
|
+
updateSelections(newSelections);
|
|
248
|
+
setIsDirty(true);
|
|
249
|
+
onChange?.(newLines.join("\n"));
|
|
250
|
+
return newLines;
|
|
251
|
+
});
|
|
252
|
+
}, [pushUndo, updateSelections, onChange]);
|
|
253
|
+
const deleteSelectionOrChar = useCallback((isForward = false) => {
|
|
254
|
+
setLines((prevLines) => {
|
|
255
|
+
const curSelections = selectionsRef.current;
|
|
256
|
+
pushUndo(prevLines, curSelections);
|
|
257
|
+
let selectionsToUse = curSelections;
|
|
258
|
+
if (!isForward) {
|
|
259
|
+
const hasPairedBracket = curSelections.some((s) => isSelectionCollapsed(s) &&
|
|
260
|
+
isPairedBracketDelete(prevLines[s.head.line] ?? "", s.head.column));
|
|
261
|
+
if (hasPairedBracket) {
|
|
262
|
+
selectionsToUse = curSelections.map((s) => {
|
|
263
|
+
if (isSelectionCollapsed(s) &&
|
|
264
|
+
isPairedBracketDelete(prevLines[s.head.line] ?? "", s.head.column)) {
|
|
265
|
+
return {
|
|
266
|
+
anchor: { line: s.head.line, column: s.head.column - 1 },
|
|
267
|
+
head: { line: s.head.line, column: s.head.column + 1 },
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
return s;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const { newLines, newSelections } = applyMultiCursorDelete(prevLines, selectionsToUse, isForward);
|
|
275
|
+
updateSelections(newSelections);
|
|
276
|
+
setIsDirty(true);
|
|
277
|
+
onChange?.(newLines.join("\n"));
|
|
278
|
+
return newLines;
|
|
279
|
+
});
|
|
280
|
+
}, [pushUndo, updateSelections, onChange]);
|
|
281
|
+
const autoCloseOrWrap = useCallback((openChar, closeChar) => {
|
|
282
|
+
setLines((prevLines) => {
|
|
283
|
+
const curSelections = selectionsRef.current;
|
|
284
|
+
pushUndo(prevLines, curSelections);
|
|
285
|
+
const hasNonCollapsed = curSelections.some((s) => !isSelectionCollapsed(s));
|
|
286
|
+
if (hasNonCollapsed) {
|
|
287
|
+
const fullText = prevLines.join("\n");
|
|
288
|
+
const ranges = curSelections.map((s) => {
|
|
289
|
+
const norm = normalizeSelection(s);
|
|
290
|
+
const from = positionToOffset(prevLines, norm.start);
|
|
291
|
+
const to = positionToOffset(prevLines, norm.end);
|
|
292
|
+
return { from, to, isForward: isPositionBefore(s.anchor, s.head) };
|
|
293
|
+
});
|
|
294
|
+
ranges.sort((a, b) => a.from - b.from);
|
|
295
|
+
let result = "";
|
|
296
|
+
let lastEnd = 0;
|
|
297
|
+
const newSelections = [];
|
|
298
|
+
for (const r of ranges) {
|
|
299
|
+
result += fullText.slice(lastEnd, r.from);
|
|
300
|
+
const insideText = fullText.slice(r.from, r.to);
|
|
301
|
+
const wrapStartOffset = result.length;
|
|
302
|
+
result += openChar + insideText + closeChar;
|
|
303
|
+
const wrapEndOffset = result.length;
|
|
304
|
+
lastEnd = r.to;
|
|
305
|
+
const tempLines = result.split(/\r?\n/);
|
|
306
|
+
const startPos = offsetToPosition(tempLines, wrapStartOffset + openChar.length);
|
|
307
|
+
const endPos = offsetToPosition(tempLines, wrapEndOffset - closeChar.length);
|
|
308
|
+
newSelections.push(r.isForward ? { anchor: startPos, head: endPos } : { anchor: endPos, head: startPos });
|
|
309
|
+
}
|
|
310
|
+
result += fullText.slice(lastEnd);
|
|
311
|
+
const nextLines = result.split(/\r?\n/);
|
|
312
|
+
updateSelections(newSelections);
|
|
313
|
+
setIsDirty(true);
|
|
314
|
+
onChange?.(nextLines.join("\n"));
|
|
315
|
+
return nextLines;
|
|
316
|
+
}
|
|
317
|
+
const { newLines, newSelections } = applyMultiCursorInsert(prevLines, curSelections, openChar + closeChar);
|
|
318
|
+
const adjustedSelections = newSelections.map((s) => ({
|
|
319
|
+
anchor: { line: s.head.line, column: Math.max(0, s.head.column - closeChar.length) },
|
|
320
|
+
head: { line: s.head.line, column: Math.max(0, s.head.column - closeChar.length) },
|
|
321
|
+
}));
|
|
322
|
+
updateSelections(adjustedSelections);
|
|
323
|
+
setIsDirty(true);
|
|
324
|
+
onChange?.(newLines.join("\n"));
|
|
325
|
+
return newLines;
|
|
326
|
+
});
|
|
327
|
+
}, [pushUndo, updateSelections, onChange]);
|
|
328
|
+
const enter = useCallback(() => {
|
|
329
|
+
insertText("\n");
|
|
330
|
+
}, [insertText]);
|
|
331
|
+
const tab = useCallback((isShift = false) => {
|
|
332
|
+
if (!isShift) {
|
|
333
|
+
insertText(" "); // 2 spaces by default
|
|
334
|
+
}
|
|
335
|
+
}, [insertText]);
|
|
336
|
+
const moveCursor = useCallback((deltaLine, deltaCol, select = false) => {
|
|
337
|
+
const curSelections = selectionsRef.current;
|
|
338
|
+
const nextSelections = curSelections.map((sel) => {
|
|
339
|
+
const target = sel.head;
|
|
340
|
+
const nextLine = Math.max(0, Math.min(lines.length - 1, target.line + deltaLine));
|
|
341
|
+
const lineText = lines[nextLine] || "";
|
|
342
|
+
let nextCol = Math.max(0, Math.min(lineText.length, target.column + deltaCol));
|
|
343
|
+
// Never park the caret between the halves of a surrogate pair.
|
|
344
|
+
if (deltaCol < 0 && nextCol > 0 && nextCol < lineText.length) {
|
|
345
|
+
if (isTrailSurrogate(lineText.charCodeAt(nextCol)) &&
|
|
346
|
+
isLeadSurrogate(lineText.charCodeAt(nextCol - 1))) {
|
|
347
|
+
nextCol -= 1;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
else if (deltaCol > 0 && nextCol > 0 && nextCol < lineText.length) {
|
|
351
|
+
if (isLeadSurrogate(lineText.charCodeAt(nextCol - 1)) &&
|
|
352
|
+
isTrailSurrogate(lineText.charCodeAt(nextCol))) {
|
|
353
|
+
nextCol += 1;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const nextHead = { line: nextLine, column: nextCol };
|
|
357
|
+
const nextAnchor = select ? sel.anchor : nextHead;
|
|
358
|
+
return { anchor: nextAnchor, head: nextHead };
|
|
359
|
+
});
|
|
360
|
+
updateSelections(nextSelections);
|
|
361
|
+
}, [lines, updateSelections]);
|
|
362
|
+
const moveByWord = useCallback((direction, select = false) => {
|
|
363
|
+
const curSelections = selectionsRef.current;
|
|
364
|
+
const nextSelections = curSelections.map((sel) => {
|
|
365
|
+
const target = sel.head;
|
|
366
|
+
const lineText = lines[target.line] || "";
|
|
367
|
+
let nextCol = findWordBoundary(lineText, target.column, direction);
|
|
368
|
+
let nextLine = target.line;
|
|
369
|
+
if (direction === -1 && target.column === 0 && target.line > 0) {
|
|
370
|
+
nextLine = target.line - 1;
|
|
371
|
+
const prevLineText = lines[nextLine] || "";
|
|
372
|
+
nextCol = prevLineText.length;
|
|
373
|
+
}
|
|
374
|
+
else if (direction === 1 &&
|
|
375
|
+
target.column === lineText.length &&
|
|
376
|
+
target.line < lines.length - 1) {
|
|
377
|
+
nextLine = target.line + 1;
|
|
378
|
+
nextCol = 0;
|
|
379
|
+
}
|
|
380
|
+
const nextHead = { line: nextLine, column: nextCol };
|
|
381
|
+
const nextAnchor = select ? sel.anchor : nextHead;
|
|
382
|
+
return { anchor: nextAnchor, head: nextHead };
|
|
383
|
+
});
|
|
384
|
+
updateSelections(nextSelections);
|
|
385
|
+
}, [lines, updateSelections]);
|
|
386
|
+
const moveToLineEdge = useCallback((edge, select = false) => {
|
|
387
|
+
const curSelections = selectionsRef.current;
|
|
388
|
+
const nextSelections = curSelections.map((sel) => {
|
|
389
|
+
const target = sel.head;
|
|
390
|
+
const lineText = lines[target.line] || "";
|
|
391
|
+
let nextCol = 0;
|
|
392
|
+
if (edge === "start") {
|
|
393
|
+
const firstNonWs = lineText.search(/\S/);
|
|
394
|
+
const indentCol = firstNonWs === -1 ? 0 : firstNonWs;
|
|
395
|
+
if (target.column === indentCol) {
|
|
396
|
+
nextCol = 0;
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
nextCol = indentCol;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
nextCol = lineText.length;
|
|
404
|
+
}
|
|
405
|
+
const nextHead = { line: target.line, column: nextCol };
|
|
406
|
+
const nextAnchor = select ? sel.anchor : nextHead;
|
|
407
|
+
return { anchor: nextAnchor, head: nextHead };
|
|
408
|
+
});
|
|
409
|
+
updateSelections(nextSelections);
|
|
410
|
+
}, [lines, updateSelections]);
|
|
411
|
+
const moveByPage = useCallback((direction, select = false) => {
|
|
412
|
+
const PAGE_SIZE = 25;
|
|
413
|
+
moveCursor(direction * PAGE_SIZE, 0, select);
|
|
414
|
+
}, [moveCursor]);
|
|
415
|
+
const toggleComment = useCallback((commentPrefix = "// ") => {
|
|
416
|
+
setLines((prevLines) => {
|
|
417
|
+
const curSelections = selectionsRef.current;
|
|
418
|
+
pushUndo(prevLines, curSelections);
|
|
419
|
+
let minLine = prevLines.length;
|
|
420
|
+
let maxLine = 0;
|
|
421
|
+
for (const s of curSelections) {
|
|
422
|
+
const norm = normalizeSelection(s);
|
|
423
|
+
if (norm.start.line < minLine)
|
|
424
|
+
minLine = norm.start.line;
|
|
425
|
+
if (norm.end.line > maxLine)
|
|
426
|
+
maxLine = norm.end.line;
|
|
427
|
+
}
|
|
428
|
+
if (minLine > maxLine) {
|
|
429
|
+
minLine = cursorRef.current.line;
|
|
430
|
+
maxLine = cursorRef.current.line;
|
|
431
|
+
}
|
|
432
|
+
const { newLines } = toggleLinesComment(prevLines, minLine, maxLine, commentPrefix);
|
|
433
|
+
setIsDirty(true);
|
|
434
|
+
onChange?.(newLines.join("\n"));
|
|
435
|
+
return newLines;
|
|
436
|
+
});
|
|
437
|
+
}, [pushUndo, onChange]);
|
|
438
|
+
const setCursor = useCallback((pos, preserveSelection = false) => {
|
|
439
|
+
const lineIdx = Math.max(0, Math.min(lines.length - 1, pos.line));
|
|
440
|
+
const lineLen = lines[lineIdx]?.length || 0;
|
|
441
|
+
const col = Math.max(0, Math.min(lineLen, pos.column));
|
|
442
|
+
const clampedPos = { line: lineIdx, column: col };
|
|
443
|
+
if (preserveSelection) {
|
|
444
|
+
const prim = selectionsRef.current[0];
|
|
445
|
+
updateSelections([{ anchor: prim ? prim.anchor : clampedPos, head: clampedPos }]);
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
updateSelections([{ anchor: clampedPos, head: clampedPos }]);
|
|
449
|
+
}
|
|
450
|
+
}, [lines, updateSelections]);
|
|
451
|
+
const selectAll = useCallback(() => {
|
|
452
|
+
if (lines.length === 0)
|
|
453
|
+
return;
|
|
454
|
+
const lastLineIdx = lines.length - 1;
|
|
455
|
+
const lastCol = lines[lastLineIdx].length;
|
|
456
|
+
updateSelections([
|
|
457
|
+
{
|
|
458
|
+
anchor: { line: 0, column: 0 },
|
|
459
|
+
head: { line: lastLineIdx, column: lastCol },
|
|
460
|
+
},
|
|
461
|
+
]);
|
|
462
|
+
}, [lines, updateSelections]);
|
|
463
|
+
const selectNextOccurrence = useCallback(() => {
|
|
464
|
+
const curSelections = selectionsRef.current;
|
|
465
|
+
if (curSelections.length === 0)
|
|
466
|
+
return;
|
|
467
|
+
if (curSelections.length === 1 && isSelectionCollapsed(curSelections[0])) {
|
|
468
|
+
const head = curSelections[0].head;
|
|
469
|
+
const lineText = lines[head.line] || "";
|
|
470
|
+
const wordRange = getWordRangeAt(lineText, head.line, head.column);
|
|
471
|
+
if (wordRange.start.line !== wordRange.end.line ||
|
|
472
|
+
wordRange.start.column !== wordRange.end.column) {
|
|
473
|
+
updateSelections([{ anchor: wordRange.start, head: wordRange.end }]);
|
|
474
|
+
}
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const prim = curSelections[0];
|
|
478
|
+
const query = getTextInRange(lines, normalizeSelection(prim));
|
|
479
|
+
if (!query)
|
|
480
|
+
return;
|
|
481
|
+
const fullText = lines.join("\n");
|
|
482
|
+
const existingOffsets = new Set();
|
|
483
|
+
for (const sel of curSelections) {
|
|
484
|
+
const norm = normalizeSelection(sel);
|
|
485
|
+
existingOffsets.add(positionToOffset(lines, norm.start));
|
|
486
|
+
}
|
|
487
|
+
const lastSel = curSelections[curSelections.length - 1];
|
|
488
|
+
const lastNorm = normalizeSelection(lastSel);
|
|
489
|
+
const lastEndOffset = positionToOffset(lines, lastNorm.end);
|
|
490
|
+
let matchIdx = fullText.indexOf(query, lastEndOffset);
|
|
491
|
+
if (matchIdx === -1) {
|
|
492
|
+
matchIdx = fullText.indexOf(query, 0);
|
|
493
|
+
}
|
|
494
|
+
let attempts = 0;
|
|
495
|
+
while (matchIdx !== -1 && existingOffsets.has(matchIdx) && attempts < fullText.length) {
|
|
496
|
+
matchIdx = fullText.indexOf(query, matchIdx + 1);
|
|
497
|
+
if (matchIdx === -1 && lastEndOffset > 0) {
|
|
498
|
+
matchIdx = fullText.indexOf(query, 0);
|
|
499
|
+
}
|
|
500
|
+
attempts++;
|
|
501
|
+
}
|
|
502
|
+
if (matchIdx !== -1 && !existingOffsets.has(matchIdx)) {
|
|
503
|
+
const matchStart = offsetToPosition(lines, matchIdx);
|
|
504
|
+
const matchEnd = offsetToPosition(lines, matchIdx + query.length);
|
|
505
|
+
updateSelections([...curSelections, { anchor: matchStart, head: matchEnd }]);
|
|
506
|
+
}
|
|
507
|
+
}, [lines, updateSelections]);
|
|
508
|
+
const selectAllOccurrences = useCallback(() => {
|
|
509
|
+
let curSelections = selectionsRef.current;
|
|
510
|
+
if (curSelections.length === 0)
|
|
511
|
+
return;
|
|
512
|
+
if (curSelections.length === 1 && isSelectionCollapsed(curSelections[0])) {
|
|
513
|
+
const head = curSelections[0].head;
|
|
514
|
+
const lineText = lines[head.line] || "";
|
|
515
|
+
const wordRange = getWordRangeAt(lineText, head.line, head.column);
|
|
516
|
+
if (wordRange.start.line === wordRange.end.line &&
|
|
517
|
+
wordRange.start.column === wordRange.end.column) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
curSelections = [{ anchor: wordRange.start, head: wordRange.end }];
|
|
521
|
+
}
|
|
522
|
+
const prim = curSelections[0];
|
|
523
|
+
const query = getTextInRange(lines, normalizeSelection(prim));
|
|
524
|
+
if (!query)
|
|
525
|
+
return;
|
|
526
|
+
const fullText = lines.join("\n");
|
|
527
|
+
const newSelections = [];
|
|
528
|
+
let idx = fullText.indexOf(query, 0);
|
|
529
|
+
while (idx !== -1) {
|
|
530
|
+
const matchStart = offsetToPosition(lines, idx);
|
|
531
|
+
const matchEnd = offsetToPosition(lines, idx + query.length);
|
|
532
|
+
newSelections.push({ anchor: matchStart, head: matchEnd });
|
|
533
|
+
idx = fullText.indexOf(query, idx + query.length);
|
|
534
|
+
}
|
|
535
|
+
if (newSelections.length > 0) {
|
|
536
|
+
updateSelections(newSelections);
|
|
537
|
+
}
|
|
538
|
+
}, [lines, updateSelections]);
|
|
539
|
+
const [ghostText, setGhostText] = useState(null);
|
|
540
|
+
const ghostTextRef = useRef(null);
|
|
541
|
+
ghostTextRef.current = ghostText;
|
|
542
|
+
const acceptGhostText = useCallback((mode = "full") => {
|
|
543
|
+
const ghost = ghostTextRef.current;
|
|
544
|
+
if (!ghost)
|
|
545
|
+
return;
|
|
546
|
+
const cur = cursorRef.current;
|
|
547
|
+
if (ghost.position.line !== cur.line || ghost.position.column !== cur.column) {
|
|
548
|
+
setGhostText(null);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (mode === "full") {
|
|
552
|
+
insertText(ghost.text);
|
|
553
|
+
setGhostText(null);
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
const match = ghost.text.match(/^(\s*\w+|\s*\W+)/);
|
|
557
|
+
if (match) {
|
|
558
|
+
const chunk = match[0];
|
|
559
|
+
insertText(chunk);
|
|
560
|
+
const remaining = ghost.text.slice(chunk.length);
|
|
561
|
+
if (remaining.length > 0) {
|
|
562
|
+
setGhostText({
|
|
563
|
+
text: remaining,
|
|
564
|
+
position: { line: cur.line, column: cur.column + chunk.length },
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
setGhostText(null);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
insertText(ghost.text);
|
|
573
|
+
setGhostText(null);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}, [insertText]);
|
|
577
|
+
const setColumnSelection = useCallback((start, end) => {
|
|
578
|
+
const cols = computeColumnSelection(lines, start, end);
|
|
579
|
+
if (cols.length > 0) {
|
|
580
|
+
updateSelections(cols);
|
|
581
|
+
}
|
|
582
|
+
}, [lines, updateSelections]);
|
|
583
|
+
const applyCodeAction = useCallback((action) => {
|
|
584
|
+
if (!action.changes || action.changes.length === 0)
|
|
585
|
+
return;
|
|
586
|
+
setLines((prevLines) => {
|
|
587
|
+
pushUndo(prevLines, selectionsRef.current);
|
|
588
|
+
let fullText = prevLines.join("\n");
|
|
589
|
+
const sortedChanges = [...(action.changes || [])].sort((a, b) => b.from - a.from);
|
|
590
|
+
for (const change of sortedChanges) {
|
|
591
|
+
fullText = fullText.slice(0, change.from) + change.insert + fullText.slice(change.to);
|
|
592
|
+
}
|
|
593
|
+
const nextLines = fullText.split(/\r?\n/);
|
|
594
|
+
setIsDirty(true);
|
|
595
|
+
onChange?.(fullText);
|
|
596
|
+
return nextLines;
|
|
597
|
+
});
|
|
598
|
+
}, [pushUndo, onChange]);
|
|
599
|
+
const undo = useCallback(() => {
|
|
600
|
+
setGhostText(null);
|
|
601
|
+
const prev = undoStack.current.pop();
|
|
602
|
+
if (!prev)
|
|
603
|
+
return;
|
|
604
|
+
redoStack.current.push({ lines, selections: selectionsRef.current });
|
|
605
|
+
setLines(prev.lines);
|
|
606
|
+
updateSelections(prev.selections);
|
|
607
|
+
onChange?.(prev.lines.join("\n"));
|
|
608
|
+
}, [lines, updateSelections, onChange]);
|
|
609
|
+
const redo = useCallback(() => {
|
|
610
|
+
setGhostText(null);
|
|
611
|
+
const next = redoStack.current.pop();
|
|
612
|
+
if (!next)
|
|
613
|
+
return;
|
|
614
|
+
undoStack.current.push({ lines, selections: selectionsRef.current });
|
|
615
|
+
setLines(next.lines);
|
|
616
|
+
updateSelections(next.selections);
|
|
617
|
+
onChange?.(next.lines.join("\n"));
|
|
618
|
+
}, [lines, updateSelections, onChange]);
|
|
619
|
+
const value = {
|
|
620
|
+
adapter,
|
|
621
|
+
lines,
|
|
622
|
+
totalLines: lines.length,
|
|
623
|
+
cursor,
|
|
624
|
+
selection,
|
|
625
|
+
selections,
|
|
626
|
+
ghostText,
|
|
627
|
+
setGhostText,
|
|
628
|
+
acceptGhostText,
|
|
629
|
+
setColumnSelection,
|
|
630
|
+
applyCodeAction,
|
|
631
|
+
isDirty,
|
|
632
|
+
canUndo: undoStack.current.length > 0,
|
|
633
|
+
canRedo: redoStack.current.length > 0,
|
|
634
|
+
visibleRange,
|
|
635
|
+
insertText,
|
|
636
|
+
deleteSelectionOrChar,
|
|
637
|
+
enter,
|
|
638
|
+
tab,
|
|
639
|
+
moveCursor,
|
|
640
|
+
moveByWord,
|
|
641
|
+
moveToLineEdge,
|
|
642
|
+
moveByPage,
|
|
643
|
+
toggleComment,
|
|
644
|
+
autoCloseOrWrap,
|
|
645
|
+
setCursor,
|
|
646
|
+
setSelection,
|
|
647
|
+
setSelections,
|
|
648
|
+
addCursor,
|
|
649
|
+
selectNextOccurrence,
|
|
650
|
+
selectAllOccurrences,
|
|
651
|
+
clearSecondaryCursors,
|
|
652
|
+
selectAll,
|
|
653
|
+
setVisibleRange: (startLine, lineCount) => setVisibleRange({ startLine, lineCount }),
|
|
654
|
+
undo,
|
|
655
|
+
redo,
|
|
656
|
+
getContent,
|
|
657
|
+
getAdapterContent,
|
|
658
|
+
tier: adapter ? "adapter" : "memory",
|
|
659
|
+
snapshot: adapter ? snapshot : null,
|
|
660
|
+
setContent,
|
|
661
|
+
replaceContent,
|
|
662
|
+
};
|
|
663
|
+
return _jsx(EditorContext.Provider, { value: value, children: children });
|
|
664
|
+
}
|
|
665
|
+
export function useEditorContext() {
|
|
666
|
+
const ctx = useContext(EditorContext);
|
|
667
|
+
if (!ctx) {
|
|
668
|
+
throw new Error("useEditorContext must be used within an <EditorProvider />");
|
|
669
|
+
}
|
|
670
|
+
return ctx;
|
|
671
|
+
}
|
|
672
|
+
//# sourceMappingURL=EditorContext.js.map
|