@pre-markdown/layout 0.2.0

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/index.js ADDED
@@ -0,0 +1,1348 @@
1
+ // src/index.ts
2
+ import {
3
+ prepare as prepare2,
4
+ prepareWithSegments as prepareWithSegments2,
5
+ layout as layout2,
6
+ layoutWithLines as layoutWithLines2,
7
+ clearCache as pretextClearCache2,
8
+ setLocale as pretextSetLocale2
9
+ } from "@chenglou/pretext";
10
+
11
+ // src/worker-backend.ts
12
+ import {
13
+ prepare,
14
+ prepareWithSegments,
15
+ layout,
16
+ layoutWithLines,
17
+ clearCache as pretextClearCache,
18
+ setLocale as pretextSetLocale
19
+ } from "@chenglou/pretext";
20
+ function createWorkerBackend(workerUrl) {
21
+ const preparedCache = /* @__PURE__ */ new Map();
22
+ const segmentCache = /* @__PURE__ */ new Map();
23
+ let worker = null;
24
+ let alive = true;
25
+ let nextId = 1;
26
+ const pending = /* @__PURE__ */ new Map();
27
+ function getWorker() {
28
+ if (worker) return worker;
29
+ const url = workerUrl ?? new URL("./worker-script.js", import.meta.url);
30
+ worker = new Worker(url, { type: "module" });
31
+ worker.onmessage = (event) => {
32
+ const { id, type, result, error } = event.data;
33
+ const handlers = pending.get(id);
34
+ if (!handlers) return;
35
+ pending.delete(id);
36
+ if (type === "error") {
37
+ handlers.reject(new Error(error ?? "Worker error"));
38
+ } else {
39
+ handlers.resolve(result);
40
+ }
41
+ };
42
+ worker.onerror = (event) => {
43
+ for (const [id, handlers] of pending) {
44
+ handlers.reject(new Error(`Worker error: ${event.message}`));
45
+ }
46
+ pending.clear();
47
+ };
48
+ return worker;
49
+ }
50
+ function sendMessage(msg) {
51
+ if (!alive) return Promise.reject(new Error("Worker has been terminated"));
52
+ return new Promise((resolve, reject) => {
53
+ const id = nextId++;
54
+ pending.set(id, { resolve, reject });
55
+ getWorker().postMessage({ ...msg, id });
56
+ });
57
+ }
58
+ function cacheKey(text, font, options) {
59
+ return `${font}|${options?.whiteSpace ?? "normal"}|${text}`;
60
+ }
61
+ const backend = {
62
+ // Synchronous prepare — uses main-thread pretext (with cache fallback)
63
+ prepare(text, font, options) {
64
+ const key = cacheKey(text, font, options);
65
+ let cached = preparedCache.get(key);
66
+ if (!cached) {
67
+ cached = prepare(text, font, options);
68
+ preparedCache.set(key, cached);
69
+ }
70
+ return cached;
71
+ },
72
+ prepareWithSegments(text, font, options) {
73
+ const key = cacheKey(text, font, options);
74
+ let cached = segmentCache.get(key);
75
+ if (!cached) {
76
+ cached = prepareWithSegments(text, font, options);
77
+ segmentCache.set(key, cached);
78
+ }
79
+ return cached;
80
+ },
81
+ // Layout is always synchronous (pure arithmetic)
82
+ layout(prepared, maxWidth, lineHeight) {
83
+ return layout(prepared, maxWidth, lineHeight);
84
+ },
85
+ layoutWithLines(prepared, maxWidth, lineHeight) {
86
+ return layoutWithLines(prepared, maxWidth, lineHeight);
87
+ },
88
+ clearCache() {
89
+ preparedCache.clear();
90
+ segmentCache.clear();
91
+ pretextClearCache();
92
+ if (alive && worker) {
93
+ sendMessage({ type: "clearCache" }).catch(() => {
94
+ });
95
+ }
96
+ },
97
+ setLocale(locale) {
98
+ pretextSetLocale(locale);
99
+ if (alive && worker) {
100
+ sendMessage({ type: "setLocale", locale }).catch(() => {
101
+ });
102
+ }
103
+ },
104
+ // --------------------------------------------------------
105
+ // Async API — offloads to Worker
106
+ // --------------------------------------------------------
107
+ async prepareAsync(texts, font, options) {
108
+ const results = new Array(texts.length);
109
+ const toProcess = [];
110
+ for (let i = 0; i < texts.length; i++) {
111
+ const key = cacheKey(texts[i], font, options);
112
+ const cached = preparedCache.get(key);
113
+ if (cached) {
114
+ results[i] = cached;
115
+ } else {
116
+ toProcess.push({ index: i, text: texts[i] });
117
+ }
118
+ }
119
+ if (toProcess.length > 0) {
120
+ const BATCH_SIZE = 50;
121
+ for (let b = 0; b < toProcess.length; b += BATCH_SIZE) {
122
+ const batch = toProcess.slice(b, b + BATCH_SIZE);
123
+ const promises = batch.map(
124
+ ({ text }) => sendMessage({ type: "prepare", text, font, options }).then((result) => result)
125
+ );
126
+ const batchResults = await Promise.all(promises);
127
+ for (let j = 0; j < batch.length; j++) {
128
+ const { index, text } = batch[j];
129
+ const prepared = batchResults[j];
130
+ const key = cacheKey(text, font, options);
131
+ preparedCache.set(key, prepared);
132
+ results[index] = prepared;
133
+ }
134
+ }
135
+ }
136
+ return results;
137
+ },
138
+ async prepareWithSegmentsAsync(texts, font, options) {
139
+ const results = new Array(texts.length);
140
+ const toProcess = [];
141
+ for (let i = 0; i < texts.length; i++) {
142
+ const key = cacheKey(texts[i], font, options);
143
+ const cached = segmentCache.get(key);
144
+ if (cached) {
145
+ results[i] = cached;
146
+ } else {
147
+ toProcess.push({ index: i, text: texts[i] });
148
+ }
149
+ }
150
+ if (toProcess.length > 0) {
151
+ const BATCH_SIZE = 50;
152
+ for (let b = 0; b < toProcess.length; b += BATCH_SIZE) {
153
+ const batch = toProcess.slice(b, b + BATCH_SIZE);
154
+ const promises = batch.map(
155
+ ({ text }) => sendMessage({ type: "prepareWithSegments", text, font, options }).then((result) => result)
156
+ );
157
+ const batchResults = await Promise.all(promises);
158
+ for (let j = 0; j < batch.length; j++) {
159
+ const { index, text } = batch[j];
160
+ const prepared = batchResults[j];
161
+ const key = cacheKey(text, font, options);
162
+ segmentCache.set(key, prepared);
163
+ results[index] = prepared;
164
+ }
165
+ }
166
+ }
167
+ return results;
168
+ },
169
+ get isAlive() {
170
+ return alive;
171
+ },
172
+ terminate() {
173
+ alive = false;
174
+ if (worker) {
175
+ worker.terminate();
176
+ worker = null;
177
+ }
178
+ for (const [, handlers] of pending) {
179
+ handlers.reject(new Error("Worker terminated"));
180
+ }
181
+ pending.clear();
182
+ }
183
+ };
184
+ return backend;
185
+ }
186
+
187
+ // src/virtual-list.ts
188
+ var VirtualList = class {
189
+ engine;
190
+ viewportHeight;
191
+ overscan;
192
+ itemGap;
193
+ /** Item texts */
194
+ items = [];
195
+ /** Cached item heights (pretext-measured) */
196
+ heights = [];
197
+ /** Cumulative offsets (heights[0..i-1] + gaps) */
198
+ offsets = [];
199
+ /** Total height of all items */
200
+ totalHeight = 0;
201
+ /** Current scroll position */
202
+ scrollTop = 0;
203
+ /** Viewport change callback */
204
+ onViewportChange = null;
205
+ constructor(config) {
206
+ this.engine = config.engine;
207
+ this.viewportHeight = config.viewportHeight;
208
+ this.overscan = config.overscan ?? 5;
209
+ this.itemGap = config.itemGap ?? 0;
210
+ }
211
+ // --------------------------------------------------------
212
+ // Data Management
213
+ // --------------------------------------------------------
214
+ /**
215
+ * Set the full list of items. Computes all heights.
216
+ * For incremental updates, use updateItems() instead.
217
+ */
218
+ setItems(texts) {
219
+ this.items = texts;
220
+ this.heights = new Array(texts.length);
221
+ this.offsets = new Array(texts.length);
222
+ let cumHeight = 0;
223
+ for (let i = 0; i < texts.length; i++) {
224
+ this.offsets[i] = cumHeight;
225
+ const result = this.engine.computeLayout(texts[i]);
226
+ this.heights[i] = result.height;
227
+ cumHeight += result.height + this.itemGap;
228
+ }
229
+ this.totalHeight = texts.length > 0 ? cumHeight - this.itemGap : 0;
230
+ }
231
+ /**
232
+ * Incrementally update items. Only recomputes heights for changed items.
233
+ * Much faster than setItems() for editing scenarios.
234
+ *
235
+ * @returns Indices of items that changed height
236
+ */
237
+ updateItems(texts) {
238
+ const prevItems = this.items;
239
+ const prevHeights = this.heights;
240
+ this.items = texts;
241
+ const len = texts.length;
242
+ const heights = new Array(len);
243
+ const changedIndices = [];
244
+ for (let i = 0; i < len; i++) {
245
+ if (i < prevItems.length && prevItems[i] === texts[i]) {
246
+ heights[i] = prevHeights[i];
247
+ } else {
248
+ heights[i] = this.engine.computeLayout(texts[i]).height;
249
+ changedIndices.push(i);
250
+ }
251
+ }
252
+ this.heights = heights;
253
+ this.offsets = new Array(len);
254
+ let cumHeight = 0;
255
+ for (let i = 0; i < len; i++) {
256
+ this.offsets[i] = cumHeight;
257
+ cumHeight += heights[i] + this.itemGap;
258
+ }
259
+ this.totalHeight = len > 0 ? cumHeight - this.itemGap : 0;
260
+ return changedIndices;
261
+ }
262
+ /**
263
+ * Get the height of a specific item.
264
+ */
265
+ getItemHeight(index) {
266
+ return this.heights[index] ?? 0;
267
+ }
268
+ /**
269
+ * Get the offset of a specific item from the top.
270
+ */
271
+ getItemOffset(index) {
272
+ return this.offsets[index] ?? 0;
273
+ }
274
+ // --------------------------------------------------------
275
+ // Scroll & Viewport
276
+ // --------------------------------------------------------
277
+ /**
278
+ * Set the scroll position and compute the visible range.
279
+ * Fires the onViewportChange callback if registered.
280
+ */
281
+ setScrollTop(scrollTop) {
282
+ this.scrollTop = Math.max(0, Math.min(scrollTop, this.totalHeight - this.viewportHeight));
283
+ const range = this.computeViewport();
284
+ if (this.onViewportChange) {
285
+ this.onViewportChange(range);
286
+ }
287
+ return range;
288
+ }
289
+ /**
290
+ * Get the current scroll position.
291
+ */
292
+ getScrollTop() {
293
+ return this.scrollTop;
294
+ }
295
+ /**
296
+ * Update viewport height (e.g., on window resize).
297
+ */
298
+ setViewportHeight(height) {
299
+ this.viewportHeight = height;
300
+ }
301
+ /**
302
+ * Scroll to make a specific item visible.
303
+ * @param index - Item index to scroll to
304
+ * @param align - 'start' | 'center' | 'end' (default: 'start')
305
+ */
306
+ scrollToItem(index, align = "start") {
307
+ if (index < 0 || index >= this.items.length) {
308
+ return this.computeViewport();
309
+ }
310
+ const offset = this.offsets[index];
311
+ const height = this.heights[index];
312
+ let scrollTop;
313
+ switch (align) {
314
+ case "start":
315
+ scrollTop = offset;
316
+ break;
317
+ case "center":
318
+ scrollTop = offset - (this.viewportHeight - height) / 2;
319
+ break;
320
+ case "end":
321
+ scrollTop = offset - this.viewportHeight + height;
322
+ break;
323
+ }
324
+ return this.setScrollTop(scrollTop);
325
+ }
326
+ /**
327
+ * Register a callback for viewport changes.
328
+ */
329
+ onViewport(callback) {
330
+ this.onViewportChange = callback;
331
+ }
332
+ // --------------------------------------------------------
333
+ // Viewport Computation
334
+ // --------------------------------------------------------
335
+ /**
336
+ * Compute the current visible range.
337
+ */
338
+ computeViewport() {
339
+ const len = this.items.length;
340
+ if (len === 0) {
341
+ return {
342
+ startIndex: 0,
343
+ endIndex: 0,
344
+ items: [],
345
+ totalHeight: 0,
346
+ offsetY: 0
347
+ };
348
+ }
349
+ const scrollTop = this.scrollTop;
350
+ const scrollBottom = scrollTop + this.viewportHeight;
351
+ let startIndex = this.binarySearchOffset(scrollTop);
352
+ let endIndex = this.binarySearchOffset(scrollBottom);
353
+ if (endIndex < len) endIndex++;
354
+ startIndex = Math.max(0, startIndex - this.overscan);
355
+ endIndex = Math.min(len, endIndex + this.overscan);
356
+ const items = [];
357
+ for (let i = startIndex; i < endIndex; i++) {
358
+ items.push({
359
+ index: i,
360
+ offsetTop: this.offsets[i],
361
+ height: this.heights[i],
362
+ text: this.items[i]
363
+ });
364
+ }
365
+ return {
366
+ startIndex,
367
+ endIndex,
368
+ items,
369
+ totalHeight: this.totalHeight,
370
+ offsetY: this.offsets[startIndex] ?? 0
371
+ };
372
+ }
373
+ // --------------------------------------------------------
374
+ // Hit Testing
375
+ // --------------------------------------------------------
376
+ /**
377
+ * Find which item is at a given Y position.
378
+ * @returns Item index, or -1 if out of bounds
379
+ */
380
+ hitTest(y) {
381
+ if (this.items.length === 0 || y < 0) return -1;
382
+ if (y >= this.totalHeight) return -1;
383
+ return this.binarySearchOffset(y);
384
+ }
385
+ /**
386
+ * Find the item and local Y offset within it.
387
+ */
388
+ hitTestDetailed(y) {
389
+ const index = this.hitTest(y);
390
+ if (index === -1) return null;
391
+ return {
392
+ index,
393
+ localY: y - this.offsets[index]
394
+ };
395
+ }
396
+ // --------------------------------------------------------
397
+ // Resize Handling
398
+ // --------------------------------------------------------
399
+ /**
400
+ * Recompute all item heights (e.g., after maxWidth change).
401
+ * Call this after engine.updateConfig({ maxWidth: newWidth }).
402
+ *
403
+ * @returns Time taken in milliseconds
404
+ */
405
+ relayout() {
406
+ const start = performance.now();
407
+ let cumHeight = 0;
408
+ for (let i = 0; i < this.items.length; i++) {
409
+ this.offsets[i] = cumHeight;
410
+ const result = this.engine.computeLayout(this.items[i]);
411
+ this.heights[i] = result.height;
412
+ cumHeight += result.height + this.itemGap;
413
+ }
414
+ this.totalHeight = this.items.length > 0 ? cumHeight - this.itemGap : 0;
415
+ return performance.now() - start;
416
+ }
417
+ // --------------------------------------------------------
418
+ // Getters
419
+ // --------------------------------------------------------
420
+ /** Total height of all items */
421
+ getTotalHeight() {
422
+ return this.totalHeight;
423
+ }
424
+ /** Number of items */
425
+ getItemCount() {
426
+ return this.items.length;
427
+ }
428
+ /** Get all item heights (readonly) */
429
+ getHeights() {
430
+ return this.heights;
431
+ }
432
+ /** Get all item offsets (readonly) */
433
+ getOffsets() {
434
+ return this.offsets;
435
+ }
436
+ // --------------------------------------------------------
437
+ // Internal: Binary Search
438
+ // --------------------------------------------------------
439
+ /**
440
+ * Binary search for the item at a given Y offset.
441
+ * Returns the index of the item that contains the offset.
442
+ */
443
+ binarySearchOffset(y) {
444
+ const offsets = this.offsets;
445
+ const len = offsets.length;
446
+ if (len === 0) return 0;
447
+ let lo = 0;
448
+ let hi = len - 1;
449
+ while (lo < hi) {
450
+ const mid = lo + hi + 1 >>> 1;
451
+ if (offsets[mid] <= y) {
452
+ lo = mid;
453
+ } else {
454
+ hi = mid - 1;
455
+ }
456
+ }
457
+ return lo;
458
+ }
459
+ };
460
+
461
+ // src/cursor.ts
462
+ var CursorEngine = class {
463
+ engine;
464
+ text = "";
465
+ hardLines = [""];
466
+ visualLines = [];
467
+ lineNumbers = [];
468
+ totalHeight = 0;
469
+ constructor(engine) {
470
+ this.engine = engine;
471
+ }
472
+ // --------------------------------------------------------
473
+ // Text Management
474
+ // --------------------------------------------------------
475
+ /**
476
+ * Set the text content and compute all visual line info.
477
+ * Call this when the editor content changes.
478
+ */
479
+ setText(text) {
480
+ this.text = text;
481
+ this.hardLines = text.split("\n");
482
+ this.recompute();
483
+ }
484
+ /** Get current text */
485
+ getText() {
486
+ return this.text;
487
+ }
488
+ /**
489
+ * Force recomputation of layout (e.g., after width change).
490
+ * Call after engine.updateConfig({ maxWidth: newWidth }).
491
+ */
492
+ recompute() {
493
+ const { hardLines } = this;
494
+ const visualLines = [];
495
+ const lineNumbers = [];
496
+ const lineHeight = this.engine.getConfig().lineHeight;
497
+ let globalOffset = 0;
498
+ let y = 0;
499
+ for (let srcLine = 0; srcLine < hardLines.length; srcLine++) {
500
+ const lineText = hardLines[srcLine];
501
+ const layout3 = this.engine.computeLayoutWithLines(lineText);
502
+ const lines = layout3.lines ?? [];
503
+ const lineNumInfo = {
504
+ lineNumber: srcLine + 1,
505
+ y,
506
+ visualLineCount: lines.length || 1,
507
+ height: layout3.height || lineHeight
508
+ };
509
+ lineNumbers.push(lineNumInfo);
510
+ if (lines.length === 0) {
511
+ visualLines.push({
512
+ index: visualLines.length,
513
+ text: "",
514
+ width: 0,
515
+ y,
516
+ sourceLine: srcLine,
517
+ startOffset: globalOffset,
518
+ endOffset: globalOffset
519
+ });
520
+ y += lineHeight;
521
+ } else {
522
+ let lineStartOffset = globalOffset;
523
+ for (let vl = 0; vl < lines.length; vl++) {
524
+ const line = lines[vl];
525
+ const endOffset = lineStartOffset + line.text.length;
526
+ visualLines.push({
527
+ index: visualLines.length,
528
+ text: line.text,
529
+ width: line.width,
530
+ y,
531
+ sourceLine: srcLine,
532
+ startOffset: lineStartOffset,
533
+ endOffset
534
+ });
535
+ lineStartOffset = endOffset;
536
+ y += lineHeight;
537
+ }
538
+ }
539
+ globalOffset += lineText.length;
540
+ if (srcLine < hardLines.length - 1) {
541
+ globalOffset += 1;
542
+ }
543
+ }
544
+ this.visualLines = visualLines;
545
+ this.lineNumbers = lineNumbers;
546
+ this.totalHeight = y;
547
+ }
548
+ // --------------------------------------------------------
549
+ // Cursor Positioning
550
+ // --------------------------------------------------------
551
+ /**
552
+ * Convert a text offset to pixel coordinates.
553
+ * Returns the cursor position (x, y) for rendering a blinking cursor.
554
+ */
555
+ offsetToPosition(offset) {
556
+ const clamped = Math.max(0, Math.min(offset, this.text.length));
557
+ const lineHeight = this.engine.getConfig().lineHeight;
558
+ const vl = this.findVisualLineByOffset(clamped);
559
+ if (!vl) {
560
+ return { offset: clamped, visualLine: 0, x: 0, y: 0, lineHeight };
561
+ }
562
+ const localOffset = clamped - vl.startOffset;
563
+ const prefix = vl.text.slice(0, localOffset);
564
+ let x = 0;
565
+ if (prefix.length > 0) {
566
+ const prefixLayout = this.engine.computeLayout(prefix);
567
+ const prefixLines = this.engine.computeLayoutWithLines(prefix);
568
+ const lastLine = prefixLines.lines?.[prefixLines.lines.length - 1];
569
+ x = lastLine?.width ?? 0;
570
+ }
571
+ return {
572
+ offset: clamped,
573
+ visualLine: vl.index,
574
+ x,
575
+ y: vl.y,
576
+ lineHeight
577
+ };
578
+ }
579
+ /**
580
+ * Convert pixel coordinates (from a click event) to a text offset.
581
+ * This is the reverse of offsetToPosition().
582
+ */
583
+ xyToOffset(x, y) {
584
+ const lineHeight = this.engine.getConfig().lineHeight;
585
+ const visualLineIdx = Math.floor(y / lineHeight);
586
+ const vl = this.visualLines[Math.max(0, Math.min(visualLineIdx, this.visualLines.length - 1))];
587
+ if (!vl) return 0;
588
+ const lineText = vl.text;
589
+ if (lineText.length === 0) return vl.startOffset;
590
+ let lo = 0;
591
+ let hi = lineText.length;
592
+ while (lo < hi) {
593
+ const mid = lo + hi >>> 1;
594
+ const prefix = lineText.slice(0, mid + 1);
595
+ const layout3 = this.engine.computeLayoutWithLines(prefix);
596
+ const lastLine = layout3.lines?.[layout3.lines.length - 1];
597
+ const w = lastLine?.width ?? 0;
598
+ if (w <= x) {
599
+ lo = mid + 1;
600
+ } else {
601
+ hi = mid;
602
+ }
603
+ }
604
+ if (lo > 0 && lo <= lineText.length) {
605
+ const prefixPrev = lineText.slice(0, lo - 1);
606
+ const prefixCurr = lineText.slice(0, lo);
607
+ const layoutPrev = this.engine.computeLayoutWithLines(prefixPrev);
608
+ const layoutCurr = this.engine.computeLayoutWithLines(prefixCurr);
609
+ const wPrev = layoutPrev.lines?.[layoutPrev.lines.length - 1]?.width ?? 0;
610
+ const wCurr = layoutCurr.lines?.[layoutCurr.lines.length - 1]?.width ?? 0;
611
+ if (x - wPrev < wCurr - x) {
612
+ lo = lo - 1;
613
+ }
614
+ }
615
+ return vl.startOffset + Math.min(lo, lineText.length);
616
+ }
617
+ // --------------------------------------------------------
618
+ // Selection
619
+ // --------------------------------------------------------
620
+ /**
621
+ * Compute selection highlight rectangles for a text range.
622
+ * Returns one rect per visual line that intersects the selection.
623
+ */
624
+ getSelectionRects(start, end) {
625
+ if (start === end) return [];
626
+ const s = Math.min(start, end);
627
+ const e = Math.max(start, end);
628
+ const lineHeight = this.engine.getConfig().lineHeight;
629
+ const maxWidth = this.engine.getConfig().maxWidth;
630
+ const rects = [];
631
+ for (const vl of this.visualLines) {
632
+ if (vl.endOffset <= s) continue;
633
+ if (vl.startOffset >= e) break;
634
+ const selStart = Math.max(s, vl.startOffset);
635
+ const selEnd = Math.min(e, vl.endOffset);
636
+ const localStart = selStart - vl.startOffset;
637
+ const localEnd = selEnd - vl.startOffset;
638
+ let x1 = 0;
639
+ if (localStart > 0) {
640
+ const prefix = vl.text.slice(0, localStart);
641
+ const layout3 = this.engine.computeLayoutWithLines(prefix);
642
+ x1 = layout3.lines?.[layout3.lines.length - 1]?.width ?? 0;
643
+ }
644
+ let x2;
645
+ if (localEnd >= vl.text.length) {
646
+ x2 = vl.text.length > 0 ? vl.width : 0;
647
+ if (selEnd > vl.endOffset || selEnd === vl.endOffset && e > vl.endOffset) {
648
+ x2 = Math.max(x2, maxWidth);
649
+ }
650
+ } else {
651
+ const prefix = vl.text.slice(0, localEnd);
652
+ const layout3 = this.engine.computeLayoutWithLines(prefix);
653
+ x2 = layout3.lines?.[layout3.lines.length - 1]?.width ?? 0;
654
+ }
655
+ if (x2 > x1 || x1 === 0 && x2 === 0 && selStart < selEnd) {
656
+ rects.push({
657
+ x: x1,
658
+ y: vl.y,
659
+ width: Math.max(x2 - x1, 4),
660
+ // min 4px for visibility on empty lines
661
+ height: lineHeight
662
+ });
663
+ }
664
+ }
665
+ return rects;
666
+ }
667
+ // --------------------------------------------------------
668
+ // Line Information
669
+ // --------------------------------------------------------
670
+ /** Get all visual lines */
671
+ getVisualLines() {
672
+ return this.visualLines;
673
+ }
674
+ /** Get visual line count (total lines including wrapped) */
675
+ getVisualLineCount() {
676
+ return this.visualLines.length;
677
+ }
678
+ /** Get source line count (hard lines from \n) */
679
+ getSourceLineCount() {
680
+ return this.hardLines.length;
681
+ }
682
+ /**
683
+ * Get line number info for rendering line numbers.
684
+ * Each entry represents a source line with its Y position
685
+ * and how many visual lines it spans (for proper alignment).
686
+ */
687
+ getLineNumbers() {
688
+ return this.lineNumbers;
689
+ }
690
+ /** Get total content height in pixels */
691
+ getTotalHeight() {
692
+ return this.totalHeight;
693
+ }
694
+ /**
695
+ * Get the source line number for a given text offset.
696
+ * Returns 1-based line number.
697
+ */
698
+ getLineNumberAtOffset(offset) {
699
+ const vl = this.findVisualLineByOffset(offset);
700
+ return vl ? vl.sourceLine + 1 : 1;
701
+ }
702
+ /**
703
+ * Get the visual line at a given Y coordinate.
704
+ */
705
+ getVisualLineAtY(y) {
706
+ const lineHeight = this.engine.getConfig().lineHeight;
707
+ const idx = Math.floor(y / lineHeight);
708
+ return this.visualLines[idx] ?? null;
709
+ }
710
+ /**
711
+ * Get all visual lines for a given source line.
712
+ */
713
+ getVisualLinesForSourceLine(sourceLine) {
714
+ return this.visualLines.filter((vl) => vl.sourceLine === sourceLine);
715
+ }
716
+ // --------------------------------------------------------
717
+ // Word boundary utilities (for double-click word selection)
718
+ // --------------------------------------------------------
719
+ /**
720
+ * Find word boundaries at a given offset.
721
+ * Returns [start, end] offsets of the word.
722
+ */
723
+ getWordBoundary(offset) {
724
+ const text = this.text;
725
+ if (text.length === 0) return [0, 0];
726
+ const clamped = Math.max(0, Math.min(offset, text.length - 1));
727
+ const isWordChar = (ch2) => ch2 >= 48 && ch2 <= 57 || // 0-9
728
+ ch2 >= 65 && ch2 <= 90 || // A-Z
729
+ ch2 >= 97 && ch2 <= 122 || // a-z
730
+ ch2 === 95 || // _
731
+ ch2 > 127;
732
+ const ch = text.charCodeAt(clamped);
733
+ if (!isWordChar(ch)) {
734
+ return [clamped, clamped + 1];
735
+ }
736
+ let start = clamped;
737
+ while (start > 0 && isWordChar(text.charCodeAt(start - 1))) start--;
738
+ let end = clamped;
739
+ while (end < text.length && isWordChar(text.charCodeAt(end))) end++;
740
+ return [start, end];
741
+ }
742
+ // --------------------------------------------------------
743
+ // Internal helpers
744
+ // --------------------------------------------------------
745
+ /**
746
+ * Find the visual line containing a given text offset.
747
+ * Uses binary search for O(log n) performance.
748
+ */
749
+ findVisualLineByOffset(offset) {
750
+ const lines = this.visualLines;
751
+ if (lines.length === 0) return null;
752
+ let lo = 0;
753
+ let hi = lines.length - 1;
754
+ while (lo < hi) {
755
+ const mid = lo + hi + 1 >>> 1;
756
+ if (lines[mid].startOffset <= offset) {
757
+ lo = mid;
758
+ } else {
759
+ hi = mid - 1;
760
+ }
761
+ }
762
+ return lines[lo] ?? null;
763
+ }
764
+ };
765
+
766
+ // src/line-renderer.ts
767
+ var LineRenderer = class {
768
+ cursor;
769
+ container;
770
+ lineHeight;
771
+ activeClass;
772
+ virtual;
773
+ overscan;
774
+ activeLine = -1;
775
+ lastScrollTop = 0;
776
+ lastViewportHeight = 0;
777
+ renderedRange = [0, 0];
778
+ // DOM pool for virtual rendering
779
+ pool = [];
780
+ constructor(config) {
781
+ this.cursor = config.cursor;
782
+ this.container = config.container;
783
+ this.lineHeight = config.lineHeight;
784
+ this.activeClass = config.activeClass ?? "active-line";
785
+ this.virtual = config.virtual ?? true;
786
+ this.overscan = config.overscan ?? 20;
787
+ }
788
+ // --------------------------------------------------------
789
+ // Active Line
790
+ // --------------------------------------------------------
791
+ /**
792
+ * Set the active (cursor) line. Highlighted in the gutter.
793
+ * @param lineNumber - 1-based source line number
794
+ */
795
+ setActiveLine(lineNumber) {
796
+ if (lineNumber === this.activeLine) return;
797
+ if (this.activeLine > 0) {
798
+ const oldEl = this.container.querySelector(
799
+ `[data-line="${this.activeLine}"]`
800
+ );
801
+ if (oldEl) oldEl.classList.remove(this.activeClass);
802
+ }
803
+ this.activeLine = lineNumber;
804
+ const newEl = this.container.querySelector(
805
+ `[data-line="${lineNumber}"]`
806
+ );
807
+ if (newEl) newEl.classList.add(this.activeClass);
808
+ }
809
+ /** Get the current active line number (1-based) */
810
+ getActiveLine() {
811
+ return this.activeLine;
812
+ }
813
+ // --------------------------------------------------------
814
+ // Rendering
815
+ // --------------------------------------------------------
816
+ /**
817
+ * Full render: rebuild all line numbers.
818
+ * Use for initial render or after text content changes.
819
+ */
820
+ render() {
821
+ const lineNumbers = this.cursor.getLineNumbers();
822
+ const totalLines = lineNumbers.length;
823
+ if (this.virtual && totalLines > 1e3) {
824
+ this.renderVirtual();
825
+ return;
826
+ }
827
+ this.renderAll(lineNumbers);
828
+ }
829
+ /**
830
+ * Render all line numbers (non-virtual mode).
831
+ * Uses absolutely-positioned divs for correct alignment with soft-wrapped text.
832
+ */
833
+ renderAll(lineNumbers) {
834
+ const fragment = document.createDocumentFragment();
835
+ for (const info of lineNumbers) {
836
+ const div = document.createElement("div");
837
+ div.textContent = String(info.lineNumber);
838
+ div.setAttribute("data-line", String(info.lineNumber));
839
+ div.style.height = info.height + "px";
840
+ div.style.lineHeight = info.height + "px";
841
+ if (info.lineNumber === this.activeLine) {
842
+ div.classList.add(this.activeClass);
843
+ }
844
+ fragment.appendChild(div);
845
+ }
846
+ this.container.innerHTML = "";
847
+ this.container.appendChild(fragment);
848
+ }
849
+ /**
850
+ * Virtual rendering: only render line numbers visible in the viewport.
851
+ * Uses absolute positioning with a spacer for correct scroll height.
852
+ */
853
+ renderVirtual() {
854
+ const lineNumbers = this.cursor.getLineNumbers();
855
+ const totalHeight = this.cursor.getTotalHeight();
856
+ const scrollTop = this.lastScrollTop;
857
+ const viewportHeight = this.lastViewportHeight || 800;
858
+ const startY = Math.max(0, scrollTop - this.overscan * this.lineHeight);
859
+ const endY = scrollTop + viewportHeight + this.overscan * this.lineHeight;
860
+ let startIdx = 0;
861
+ let endIdx = lineNumbers.length;
862
+ {
863
+ let lo = 0, hi = lineNumbers.length - 1;
864
+ while (lo < hi) {
865
+ const mid = lo + hi + 1 >>> 1;
866
+ if (lineNumbers[mid].y <= startY) lo = mid;
867
+ else hi = mid - 1;
868
+ }
869
+ startIdx = lo;
870
+ }
871
+ {
872
+ let lo = startIdx, hi = lineNumbers.length - 1;
873
+ while (lo < hi) {
874
+ const mid = lo + hi + 1 >>> 1;
875
+ if (lineNumbers[mid].y <= endY) lo = mid;
876
+ else hi = mid - 1;
877
+ }
878
+ endIdx = lo + 1;
879
+ }
880
+ if (this.renderedRange[0] === startIdx && this.renderedRange[1] === endIdx) {
881
+ return;
882
+ }
883
+ this.renderedRange = [startIdx, endIdx];
884
+ this.container.innerHTML = "";
885
+ const spacer = document.createElement("div");
886
+ spacer.style.height = totalHeight + "px";
887
+ spacer.style.position = "relative";
888
+ const fragment = document.createDocumentFragment();
889
+ for (let i = startIdx; i < endIdx && i < lineNumbers.length; i++) {
890
+ const info = lineNumbers[i];
891
+ const div = this.getPooledDiv();
892
+ div.textContent = String(info.lineNumber);
893
+ div.setAttribute("data-line", String(info.lineNumber));
894
+ div.style.position = "absolute";
895
+ div.style.top = info.y + "px";
896
+ div.style.height = info.height + "px";
897
+ div.style.lineHeight = info.height + "px";
898
+ div.style.width = "100%";
899
+ div.style.textAlign = "right";
900
+ if (info.lineNumber === this.activeLine) {
901
+ div.classList.add(this.activeClass);
902
+ } else {
903
+ div.classList.remove(this.activeClass);
904
+ }
905
+ fragment.appendChild(div);
906
+ }
907
+ spacer.appendChild(fragment);
908
+ this.container.appendChild(spacer);
909
+ }
910
+ /**
911
+ * Update scroll position for virtual rendering.
912
+ * Call from the editor's scroll event handler.
913
+ */
914
+ updateScroll(scrollTop, viewportHeight) {
915
+ this.lastScrollTop = scrollTop;
916
+ this.lastViewportHeight = viewportHeight;
917
+ if (this.virtual && this.cursor.getSourceLineCount() > 1e3) {
918
+ this.renderVirtual();
919
+ }
920
+ }
921
+ /**
922
+ * Update after text changes. Re-renders line numbers.
923
+ */
924
+ update() {
925
+ this.render();
926
+ }
927
+ // --------------------------------------------------------
928
+ // Auto-wrap information
929
+ // --------------------------------------------------------
930
+ /**
931
+ * Get the number of visual lines for each source line.
932
+ * Useful for understanding how text wraps without DOM measurement.
933
+ *
934
+ * @returns Array where index = source line (0-based), value = visual line count
935
+ */
936
+ getWrapInfo() {
937
+ const lineNumbers = this.cursor.getLineNumbers();
938
+ return lineNumbers.map((ln) => ln.visualLineCount);
939
+ }
940
+ /**
941
+ * Check if a source line is soft-wrapped (spans multiple visual lines).
942
+ */
943
+ isLineWrapped(sourceLine) {
944
+ const lineNumbers = this.cursor.getLineNumbers();
945
+ const info = lineNumbers[sourceLine];
946
+ return info ? info.visualLineCount > 1 : false;
947
+ }
948
+ /**
949
+ * Get total visual line count (including wrapped lines).
950
+ */
951
+ getTotalVisualLines() {
952
+ return this.cursor.getVisualLineCount();
953
+ }
954
+ // --------------------------------------------------------
955
+ // Cleanup
956
+ // --------------------------------------------------------
957
+ /**
958
+ * Dispose resources.
959
+ */
960
+ dispose() {
961
+ this.container.innerHTML = "";
962
+ this.pool = [];
963
+ }
964
+ // --------------------------------------------------------
965
+ // Internal
966
+ // --------------------------------------------------------
967
+ getPooledDiv() {
968
+ if (this.pool.length > 0) {
969
+ return this.pool.pop();
970
+ }
971
+ return document.createElement("div");
972
+ }
973
+ };
974
+
975
+ // src/index.ts
976
+ var LRUCache = class {
977
+ map = /* @__PURE__ */ new Map();
978
+ maxSize;
979
+ constructor(maxSize = 512) {
980
+ this.maxSize = maxSize;
981
+ }
982
+ get(key) {
983
+ const entry = this.map.get(key);
984
+ if (!entry) return void 0;
985
+ this.map.delete(key);
986
+ this.map.set(key, entry);
987
+ return entry.value;
988
+ }
989
+ set(key, value) {
990
+ if (this.map.has(key)) {
991
+ this.map.delete(key);
992
+ } else if (this.map.size >= this.maxSize) {
993
+ const first = this.map.keys().next().value;
994
+ if (first !== void 0) this.map.delete(first);
995
+ }
996
+ this.map.set(key, { value });
997
+ }
998
+ has(key) {
999
+ return this.map.has(key);
1000
+ }
1001
+ delete(key) {
1002
+ return this.map.delete(key);
1003
+ }
1004
+ clear() {
1005
+ this.map.clear();
1006
+ }
1007
+ get size() {
1008
+ return this.map.size;
1009
+ }
1010
+ };
1011
+ var pretextBackend = {
1012
+ prepare: prepare2,
1013
+ prepareWithSegments: prepareWithSegments2,
1014
+ layout: layout2,
1015
+ layoutWithLines: layoutWithLines2,
1016
+ clearCache: pretextClearCache2,
1017
+ setLocale: pretextSetLocale2
1018
+ };
1019
+ function createFallbackBackend(avgCharWidth = 8) {
1020
+ return {
1021
+ prepare(text, _font, _options) {
1022
+ return { __fallback: true, text };
1023
+ },
1024
+ prepareWithSegments(text, _font, _options) {
1025
+ return { __fallback: true, text, segments: text.split(/(\s+)/).filter(Boolean) };
1026
+ },
1027
+ layout(prepared, maxWidth, lineHeight) {
1028
+ const text = prepared.text ?? "";
1029
+ const lines = estimateLines(text, maxWidth, avgCharWidth);
1030
+ return { height: lines * lineHeight, lineCount: lines };
1031
+ },
1032
+ layoutWithLines(prepared, maxWidth, lineHeight) {
1033
+ const text = prepared.text ?? "";
1034
+ const wrappedLines = wrapText(text, maxWidth, avgCharWidth);
1035
+ const lines = wrappedLines.map((line, i) => ({
1036
+ text: line,
1037
+ width: line.length * avgCharWidth,
1038
+ start: { segmentIndex: 0, graphemeIndex: 0 },
1039
+ end: { segmentIndex: 0, graphemeIndex: 0 }
1040
+ }));
1041
+ return {
1042
+ height: lines.length * lineHeight,
1043
+ lineCount: lines.length,
1044
+ lines
1045
+ };
1046
+ },
1047
+ clearCache() {
1048
+ },
1049
+ setLocale() {
1050
+ }
1051
+ };
1052
+ }
1053
+ function estimateLines(text, maxWidth, avgCharWidth) {
1054
+ if (text.length === 0) return 1;
1055
+ const hardLines = text.split("\n");
1056
+ let total = 0;
1057
+ const charsPerLine = Math.max(1, Math.floor(maxWidth / avgCharWidth));
1058
+ for (const line of hardLines) {
1059
+ total += Math.max(1, Math.ceil(line.length / charsPerLine));
1060
+ }
1061
+ return total;
1062
+ }
1063
+ function wrapText(text, maxWidth, avgCharWidth) {
1064
+ const charsPerLine = Math.max(1, Math.floor(maxWidth / avgCharWidth));
1065
+ const result = [];
1066
+ const hardLines = text.split("\n");
1067
+ for (const line of hardLines) {
1068
+ if (line.length <= charsPerLine) {
1069
+ result.push(line);
1070
+ } else {
1071
+ for (let i = 0; i < line.length; i += charsPerLine) {
1072
+ result.push(line.slice(i, i + charsPerLine));
1073
+ }
1074
+ }
1075
+ }
1076
+ return result.length > 0 ? result : [""];
1077
+ }
1078
+ var LayoutEngine = class {
1079
+ config;
1080
+ backend;
1081
+ preparedCache;
1082
+ preparedSegCache;
1083
+ constructor(config, backend) {
1084
+ this.config = {
1085
+ viewportBuffer: 2,
1086
+ whiteSpace: "normal",
1087
+ ...config
1088
+ };
1089
+ this.backend = backend ?? pretextBackend;
1090
+ this.preparedCache = new LRUCache(512);
1091
+ this.preparedSegCache = new LRUCache(256);
1092
+ }
1093
+ // --------------------------------------------------------
1094
+ // Configuration
1095
+ // --------------------------------------------------------
1096
+ /** Update layout configuration. Invalidates cache if font changes. */
1097
+ updateConfig(config) {
1098
+ const fontChanged = config.font && config.font !== this.config.font;
1099
+ this.config = { ...this.config, ...config };
1100
+ if (fontChanged) {
1101
+ this.clearAllCaches();
1102
+ }
1103
+ }
1104
+ /** Get current configuration (readonly). */
1105
+ getConfig() {
1106
+ return this.config;
1107
+ }
1108
+ /** Replace the measurement backend (e.g. for testing). */
1109
+ setBackend(backend) {
1110
+ this.backend = backend;
1111
+ this.clearAllCaches();
1112
+ }
1113
+ /** Set locale for text segmentation. */
1114
+ setLocale(locale) {
1115
+ this.backend.setLocale(locale);
1116
+ this.clearAllCaches();
1117
+ }
1118
+ // --------------------------------------------------------
1119
+ // Core Layout API
1120
+ // --------------------------------------------------------
1121
+ /**
1122
+ * Compute height and line count for a text block.
1123
+ * Uses pretext prepare() + layout() pipeline.
1124
+ * The PreparedText is cached by (text, font) key.
1125
+ */
1126
+ computeLayout(text) {
1127
+ const prepared = this.getPrepared(text);
1128
+ const result = this.backend.layout(prepared, this.config.maxWidth, this.config.lineHeight);
1129
+ return {
1130
+ height: result.height,
1131
+ lineCount: result.lineCount
1132
+ };
1133
+ }
1134
+ /**
1135
+ * Compute layout for code blocks using code font.
1136
+ * Falls back to main font if codeFont is not configured.
1137
+ */
1138
+ computeCodeLayout(text) {
1139
+ const font = this.config.codeFont ?? this.config.font;
1140
+ const lineHeight = this.config.codeLineHeight ?? this.config.lineHeight;
1141
+ const key = `${font}|pre-wrap|${text}`;
1142
+ let prepared = this.preparedCache.get(key);
1143
+ if (!prepared) {
1144
+ prepared = this.backend.prepare(text, font, { whiteSpace: "pre-wrap" });
1145
+ this.preparedCache.set(key, prepared);
1146
+ }
1147
+ const result = this.backend.layout(prepared, this.config.maxWidth, lineHeight);
1148
+ return {
1149
+ height: result.height,
1150
+ lineCount: result.lineCount
1151
+ };
1152
+ }
1153
+ /**
1154
+ * Compute layout with individual line information.
1155
+ * Uses prepareWithSegments() + layoutWithLines().
1156
+ */
1157
+ computeLayoutWithLines(text) {
1158
+ const prepared = this.getPreparedWithSegments(text);
1159
+ const result = this.backend.layoutWithLines(
1160
+ prepared,
1161
+ this.config.maxWidth,
1162
+ this.config.lineHeight
1163
+ );
1164
+ const lines = result.lines.map((line, i) => ({
1165
+ text: line.text,
1166
+ width: line.width,
1167
+ y: i * this.config.lineHeight,
1168
+ sourceIndex: i
1169
+ }));
1170
+ return {
1171
+ height: result.height,
1172
+ lineCount: result.lineCount,
1173
+ lines
1174
+ };
1175
+ }
1176
+ /**
1177
+ * Compute layout for only the visible viewport.
1178
+ * Key performance optimization: only measure and position visible lines.
1179
+ * Includes configurable buffer (default 2x viewport) for smooth scrolling.
1180
+ */
1181
+ computeViewportLayout(text, scrollTop, viewportHeight) {
1182
+ const allLayout = this.computeLayoutWithLines(text);
1183
+ const allLines = allLayout.lines ?? [];
1184
+ const { lineHeight } = this.config;
1185
+ const buffer = (this.config.viewportBuffer ?? 2) * viewportHeight;
1186
+ const bufferedTop = Math.max(0, scrollTop - buffer);
1187
+ const bufferedBottom = scrollTop + viewportHeight + buffer;
1188
+ const startIndex = Math.max(0, Math.floor(bufferedTop / lineHeight));
1189
+ const endIndex = Math.min(allLines.length, Math.ceil(bufferedBottom / lineHeight));
1190
+ const visibleLines = allLines.slice(startIndex, endIndex);
1191
+ return {
1192
+ visibleLines,
1193
+ totalHeight: allLayout.height,
1194
+ startY: startIndex * lineHeight,
1195
+ startIndex,
1196
+ endIndex
1197
+ };
1198
+ }
1199
+ // --------------------------------------------------------
1200
+ // Multi-paragraph Layout
1201
+ // --------------------------------------------------------
1202
+ /**
1203
+ * Compute layout for an array of text blocks (paragraphs).
1204
+ * Returns cumulative heights for virtual scrolling.
1205
+ */
1206
+ computeDocumentLayout(paragraphs) {
1207
+ const offsets = [];
1208
+ const heights = [];
1209
+ let cumHeight = 0;
1210
+ for (const text of paragraphs) {
1211
+ offsets.push(cumHeight);
1212
+ const result = this.computeLayout(text);
1213
+ heights.push(result.height);
1214
+ cumHeight += result.height;
1215
+ }
1216
+ return {
1217
+ totalHeight: cumHeight,
1218
+ paragraphOffsets: offsets,
1219
+ paragraphHeights: heights
1220
+ };
1221
+ }
1222
+ /**
1223
+ * Find which paragraph and line is at a given scrollTop position.
1224
+ */
1225
+ hitTest(paragraphs, scrollTop) {
1226
+ let cumHeight = 0;
1227
+ for (let i = 0; i < paragraphs.length; i++) {
1228
+ const result = this.computeLayout(paragraphs[i]);
1229
+ if (cumHeight + result.height > scrollTop) {
1230
+ const localY = scrollTop - cumHeight;
1231
+ const lineIndex = Math.floor(localY / this.config.lineHeight);
1232
+ return { paragraphIndex: i, lineIndex };
1233
+ }
1234
+ cumHeight += result.height;
1235
+ }
1236
+ return null;
1237
+ }
1238
+ // --------------------------------------------------------
1239
+ // Incremental Document Layout
1240
+ // --------------------------------------------------------
1241
+ _lastParagraphs = [];
1242
+ _lastHeights = [];
1243
+ _lastTotalHeight = 0;
1244
+ /**
1245
+ * Incremental document layout — reuses cached heights for unchanged paragraphs.
1246
+ * Only recomputes layout for paragraphs that changed since the last call.
1247
+ * Use this for real-time editing instead of computeDocumentLayout().
1248
+ *
1249
+ * Returns the same structure as computeDocumentLayout().
1250
+ */
1251
+ updateDocumentLayout(paragraphs) {
1252
+ const prev = this._lastParagraphs;
1253
+ const prevHeights = this._lastHeights;
1254
+ const len = paragraphs.length;
1255
+ const heights = new Array(len);
1256
+ const offsets = new Array(len);
1257
+ const changedIndices = [];
1258
+ let cumHeight = 0;
1259
+ for (let i = 0; i < len; i++) {
1260
+ const text = paragraphs[i];
1261
+ if (i < prev.length && prev[i] === text) {
1262
+ heights[i] = prevHeights[i];
1263
+ } else {
1264
+ heights[i] = this.computeLayout(text).height;
1265
+ changedIndices.push(i);
1266
+ }
1267
+ offsets[i] = cumHeight;
1268
+ cumHeight += heights[i];
1269
+ }
1270
+ this._lastParagraphs = paragraphs;
1271
+ this._lastHeights = heights;
1272
+ this._lastTotalHeight = cumHeight;
1273
+ return {
1274
+ totalHeight: cumHeight,
1275
+ paragraphOffsets: offsets,
1276
+ paragraphHeights: heights,
1277
+ changedIndices
1278
+ };
1279
+ }
1280
+ /**
1281
+ * Get the cached total height from the last updateDocumentLayout() call.
1282
+ * O(1) — no recomputation.
1283
+ */
1284
+ getCachedTotalHeight() {
1285
+ return this._lastTotalHeight;
1286
+ }
1287
+ // --------------------------------------------------------
1288
+ // Cache Management
1289
+ // --------------------------------------------------------
1290
+ /** Invalidate cache for a specific text block. */
1291
+ invalidateCache(text) {
1292
+ if (text) {
1293
+ const key = this.cacheKey(text);
1294
+ this.preparedCache.delete(key);
1295
+ this.preparedSegCache.delete(key);
1296
+ } else {
1297
+ this.clearAllCaches();
1298
+ }
1299
+ }
1300
+ /** Clear all caches including pretext internal cache. */
1301
+ clearAllCaches() {
1302
+ this.preparedCache.clear();
1303
+ this.preparedSegCache.clear();
1304
+ this.backend.clearCache();
1305
+ }
1306
+ /** Get current cache statistics. */
1307
+ getCacheStats() {
1308
+ return {
1309
+ preparedSize: this.preparedCache.size,
1310
+ segmentSize: this.preparedSegCache.size
1311
+ };
1312
+ }
1313
+ // --------------------------------------------------------
1314
+ // Internal Helpers
1315
+ // --------------------------------------------------------
1316
+ cacheKey(text) {
1317
+ return `${this.config.font}|${this.config.whiteSpace ?? "normal"}|${text}`;
1318
+ }
1319
+ getPrepared(text) {
1320
+ const key = this.cacheKey(text);
1321
+ let prepared = this.preparedCache.get(key);
1322
+ if (!prepared) {
1323
+ const opts = this.config.whiteSpace === "pre-wrap" ? { whiteSpace: "pre-wrap" } : void 0;
1324
+ prepared = this.backend.prepare(text, this.config.font, opts);
1325
+ this.preparedCache.set(key, prepared);
1326
+ }
1327
+ return prepared;
1328
+ }
1329
+ getPreparedWithSegments(text) {
1330
+ const key = this.cacheKey(text);
1331
+ let prepared = this.preparedSegCache.get(key);
1332
+ if (!prepared) {
1333
+ const opts = this.config.whiteSpace === "pre-wrap" ? { whiteSpace: "pre-wrap" } : void 0;
1334
+ prepared = this.backend.prepareWithSegments(text, this.config.font, opts);
1335
+ this.preparedSegCache.set(key, prepared);
1336
+ }
1337
+ return prepared;
1338
+ }
1339
+ };
1340
+ export {
1341
+ CursorEngine,
1342
+ LayoutEngine,
1343
+ LineRenderer,
1344
+ VirtualList,
1345
+ createFallbackBackend,
1346
+ createWorkerBackend
1347
+ };
1348
+ //# sourceMappingURL=index.js.map