chat-layout 1.1.0 → 1.1.2

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/README.md CHANGED
@@ -108,6 +108,7 @@ Notes:
108
108
  ## Migration notes
109
109
 
110
110
  - Use `memoRenderItemBy(keyOf, renderItem)` when list items are primitives.
111
+ - `memoRenderItemBy()` now uses a bounded LRU cache by default; pass `{ maxEntries: Infinity }` to keep the old unbounded behavior explicitly.
111
112
  - `FlexItem` exposes `grow`, `shrink`, and `alignSelf`; `basis` is no longer public.
112
113
  - `MultilineText` now uses `align` / `physicalAlign` instead of `alignment`.
113
114
  - `ListState.position` uses `undefined` for the renderer default anchor.
package/index.d.mts CHANGED
@@ -518,7 +518,9 @@ declare function memoRenderItem<C extends CanvasRenderingContext2D, T extends ob
518
518
  /**
519
519
  * Memoizes `renderItem` by a caller-provided cache key.
520
520
  */
521
- declare function memoRenderItemBy<C extends CanvasRenderingContext2D, T, K>(keyOf: (item: T) => K, renderItem: (item: T) => Node<C>): ((item: T) => Node<C>) & {
521
+ declare function memoRenderItemBy<C extends CanvasRenderingContext2D, T, K>(keyOf: (item: T) => K, renderItem: (item: T) => Node<C>, options?: {
522
+ maxEntries?: number;
523
+ }): ((item: T) => Node<C>) & {
522
524
  reset: (item: T) => boolean;
523
525
  resetKey: (key: K) => boolean;
524
526
  };
package/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { layoutNextLine, layoutWithLines, measureLineStats, measureNaturalWidth, prepareWithSegments } from "@chenglou/pretext";
2
- import { layoutNextRichInlineLineRange, materializeRichInlineLineRange, measureRichInlineStats, prepareRichInline, walkRichInlineLineRanges } from "@chenglou/pretext/rich-inline";
2
+ import { layoutNextRichInlineLineRange, materializeRichInlineLineRange, measureRichInlineStats, prepareRichInline } from "@chenglou/pretext/rich-inline";
3
3
  //#region src/internal/node-registry.ts
4
4
  const registry = /* @__PURE__ */ new WeakMap();
5
5
  const revisions = /* @__PURE__ */ new WeakMap();
@@ -818,17 +818,15 @@ var Place = class extends Wrapper {
818
818
  };
819
819
  const INTRINSIC_MAX_WIDTH = Number.POSITIVE_INFINITY;
820
820
  const MIN_CONTENT_WIDTH_EPSILON = .001;
821
- const fontShiftCache = /* @__PURE__ */ new Map();
822
- const ellipsisWidthCache = /* @__PURE__ */ new Map();
823
821
  let sharedGraphemeSegmenter;
824
- function readLruValue(cache, key) {
822
+ function readLruValue$1(cache, key) {
825
823
  const cached = cache.get(key);
826
824
  if (cached == null) return;
827
825
  cache.delete(key);
828
826
  cache.set(key, cached);
829
827
  return cached;
830
828
  }
831
- function writeLruValue(cache, key, value, capacity) {
829
+ function writeLruValue$1(cache, key, value, capacity) {
832
830
  if (cache.has(key)) cache.delete(key);
833
831
  else if (cache.size >= capacity) {
834
832
  const firstKey = cache.keys().next().value;
@@ -838,17 +836,11 @@ function writeLruValue(cache, key, value, capacity) {
838
836
  return value;
839
837
  }
840
838
  function measureFontShift(ctx) {
841
- const font = ctx.graphics.font;
842
- const cached = readLruValue(fontShiftCache, font);
843
- if (cached != null) return cached;
844
839
  const { fontBoundingBoxAscent: ascent = 0, fontBoundingBoxDescent: descent = 0 } = ctx.graphics.measureText("M");
845
- return writeLruValue(fontShiftCache, font, ascent - descent, 64);
840
+ return ascent - descent;
846
841
  }
847
842
  function measureEllipsisWidth(ctx) {
848
- const font = ctx.graphics.font;
849
- const cached = readLruValue(ellipsisWidthCache, font);
850
- if (cached != null) return cached;
851
- return writeLruValue(ellipsisWidthCache, font, ctx.graphics.measureText("…").width, 64);
843
+ return ctx.graphics.measureText("…").width;
852
844
  }
853
845
  function getGraphemeSegmenter() {
854
846
  if (sharedGraphemeSegmenter !== void 0) return sharedGraphemeSegmenter;
@@ -936,15 +928,14 @@ const LINE_START_CURSOR$1 = {
936
928
  graphemeIndex: 0
937
929
  };
938
930
  const preparedTextCache = /* @__PURE__ */ new Map();
939
- const preparedUnitCache = /* @__PURE__ */ new WeakMap();
940
931
  function getPreparedTextCacheKey(text, font, whiteSpace, wordBreak) {
941
932
  return `${font}\u0000${whiteSpace}\u0000${wordBreak}\u0000${text}`;
942
933
  }
943
934
  function readPreparedText(text, font, whiteSpace, wordBreak) {
944
935
  const key = getPreparedTextCacheKey(text, font, whiteSpace, wordBreak);
945
- const cached = readLruValue(preparedTextCache, key);
936
+ const cached = readLruValue$1(preparedTextCache, key);
946
937
  if (cached != null) return cached;
947
- return writeLruValue(preparedTextCache, key, prepareWithSegments(text, font, {
938
+ return writeLruValue$1(preparedTextCache, key, prepareWithSegments(text, font, {
948
939
  whiteSpace,
949
940
  wordBreak
950
941
  }), 512);
@@ -973,8 +964,6 @@ function measurePreparedMinContentWidth(prepared, overflowWrap = "break-word") {
973
964
  return maxWidth > 0 ? maxWidth : maxAnyWidth;
974
965
  }
975
966
  function getPreparedUnits(prepared) {
976
- const cached = preparedUnitCache.get(prepared);
977
- if (cached != null) return cached;
978
967
  const units = [];
979
968
  for (let index = 0; index < prepared.segments.length; index += 1) {
980
969
  const segment = prepared.segments[index] ?? "";
@@ -995,7 +984,6 @@ function getPreparedUnits(prepared) {
995
984
  width: segmentWidth
996
985
  });
997
986
  }
998
- preparedUnitCache.set(prepared, units);
999
987
  return units;
1000
988
  }
1001
989
  function joinUnitText(units, start, end) {
@@ -1231,6 +1219,8 @@ function layoutTextWithOverflow(ctx, text, maxWidth, options = {}) {
1231
1219
  //#endregion
1232
1220
  //#region src/text/rich.ts
1233
1221
  const RICH_PREPARED_CACHE_CAPACITY = 256;
1222
+ const LEADING_COLLAPSIBLE_BOUNDARY_RE = /^[ \t\n\f\r]+/;
1223
+ const TRAILING_COLLAPSIBLE_BOUNDARY_RE = /[ \t\n\f\r]+$/;
1234
1224
  const richPreparedCache = /* @__PURE__ */ new Map();
1235
1225
  function withFont(ctx, font, cb) {
1236
1226
  const previousFont = ctx.graphics.font;
@@ -1246,20 +1236,87 @@ function getRichPreparedCacheKey(spans, defaultFont) {
1246
1236
  }
1247
1237
  function readRichPrepared(spans, defaultFont) {
1248
1238
  const key = getRichPreparedCacheKey(spans, defaultFont);
1249
- const cached = readLruValue(richPreparedCache, key);
1239
+ const cached = readLruValue$1(richPreparedCache, key);
1250
1240
  if (cached != null) return cached;
1251
- return writeLruValue(richPreparedCache, key, prepareRichInline(spans.map((span) => ({
1241
+ const items = spans.map((span) => ({
1252
1242
  text: span.text,
1253
1243
  font: span.font ?? defaultFont,
1254
1244
  break: span.break,
1255
1245
  extraWidth: span.extraWidth
1256
- }))), RICH_PREPARED_CACHE_CAPACITY);
1246
+ }));
1247
+ const preparedItemIndexBySourceItemIndex = buildPreparedItemIndexBySourceItemIndex(spans);
1248
+ return writeLruValue$1(richPreparedCache, key, {
1249
+ prepared: prepareRichInline(items),
1250
+ preparedItemIndexBySourceItemIndex
1251
+ }, RICH_PREPARED_CACHE_CAPACITY);
1252
+ }
1253
+ function trimRichInlineBoundaryWhitespace(text) {
1254
+ return text.replace(LEADING_COLLAPSIBLE_BOUNDARY_RE, "").replace(TRAILING_COLLAPSIBLE_BOUNDARY_RE, "");
1255
+ }
1256
+ function buildPreparedItemIndexBySourceItemIndex(spans) {
1257
+ const preparedItemIndexBySourceItemIndex = Array.from({ length: spans.length });
1258
+ let preparedItemIndex = 0;
1259
+ for (let index = 0; index < spans.length; index += 1) {
1260
+ if (trimRichInlineBoundaryWhitespace(spans[index].text).length === 0) continue;
1261
+ preparedItemIndexBySourceItemIndex[index] = preparedItemIndex;
1262
+ preparedItemIndex += 1;
1263
+ }
1264
+ return preparedItemIndexBySourceItemIndex;
1265
+ }
1266
+ function getRichFragmentStartCursor(prepared, fragment) {
1267
+ const itemIndex = prepared.preparedItemIndexBySourceItemIndex[fragment.itemIndex];
1268
+ if (itemIndex == null) return null;
1269
+ return {
1270
+ itemIndex,
1271
+ segmentIndex: fragment.start.segmentIndex,
1272
+ graphemeIndex: fragment.start.graphemeIndex
1273
+ };
1274
+ }
1275
+ function splitOverflowingRichLineRange(prepared, lineRange, maxWidth) {
1276
+ if (lineRange.width <= maxWidth || lineRange.fragments.length <= 1) return lineRange;
1277
+ const trailingFragment = lineRange.fragments[lineRange.fragments.length - 1];
1278
+ const splitCursor = getRichFragmentStartCursor(prepared, trailingFragment);
1279
+ if (splitCursor == null) return lineRange;
1280
+ const fragments = lineRange.fragments.slice(0, -1);
1281
+ return {
1282
+ fragments,
1283
+ width: fragments.reduce((total, fragment) => total + fragment.gapBefore + fragment.occupiedWidth, 0),
1284
+ end: splitCursor
1285
+ };
1286
+ }
1287
+ function layoutNextConstrainedRichInlineLineRange(prepared, maxWidth, start) {
1288
+ const lineRange = layoutNextRichInlineLineRange(prepared.prepared, maxWidth, start);
1289
+ if (lineRange == null) return null;
1290
+ return splitOverflowingRichLineRange(prepared, lineRange, maxWidth);
1291
+ }
1292
+ function walkConstrainedRichInlineLineRanges(prepared, maxWidth, onLine) {
1293
+ let lineCount = 0;
1294
+ let cursor;
1295
+ while (true) {
1296
+ const lineRange = layoutNextConstrainedRichInlineLineRange(prepared, maxWidth, cursor);
1297
+ if (lineRange == null) return lineCount;
1298
+ onLine(lineRange);
1299
+ lineCount += 1;
1300
+ cursor = lineRange.end;
1301
+ }
1302
+ }
1303
+ function measureConstrainedRichInlineStats(prepared, maxWidth) {
1304
+ let lineCount = 0;
1305
+ let maxLineWidth = 0;
1306
+ walkConstrainedRichInlineLineRanges(prepared, maxWidth, (lineRange) => {
1307
+ lineCount += 1;
1308
+ if (lineRange.width > maxLineWidth) maxLineWidth = lineRange.width;
1309
+ });
1310
+ return {
1311
+ lineCount,
1312
+ maxLineWidth
1313
+ };
1257
1314
  }
1258
1315
  function measureRichFragmentShift(ctx, font) {
1259
1316
  return withFont(ctx, font, () => measureFontShift(ctx));
1260
1317
  }
1261
1318
  function materializeRichLine(ctx, spans, defaultFont, defaultColor, lineRange, overflowed) {
1262
- const richLine = materializeRichInlineLineRange(readRichPrepared(spans, defaultFont), lineRange);
1319
+ const richLine = materializeRichInlineLineRange(readRichPrepared(spans, defaultFont).prepared, lineRange);
1263
1320
  const fragments = richLine.fragments.map((fragment) => {
1264
1321
  const span = spans[fragment.itemIndex];
1265
1322
  const font = span?.font ?? defaultFont;
@@ -1412,7 +1469,7 @@ function layoutRichFirstLineIntrinsic(ctx, spans, defaultFont, defaultColor) {
1412
1469
  fragments: [],
1413
1470
  overflowed: false
1414
1471
  };
1415
- const lineRange = layoutNextRichInlineLineRange(readRichPrepared(spans, defaultFont), INTRINSIC_MAX_WIDTH);
1472
+ const lineRange = layoutNextRichInlineLineRange(readRichPrepared(spans, defaultFont).prepared, INTRINSIC_MAX_WIDTH);
1416
1473
  if (lineRange == null) return {
1417
1474
  width: 0,
1418
1475
  fragments: [],
@@ -1427,7 +1484,7 @@ function layoutRichFirstLine(ctx, spans, maxWidth, defaultFont, defaultColor) {
1427
1484
  fragments: [],
1428
1485
  overflowed: false
1429
1486
  };
1430
- const lineRange = layoutNextRichInlineLineRange(readRichPrepared(spans, defaultFont), clampedMaxWidth);
1487
+ const lineRange = layoutNextConstrainedRichInlineLineRange(readRichPrepared(spans, defaultFont), clampedMaxWidth);
1431
1488
  if (lineRange == null) return {
1432
1489
  width: 0,
1433
1490
  fragments: [],
@@ -1454,7 +1511,7 @@ function measureRichText(_ctx, spans, maxWidth, defaultFont) {
1454
1511
  width: 0,
1455
1512
  lineCount: 0
1456
1513
  };
1457
- const { maxLineWidth: width, lineCount } = measureRichInlineStats(readRichPrepared(spans, defaultFont), maxWidth);
1514
+ const { maxLineWidth: width, lineCount } = measureConstrainedRichInlineStats(readRichPrepared(spans, defaultFont), maxWidth);
1458
1515
  return {
1459
1516
  width,
1460
1517
  lineCount
@@ -1465,7 +1522,7 @@ function measureRichTextIntrinsic(_ctx, spans, defaultFont) {
1465
1522
  width: 0,
1466
1523
  lineCount: 0
1467
1524
  };
1468
- const { maxLineWidth: width, lineCount } = measureRichInlineStats(readRichPrepared(spans, defaultFont), INTRINSIC_MAX_WIDTH);
1525
+ const { maxLineWidth: width, lineCount } = measureRichInlineStats(readRichPrepared(spans, defaultFont).prepared, INTRINSIC_MAX_WIDTH);
1469
1526
  return {
1470
1527
  width,
1471
1528
  lineCount
@@ -1487,7 +1544,7 @@ function measureRichTextMinContent(_ctx, spans, defaultFont, overflowWrap = "bre
1487
1544
  width: 0,
1488
1545
  lineCount: 0
1489
1546
  };
1490
- const { lineCount } = measureRichInlineStats(readRichPrepared(spans, defaultFont), Math.max(maxWidth, MIN_CONTENT_WIDTH_EPSILON));
1547
+ const { lineCount } = measureConstrainedRichInlineStats(readRichPrepared(spans, defaultFont), Math.max(maxWidth, MIN_CONTENT_WIDTH_EPSILON));
1491
1548
  return {
1492
1549
  width: maxWidth,
1493
1550
  lineCount
@@ -1501,7 +1558,7 @@ function layoutRichText(ctx, spans, maxWidth, defaultFont, defaultColor) {
1501
1558
  };
1502
1559
  const prepared = readRichPrepared(spans, defaultFont);
1503
1560
  const lineRanges = [];
1504
- walkRichInlineLineRanges(prepared, maxWidth, (lineRange) => lineRanges.push(lineRange));
1561
+ walkConstrainedRichInlineLineRanges(prepared, maxWidth, (lineRange) => lineRanges.push(lineRange));
1505
1562
  if (lineRanges.length === 0) return {
1506
1563
  width: 0,
1507
1564
  lines: [],
@@ -2086,27 +2143,56 @@ var DebugRenderer = class extends BaseRenderer {
2086
2143
  }
2087
2144
  };
2088
2145
  //#endregion
2146
+ //#region src/renderer/weak-listeners.ts
2147
+ function pruneWeakListenerMap(listeners) {
2148
+ for (const [token, listener] of listeners) if (listener.ownerRef.deref() == null) listeners.delete(token);
2149
+ }
2150
+ function emitWeakListeners(listeners, event) {
2151
+ for (const [token, listener] of [...listeners]) {
2152
+ const owner = listener.ownerRef.deref();
2153
+ if (owner == null) {
2154
+ listeners.delete(token);
2155
+ continue;
2156
+ }
2157
+ listener.notify(owner, event);
2158
+ }
2159
+ }
2160
+ //#endregion
2089
2161
  //#region src/renderer/list-state.ts
2090
2162
  const listStateListeners = /* @__PURE__ */ new WeakMap();
2163
+ const listStateListenerRegistry = typeof FinalizationRegistry === "function" ? new FinalizationRegistry(({ listRef, token }) => {
2164
+ const list = listRef.deref();
2165
+ if (list == null) return;
2166
+ deleteListStateListener(list, token);
2167
+ }) : null;
2168
+ function deleteListStateListener(list, token) {
2169
+ const listeners = listStateListeners.get(list);
2170
+ if (listeners == null) return;
2171
+ listeners.delete(token);
2172
+ if (listeners.size === 0) listStateListeners.delete(list);
2173
+ }
2091
2174
  function emitListStateChange(list, change) {
2092
2175
  const listeners = listStateListeners.get(list);
2093
- if (listeners == null || listeners.size === 0) return;
2094
- for (const listener of [...listeners]) listener(change);
2176
+ if (listeners == null) return;
2177
+ emitWeakListeners(listeners, change);
2178
+ if (listeners.size === 0) listStateListeners.delete(list);
2095
2179
  }
2096
- function subscribeListState(list, listener) {
2180
+ function subscribeListState(list, owner, listener) {
2097
2181
  const key = list;
2098
2182
  let listeners = listStateListeners.get(key);
2099
2183
  if (listeners == null) {
2100
- listeners = /* @__PURE__ */ new Set();
2184
+ listeners = /* @__PURE__ */ new Map();
2101
2185
  listStateListeners.set(key, listeners);
2102
- }
2103
- listeners.add(listener);
2104
- return () => {
2105
- const current = listStateListeners.get(key);
2106
- if (current == null) return;
2107
- current.delete(listener);
2108
- if (current.size === 0) listStateListeners.delete(key);
2109
- };
2186
+ } else pruneWeakListenerMap(listeners);
2187
+ const token = Symbol();
2188
+ listeners.set(token, {
2189
+ ownerRef: new WeakRef(owner),
2190
+ notify: listener
2191
+ });
2192
+ listStateListenerRegistry?.register(owner, {
2193
+ listRef: new WeakRef(key),
2194
+ token
2195
+ });
2110
2196
  }
2111
2197
  var ListState = class {
2112
2198
  #items;
@@ -2200,9 +2286,31 @@ var ListState = class {
2200
2286
  };
2201
2287
  //#endregion
2202
2288
  //#region src/renderer/memo.ts
2289
+ const DEFAULT_MEMO_RENDER_ITEM_BY_MAX_ENTRIES = 512;
2203
2290
  function isWeakMapKey(value) {
2204
2291
  return typeof value === "object" && value !== null || typeof value === "function";
2205
2292
  }
2293
+ function normalizeMaxEntries(maxEntries) {
2294
+ if (maxEntries === Number.POSITIVE_INFINITY) return Number.POSITIVE_INFINITY;
2295
+ if (maxEntries == null || !Number.isFinite(maxEntries)) return DEFAULT_MEMO_RENDER_ITEM_BY_MAX_ENTRIES;
2296
+ return Math.max(0, Math.trunc(maxEntries));
2297
+ }
2298
+ function readLruValue(cache, key) {
2299
+ const cached = cache.get(key);
2300
+ if (cached == null) return;
2301
+ cache.delete(key);
2302
+ cache.set(key, cached);
2303
+ return cached;
2304
+ }
2305
+ function writeLruValue(cache, key, value, maxEntries) {
2306
+ if (cache.has(key)) cache.delete(key);
2307
+ else if (Number.isFinite(maxEntries) && cache.size >= maxEntries) {
2308
+ const oldestKey = cache.keys().next().value;
2309
+ if (oldestKey != null) cache.delete(oldestKey);
2310
+ }
2311
+ if (maxEntries > 0) cache.set(key, value);
2312
+ return value;
2313
+ }
2206
2314
  /**
2207
2315
  * Memoizes `renderItem` by object identity.
2208
2316
  */
@@ -2222,15 +2330,14 @@ function memoRenderItem(renderItem) {
2222
2330
  /**
2223
2331
  * Memoizes `renderItem` by a caller-provided cache key.
2224
2332
  */
2225
- function memoRenderItemBy(keyOf, renderItem) {
2333
+ function memoRenderItemBy(keyOf, renderItem, options = {}) {
2226
2334
  const cache = /* @__PURE__ */ new Map();
2335
+ const maxEntries = normalizeMaxEntries(options.maxEntries);
2227
2336
  function fn(item) {
2228
2337
  const key = keyOf(item);
2229
- const cached = cache.get(key);
2338
+ const cached = readLruValue(cache, key);
2230
2339
  if (cached != null) return cached;
2231
- const result = renderItem(item);
2232
- cache.set(key, result);
2233
- return result;
2340
+ return writeLruValue(cache, key, renderItem(item), maxEntries);
2234
2341
  }
2235
2342
  return Object.assign(fn, {
2236
2343
  reset: (item) => cache.delete(keyOf(item)),
@@ -2272,11 +2379,10 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
2272
2379
  #jumpAnimation;
2273
2380
  #replacementAnimations = /* @__PURE__ */ new Map();
2274
2381
  #nextReplacementLayerKey = 0;
2275
- #unsubscribeListState;
2276
2382
  constructor(graphics, options) {
2277
2383
  super(graphics, options);
2278
- this.#unsubscribeListState = subscribeListState(options.list, (change) => {
2279
- this.#handleListStateChange(change);
2384
+ subscribeListState(options.list, this, (owner, change) => {
2385
+ owner.#handleListStateChange(change);
2280
2386
  });
2281
2387
  }
2282
2388
  /** Current anchor item index. */