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