@ashx-j/lunr-tui 0.2.8 → 0.2.10
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/dist/components/editor.d.ts +33 -1
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +123 -21
- package/dist/components/editor.js.map +1 -1
- package/dist/editor-component.d.ts +26 -0
- package/dist/editor-component.d.ts.map +1 -1
- package/dist/editor-component.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +7 -3
- package/dist/keys.js.map +1 -1
- package/dist/tui.d.ts +11 -0
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +94 -7
- package/dist/tui.js.map +1 -1
- package/package.json +1 -1
|
@@ -12,33 +12,58 @@ const wordSegmenter = getWordSegmenter();
|
|
|
12
12
|
const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
|
|
13
13
|
/** Non-global version for single-segment testing. */
|
|
14
14
|
const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;
|
|
15
|
+
/** Regex matching clipboard image chips like `[image_1]`. */
|
|
16
|
+
export const IMAGE_MARKER_REGEX = /\[image_(\d+)\]/g;
|
|
17
|
+
/** Non-global version for single-segment testing. */
|
|
18
|
+
const IMAGE_MARKER_SINGLE = /^\[image_(\d+)\]$/;
|
|
19
|
+
export function formatImageMarker(id) {
|
|
20
|
+
return `[image_${id}]`;
|
|
21
|
+
}
|
|
22
|
+
export function collectImageMarkerIds(text) {
|
|
23
|
+
const ids = [];
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
for (const match of text.matchAll(IMAGE_MARKER_REGEX)) {
|
|
26
|
+
const id = Number.parseInt(match[1], 10);
|
|
27
|
+
if (seen.has(id))
|
|
28
|
+
continue;
|
|
29
|
+
seen.add(id);
|
|
30
|
+
ids.push(id);
|
|
31
|
+
}
|
|
32
|
+
return ids;
|
|
33
|
+
}
|
|
15
34
|
/** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */
|
|
16
35
|
function isPasteMarker(segment) {
|
|
17
36
|
return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment);
|
|
18
37
|
}
|
|
38
|
+
function isImageMarker(segment) {
|
|
39
|
+
return segment.length >= 9 && IMAGE_MARKER_SINGLE.test(segment);
|
|
40
|
+
}
|
|
41
|
+
function isAtomicMarker(segment) {
|
|
42
|
+
return isPasteMarker(segment) || isImageMarker(segment);
|
|
43
|
+
}
|
|
19
44
|
/**
|
|
20
45
|
* A segmenter that wraps Intl.Segmenter and merges graphemes that fall
|
|
21
|
-
* within paste markers into single atomic segments. This makes cursor
|
|
22
|
-
* movement, deletion, word-wrap, etc. treat
|
|
46
|
+
* within paste/image markers into single atomic segments. This makes cursor
|
|
47
|
+
* movement, deletion, word-wrap, etc. treat those markers as single units.
|
|
23
48
|
*
|
|
24
|
-
* Only markers whose numeric ID exists in `validIds` are merged.
|
|
49
|
+
* Only markers whose numeric ID exists in a family's `validIds` are merged.
|
|
25
50
|
*/
|
|
26
|
-
function segmentWithMarkers(text, baseSegmenter,
|
|
27
|
-
// Fast path: no paste markers in the text or no valid IDs.
|
|
28
|
-
if (validIds.size === 0 || !text.includes("[paste #")) {
|
|
29
|
-
return baseSegmenter.segment(text);
|
|
30
|
-
}
|
|
31
|
-
// Find all marker spans with valid IDs.
|
|
51
|
+
function segmentWithMarkers(text, baseSegmenter, families) {
|
|
32
52
|
const markers = [];
|
|
33
|
-
for (const
|
|
34
|
-
|
|
35
|
-
if (!validIds.has(id))
|
|
53
|
+
for (const family of families) {
|
|
54
|
+
if (family.validIds.size === 0 || !text.includes(family.needle))
|
|
36
55
|
continue;
|
|
37
|
-
|
|
56
|
+
for (const m of text.matchAll(family.regex)) {
|
|
57
|
+
const id = Number.parseInt(m[1], 10);
|
|
58
|
+
if (!family.validIds.has(id))
|
|
59
|
+
continue;
|
|
60
|
+
markers.push({ start: m.index, end: m.index + m[0].length });
|
|
61
|
+
}
|
|
38
62
|
}
|
|
39
63
|
if (markers.length === 0) {
|
|
40
64
|
return baseSegmenter.segment(text);
|
|
41
65
|
}
|
|
66
|
+
markers.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
42
67
|
// Build merged segment list.
|
|
43
68
|
const baseSegments = baseSegmenter.segment(text);
|
|
44
69
|
const result = [];
|
|
@@ -100,7 +125,7 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
|
|
|
100
125
|
const grapheme = seg.segment;
|
|
101
126
|
const gWidth = visibleWidth(grapheme);
|
|
102
127
|
const charIndex = seg.index;
|
|
103
|
-
const isWs = !
|
|
128
|
+
const isWs = !isAtomicMarker(grapheme) && isWhitespaceChar(grapheme);
|
|
104
129
|
// Overflow check before advancing.
|
|
105
130
|
if (currentWidth + gWidth > maxWidth) {
|
|
106
131
|
if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) {
|
|
@@ -145,13 +170,13 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
|
|
|
145
170
|
// or at a boundary where either side is CJK (CJK allows breaking
|
|
146
171
|
// between any adjacent characters).
|
|
147
172
|
const next = segments[i + 1];
|
|
148
|
-
if (isWs && next && (
|
|
173
|
+
if (isWs && next && (isAtomicMarker(next.segment) || !isWhitespaceChar(next.segment))) {
|
|
149
174
|
wrapOppIndex = next.index;
|
|
150
175
|
wrapOppWidth = currentWidth;
|
|
151
176
|
}
|
|
152
177
|
else if (!isWs && next && !isWhitespaceChar(next.segment)) {
|
|
153
|
-
const isCjk = !
|
|
154
|
-
const nextIsCjk = !
|
|
178
|
+
const isCjk = !isAtomicMarker(grapheme) && cjkBreakRegex.test(grapheme);
|
|
179
|
+
const nextIsCjk = !isAtomicMarker(next.segment) && cjkBreakRegex.test(next.segment);
|
|
155
180
|
if (isCjk || nextIsCjk) {
|
|
156
181
|
wrapOppIndex = next.index;
|
|
157
182
|
wrapOppWidth = currentWidth;
|
|
@@ -212,6 +237,10 @@ export class Editor {
|
|
|
212
237
|
// Paste tracking for large pastes
|
|
213
238
|
pastes = new Map();
|
|
214
239
|
pasteCounter = 0;
|
|
240
|
+
// Clipboard image chips: `[image_n]` stays in the editor; bytes live off-screen.
|
|
241
|
+
images = new Map();
|
|
242
|
+
imageCounter = 0;
|
|
243
|
+
submittedImages = [];
|
|
215
244
|
// Bracketed paste mode buffering
|
|
216
245
|
pasteBuffer = "";
|
|
217
246
|
isInPaste = false;
|
|
@@ -250,9 +279,19 @@ export class Editor {
|
|
|
250
279
|
validPasteIds() {
|
|
251
280
|
return new Set(this.pastes.keys());
|
|
252
281
|
}
|
|
253
|
-
/**
|
|
282
|
+
/** Set of currently valid image IDs, for marker-aware segmentation. */
|
|
283
|
+
validImageIds() {
|
|
284
|
+
return new Set(this.images.keys());
|
|
285
|
+
}
|
|
286
|
+
markerFamilies() {
|
|
287
|
+
return [
|
|
288
|
+
{ regex: PASTE_MARKER_REGEX, validIds: this.validPasteIds(), needle: "[paste #" },
|
|
289
|
+
{ regex: IMAGE_MARKER_REGEX, validIds: this.validImageIds(), needle: "[image_" },
|
|
290
|
+
];
|
|
291
|
+
}
|
|
292
|
+
/** Segment text with paste/image-marker awareness, only merging markers with valid IDs. */
|
|
254
293
|
segment(text, mode) {
|
|
255
|
-
return segmentWithMarkers(text, mode === "word" ? wordSegmenter : graphemeSegmenter, this.
|
|
294
|
+
return segmentWithMarkers(text, mode === "word" ? wordSegmenter : graphemeSegmenter, this.markerFamilies());
|
|
256
295
|
}
|
|
257
296
|
getPaddingX() {
|
|
258
297
|
return this.paddingX;
|
|
@@ -839,6 +878,9 @@ export class Editor {
|
|
|
839
878
|
this.exitHistoryBrowsing();
|
|
840
879
|
this.pastes.clear();
|
|
841
880
|
this.pasteCounter = 0;
|
|
881
|
+
this.images.clear();
|
|
882
|
+
this.imageCounter = 0;
|
|
883
|
+
// Keep submittedImages: submitValue() snapshots chips, then setText("") runs.
|
|
842
884
|
const normalized = this.normalizeText(text);
|
|
843
885
|
// Push undo snapshot if content differs (makes programmatic changes undoable)
|
|
844
886
|
if (this.getText() !== normalized) {
|
|
@@ -860,6 +902,53 @@ export class Editor {
|
|
|
860
902
|
this.exitHistoryBrowsing();
|
|
861
903
|
this.insertTextAtCursorInternal(text);
|
|
862
904
|
}
|
|
905
|
+
/**
|
|
906
|
+
* Insert an atomic `[image_n]` chip and remember the off-screen file.
|
|
907
|
+
* IDs stay stable for the current draft. The next unused number is used.
|
|
908
|
+
*/
|
|
909
|
+
insertImageMarker(attachment) {
|
|
910
|
+
this.cancelAutocomplete();
|
|
911
|
+
this.pushUndoSnapshot();
|
|
912
|
+
this.lastAction = null;
|
|
913
|
+
this.exitHistoryBrowsing();
|
|
914
|
+
this.imageCounter++;
|
|
915
|
+
const id = this.imageCounter;
|
|
916
|
+
this.images.set(id, { id, path: attachment.path, mimeType: attachment.mimeType });
|
|
917
|
+
this.insertTextAtCursorInternal(formatImageMarker(id));
|
|
918
|
+
return id;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Restore previously submitted image chips into the current draft.
|
|
922
|
+
* Used by /edit so `[image_n]` stays a chip instead of becoming a path.
|
|
923
|
+
*/
|
|
924
|
+
restoreImageMarkers(attachments) {
|
|
925
|
+
this.images.clear();
|
|
926
|
+
this.imageCounter = 0;
|
|
927
|
+
for (const attachment of attachments) {
|
|
928
|
+
this.images.set(attachment.id, { ...attachment });
|
|
929
|
+
if (attachment.id > this.imageCounter)
|
|
930
|
+
this.imageCounter = attachment.id;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
/** Pending image chips still in the editor, in first-appearance order. */
|
|
934
|
+
getPendingImages() {
|
|
935
|
+
const text = this.getText();
|
|
936
|
+
const pending = [];
|
|
937
|
+
for (const id of collectImageMarkerIds(text)) {
|
|
938
|
+
const attachment = this.images.get(id);
|
|
939
|
+
if (attachment)
|
|
940
|
+
pending.push(attachment);
|
|
941
|
+
}
|
|
942
|
+
return pending;
|
|
943
|
+
}
|
|
944
|
+
/** Take and clear pending image chips. Call after the text has been submitted. */
|
|
945
|
+
takePendingImages() {
|
|
946
|
+
const pending = this.submittedImages.length > 0 ? this.submittedImages : this.getPendingImages();
|
|
947
|
+
this.submittedImages = [];
|
|
948
|
+
this.images.clear();
|
|
949
|
+
this.imageCounter = 0;
|
|
950
|
+
return pending;
|
|
951
|
+
}
|
|
863
952
|
/**
|
|
864
953
|
* Normalize text for editor storage:
|
|
865
954
|
* - Normalize line endings (\r\n and \r -> \n)
|
|
@@ -1054,9 +1143,13 @@ export class Editor {
|
|
|
1054
1143
|
submitValue() {
|
|
1055
1144
|
this.cancelAutocomplete();
|
|
1056
1145
|
const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim();
|
|
1146
|
+
// Snapshot before the editor is cleared so onSubmit can still attach files.
|
|
1147
|
+
this.submittedImages = this.getPendingImages();
|
|
1057
1148
|
this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
|
1058
1149
|
this.pastes.clear();
|
|
1059
1150
|
this.pasteCounter = 0;
|
|
1151
|
+
this.images.clear();
|
|
1152
|
+
this.imageCounter = 0;
|
|
1060
1153
|
this.exitHistoryBrowsing();
|
|
1061
1154
|
this.scrollOffset = 0;
|
|
1062
1155
|
this.undoStack.clear();
|
|
@@ -1079,6 +1172,7 @@ export class Editor {
|
|
|
1079
1172
|
const lastGrapheme = graphemes[graphemes.length - 1];
|
|
1080
1173
|
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
|
1081
1174
|
const isPastedSegmented = PASTE_MARKER_SINGLE.exec(lastGrapheme.segment);
|
|
1175
|
+
const isImageSegmented = IMAGE_MARKER_SINGLE.exec(lastGrapheme.segment);
|
|
1082
1176
|
if (isPastedSegmented) {
|
|
1083
1177
|
// This contains the id part e.g 4 from [paste #4 +123 lines]
|
|
1084
1178
|
const targetId = Number(isPastedSegmented[1]);
|
|
@@ -1096,6 +1190,10 @@ export class Editor {
|
|
|
1096
1190
|
return newText;
|
|
1097
1191
|
}));
|
|
1098
1192
|
}
|
|
1193
|
+
else if (isImageSegmented) {
|
|
1194
|
+
const targetId = Number(isImageSegmented[1]);
|
|
1195
|
+
this.images.delete(targetId);
|
|
1196
|
+
}
|
|
1099
1197
|
line = this.state.lines[this.state.cursorLine] || "";
|
|
1100
1198
|
const before = line.slice(0, this.state.cursorCol - graphemeLength);
|
|
1101
1199
|
const after = line.slice(this.state.cursorCol);
|
|
@@ -1401,6 +1499,10 @@ export class Editor {
|
|
|
1401
1499
|
const graphemes = [...this.segment(afterCursor, "grapheme")];
|
|
1402
1500
|
const firstGrapheme = graphemes[0];
|
|
1403
1501
|
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
|
1502
|
+
const deletedImage = firstGrapheme ? IMAGE_MARKER_SINGLE.exec(firstGrapheme.segment) : null;
|
|
1503
|
+
if (deletedImage) {
|
|
1504
|
+
this.images.delete(Number(deletedImage[1]));
|
|
1505
|
+
}
|
|
1404
1506
|
const before = currentLine.slice(0, this.state.cursorCol);
|
|
1405
1507
|
const after = currentLine.slice(this.state.cursorCol + graphemeLength);
|
|
1406
1508
|
this.state.lines[this.state.cursorLine] = before + after;
|
|
@@ -1577,7 +1679,7 @@ export class Editor {
|
|
|
1577
1679
|
}
|
|
1578
1680
|
this.setCursorCol(findWordBackward(currentLine, this.state.cursorCol, {
|
|
1579
1681
|
segment: (text) => this.segment(text, "word"),
|
|
1580
|
-
isAtomicSegment:
|
|
1682
|
+
isAtomicSegment: isAtomicMarker,
|
|
1581
1683
|
}));
|
|
1582
1684
|
}
|
|
1583
1685
|
/**
|
|
@@ -1737,7 +1839,7 @@ export class Editor {
|
|
|
1737
1839
|
}
|
|
1738
1840
|
this.setCursorCol(findWordForward(currentLine, this.state.cursorCol, {
|
|
1739
1841
|
segment: (text) => this.segment(text, "word"),
|
|
1740
|
-
isAtomicSegment:
|
|
1842
|
+
isAtomicSegment: isAtomicMarker,
|
|
1741
1843
|
}));
|
|
1742
1844
|
}
|
|
1743
1845
|
// Slash menu only allowed on the first line of the editor
|