@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.
@@ -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