@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.
@@ -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 paste markers as single units.
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, validIds) {
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 m of text.matchAll(PASTE_MARKER_REGEX)) {
34
- const id = Number.parseInt(m[1], 10);
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
- markers.push({ start: m.index, end: m.index + m[0].length });
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 = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme);
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 && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
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 = !isPasteMarker(grapheme) && cjkBreakRegex.test(grapheme);
154
- const nextIsCjk = !isPasteMarker(next.segment) && cjkBreakRegex.test(next.segment);
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
- /** Segment text with paste-marker awareness, only merging markers with valid IDs. */
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.validPasteIds());
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: isPasteMarker,
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: isPasteMarker,
1842
+ isAtomicSegment: isAtomicMarker,
1741
1843
  }));
1742
1844
  }
1743
1845
  // Slash menu only allowed on the first line of the editor