chat-layout 1.2.0-1 → 1.2.0-3

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/index.mjs CHANGED
@@ -2386,63 +2386,65 @@ function measureRichFragmentShift(ctx, font) {
2386
2386
  }
2387
2387
  function materializeRichFragments(ctx, spans, defaultColor, atoms) {
2388
2388
  const fragments = [];
2389
- let pendingGapBefore = 0;
2390
- for (const atom of atoms) {
2391
- const occupiedWidth = atom.width + atom.extraWidthAfter;
2392
- if (atom.kind === "space" && !atom.preservesLineEnd && atom.atomicGroupId == null) {
2393
- pendingGapBefore += occupiedWidth;
2394
- continue;
2395
- }
2396
- const span = spans[atom.itemIndex];
2397
- const font = span?.font ?? atom.font;
2398
- const color = span?.color ?? defaultColor;
2399
- const previous = fragments[fragments.length - 1];
2400
- if (previous != null && previous.itemIndex === atom.itemIndex && previous.font === font && pendingGapBefore === 0) {
2401
- previous.text += atom.text;
2402
- previous.occupiedWidth += occupiedWidth;
2403
- continue;
2404
- }
2405
- fragments.push({
2406
- itemIndex: atom.itemIndex,
2407
- text: atom.text,
2408
- font,
2409
- color,
2410
- gapBefore: pendingGapBefore,
2411
- occupiedWidth,
2412
- shift: measureRichFragmentShift(ctx, font)
2413
- });
2414
- pendingGapBefore = 0;
2415
- }
2389
+ let pendingGap = {
2390
+ gapBefore: 0,
2391
+ gapAtomCount: 0,
2392
+ gapSpaceCount: 0
2393
+ };
2394
+ for (const atom of atoms) pendingGap = appendRichFragment(ctx, spans, defaultColor, fragments, atom, pendingGap);
2416
2395
  return fragments;
2417
2396
  }
2418
- function appendRichFragment(ctx, spans, defaultColor, fragments, atom, pendingGapBefore) {
2397
+ function appendRichFragment(ctx, spans, defaultColor, fragments, atom, pendingGap) {
2419
2398
  const occupiedWidth = atom.width + atom.extraWidthAfter;
2420
- if (atom.kind === "space" && !atom.preservesLineEnd && atom.atomicGroupId == null) return pendingGapBefore + occupiedWidth;
2399
+ if (atom.kind === "space" && !atom.preservesLineEnd && atom.atomicGroupId == null) return {
2400
+ gapBefore: pendingGap.gapBefore + occupiedWidth,
2401
+ gapAtomCount: pendingGap.gapAtomCount + 1,
2402
+ gapSpaceCount: pendingGap.gapSpaceCount + 1
2403
+ };
2421
2404
  const span = spans[atom.itemIndex];
2422
2405
  const font = span?.font ?? atom.font;
2423
2406
  const color = span?.color ?? defaultColor;
2424
2407
  const previous = fragments[fragments.length - 1];
2425
- if (previous != null && previous.itemIndex === atom.itemIndex && previous.font === font && pendingGapBefore === 0) {
2408
+ const spaceCount = atom.kind === "space" ? 1 : 0;
2409
+ if (previous != null && previous.itemIndex === atom.itemIndex && previous.font === font && pendingGap.gapBefore === 0) {
2426
2410
  previous.text += atom.text;
2427
2411
  previous.occupiedWidth += occupiedWidth;
2428
- return 0;
2412
+ previous.atomCount += 1;
2413
+ previous.spaceCount += spaceCount;
2414
+ return {
2415
+ gapBefore: 0,
2416
+ gapAtomCount: 0,
2417
+ gapSpaceCount: 0
2418
+ };
2429
2419
  }
2430
2420
  fragments.push({
2431
2421
  itemIndex: atom.itemIndex,
2432
2422
  text: atom.text,
2433
2423
  font,
2434
2424
  color,
2435
- gapBefore: pendingGapBefore,
2425
+ gapBefore: pendingGap.gapBefore,
2426
+ gapAtomCount: pendingGap.gapAtomCount,
2427
+ gapSpaceCount: pendingGap.gapSpaceCount,
2436
2428
  occupiedWidth,
2429
+ atomCount: 1,
2430
+ spaceCount,
2437
2431
  shift: measureRichFragmentShift(ctx, font)
2438
2432
  });
2439
- return 0;
2433
+ return {
2434
+ gapBefore: 0,
2435
+ gapAtomCount: 0,
2436
+ gapSpaceCount: 0
2437
+ };
2440
2438
  }
2441
2439
  function materializeRichFragmentsInRange(ctx, spans, defaultColor, prepared, start, end) {
2442
2440
  const fragments = [];
2443
- let pendingGapBefore = 0;
2441
+ let pendingGap = {
2442
+ gapBefore: 0,
2443
+ gapAtomCount: 0,
2444
+ gapSpaceCount: 0
2445
+ };
2444
2446
  forEachAtomInRange(prepared, start, end, (atom) => {
2445
- pendingGapBefore = appendRichFragment(ctx, spans, defaultColor, fragments, atom, pendingGapBefore);
2447
+ pendingGap = appendRichFragment(ctx, spans, defaultColor, fragments, atom, pendingGap);
2446
2448
  });
2447
2449
  return fragments;
2448
2450
  }
@@ -2488,7 +2490,11 @@ function createRichEllipsisFragment(ctx, font, color) {
2488
2490
  font,
2489
2491
  color,
2490
2492
  gapBefore: 0,
2493
+ gapAtomCount: 0,
2494
+ gapSpaceCount: 0,
2491
2495
  occupiedWidth: measureEllipsisWidth(ctx),
2496
+ atomCount: 1,
2497
+ spaceCount: 0,
2492
2498
  shift: measureFontShift(ctx)
2493
2499
  }));
2494
2500
  }
@@ -2574,13 +2580,17 @@ function layoutRichEndEllipsisFromCursor(ctx, spans, defaultFont, defaultColor,
2574
2580
  const { prefixCount, width } = selection;
2575
2581
  const fragments = [];
2576
2582
  let atomIndex = 0;
2577
- let pendingGapBefore = 0;
2583
+ let pendingGap = {
2584
+ gapBefore: 0,
2585
+ gapAtomCount: 0,
2586
+ gapSpaceCount: 0
2587
+ };
2578
2588
  let lastVisibleAtom;
2579
2589
  let lastAtom;
2580
2590
  forEachAtomFromCursorToEnd(prepared, start, (atom) => {
2581
2591
  lastAtom = atom;
2582
2592
  if (atomIndex < prefixCount) {
2583
- pendingGapBefore = appendRichFragment(ctx, spans, defaultColor, fragments, atom, pendingGapBefore);
2593
+ pendingGap = appendRichFragment(ctx, spans, defaultColor, fragments, atom, pendingGap);
2584
2594
  lastVisibleAtom = atom;
2585
2595
  }
2586
2596
  atomIndex += 1;
@@ -2749,6 +2759,154 @@ function layoutRichTextWithOverflow(ctx, spans, maxWidth, defaultFont, defaultCo
2749
2759
  };
2750
2760
  }
2751
2761
  //#endregion
2762
+ //#region src/text/justify.ts
2763
+ let _justifySupported;
2764
+ function isJustifySupported(ctx) {
2765
+ if (_justifySupported !== void 0) return _justifySupported;
2766
+ _justifySupported = typeof ctx.wordSpacing === "string" && typeof ctx.letterSpacing === "string";
2767
+ return _justifySupported;
2768
+ }
2769
+ function resolveJustifyMode(justify) {
2770
+ if (justify === true) return "inter-word";
2771
+ if (justify === "inter-word" || justify === "inter-character") return justify;
2772
+ return null;
2773
+ }
2774
+ const HYBRID_WORD_SHARE_CANDIDATES = [
2775
+ .15,
2776
+ .2,
2777
+ .25,
2778
+ .3,
2779
+ .35,
2780
+ .4,
2781
+ .45,
2782
+ .5,
2783
+ .55,
2784
+ .6,
2785
+ .65,
2786
+ .7,
2787
+ .75,
2788
+ .8,
2789
+ .85,
2790
+ 1,
2791
+ 0
2792
+ ];
2793
+ const PUNCTUATION_OR_SYMBOL_PATTERN = /^[\p{P}\p{S}]$/u;
2794
+ const JUSTIFY_SCORE_EPSILON = 1e-9;
2795
+ function analyzeLineForJustify(prepared, line) {
2796
+ let wordGapCount = 0;
2797
+ let wordCount = 0;
2798
+ let renderAtomCount = 0;
2799
+ let spaceCount = 0;
2800
+ let nonSpaceCount = 0;
2801
+ let cjkCount = 0;
2802
+ let latinLikeCount = 0;
2803
+ let punctuationCount = 0;
2804
+ let nonSpaceWidth = 0;
2805
+ let insideWord = false;
2806
+ forEachAtomInRange(prepared, line.start, line.end, (atom) => {
2807
+ if (atom.kind === "space" && !atom.preservesLineEnd && atom.atomicGroupId == null) wordGapCount++;
2808
+ renderAtomCount++;
2809
+ if (atom.kind === "space") {
2810
+ spaceCount++;
2811
+ insideWord = false;
2812
+ return;
2813
+ }
2814
+ nonSpaceCount++;
2815
+ nonSpaceWidth += atom.width + atom.extraWidthAfter;
2816
+ if (!insideWord) {
2817
+ wordCount++;
2818
+ insideWord = true;
2819
+ }
2820
+ if (isCJK(atom.text)) {
2821
+ cjkCount++;
2822
+ return;
2823
+ }
2824
+ if (PUNCTUATION_OR_SYMBOL_PATTERN.test(atom.text)) {
2825
+ punctuationCount++;
2826
+ return;
2827
+ }
2828
+ latinLikeCount++;
2829
+ });
2830
+ return {
2831
+ wordGapCount,
2832
+ wordCount,
2833
+ renderAtomCount,
2834
+ letterGapCount: Math.max(renderAtomCount - 1, 0),
2835
+ spaceCount,
2836
+ nonSpaceCount,
2837
+ cjkCount,
2838
+ latinLikeCount,
2839
+ punctuationCount,
2840
+ lineWidth: line.width,
2841
+ nonSpaceWidth
2842
+ };
2843
+ }
2844
+ function getAverageWordWidth(info) {
2845
+ return info.wordCount > 0 ? info.nonSpaceWidth / info.wordCount : info.lineWidth;
2846
+ }
2847
+ function getAverageCharWidth(info) {
2848
+ return info.renderAtomCount > 0 ? info.lineWidth / info.renderAtomCount : info.lineWidth;
2849
+ }
2850
+ function resolvePerGapSpacing(totalSpace, gapCount) {
2851
+ if (totalSpace === 0) return 0;
2852
+ if (gapCount <= 0) return null;
2853
+ return totalSpace / gapCount;
2854
+ }
2855
+ function exceedsThreshold(perGap, averageWidth, threshold) {
2856
+ if (!Number.isFinite(threshold)) return false;
2857
+ return perGap > threshold * averageWidth;
2858
+ }
2859
+ function createJustifySpacing(wordSpacingPx, letterSpacingPx) {
2860
+ return {
2861
+ wordSpacing: `${wordSpacingPx}px`,
2862
+ letterSpacing: `${letterSpacingPx}px`,
2863
+ wordSpacingPx,
2864
+ letterSpacingPx
2865
+ };
2866
+ }
2867
+ function computeJustifySpacing(lineWidth, maxWidth, info, mode, threshold = Number.POSITIVE_INFINITY) {
2868
+ const extraSpace = maxWidth - lineWidth;
2869
+ if (extraSpace <= 0 || mode == null) return null;
2870
+ if (mode === "inter-word" && info.wordGapCount > 0) {
2871
+ const perGap = extraSpace / info.wordGapCount;
2872
+ if (exceedsThreshold(perGap, Math.max(getAverageWordWidth(info), Number.EPSILON), threshold)) return null;
2873
+ return createJustifySpacing(perGap, 0);
2874
+ }
2875
+ if (mode !== "inter-character" || info.renderAtomCount === 0) return null;
2876
+ const avgCharWidth = Math.max(getAverageCharWidth(info), Number.EPSILON);
2877
+ if (info.wordGapCount === 0) {
2878
+ const perGap = resolvePerGapSpacing(extraSpace, info.letterGapCount);
2879
+ if (perGap == null) return null;
2880
+ if (exceedsThreshold(perGap, avgCharWidth, threshold)) return null;
2881
+ return createJustifySpacing(0, perGap);
2882
+ }
2883
+ const avgWordWidth = Math.max(getAverageWordWidth(info), Number.EPSILON);
2884
+ const nonSpaceCount = Math.max(info.nonSpaceCount, 1);
2885
+ const cjkRatio = info.cjkCount / nonSpaceCount;
2886
+ const latinLikeRatio = info.latinLikeCount / nonSpaceCount;
2887
+ const punctuationRatio = info.punctuationCount / nonSpaceCount;
2888
+ const wordPenalty = 1 + cjkRatio;
2889
+ const letterPenalty = 1 + latinLikeRatio + .5 * punctuationRatio;
2890
+ let bestCandidate = null;
2891
+ for (const wordShare of HYBRID_WORD_SHARE_CANDIDATES) {
2892
+ const wordExtraSpace = extraSpace * wordShare;
2893
+ const letterExtraSpace = extraSpace - wordExtraSpace;
2894
+ const wordSpacingPx = resolvePerGapSpacing(wordExtraSpace, info.wordGapCount);
2895
+ const letterSpacingPx = resolvePerGapSpacing(letterExtraSpace, info.letterGapCount);
2896
+ if (wordSpacingPx == null || letterSpacingPx == null) continue;
2897
+ if (exceedsThreshold(wordSpacingPx, avgWordWidth, threshold) || exceedsThreshold(letterSpacingPx, avgCharWidth, threshold)) continue;
2898
+ const wordRatio = wordSpacingPx / avgWordWidth;
2899
+ const letterRatio = letterSpacingPx / avgCharWidth;
2900
+ const score = wordPenalty * wordRatio ** 2 + letterPenalty * letterRatio ** 2;
2901
+ if (bestCandidate == null || score < bestCandidate.score - JUSTIFY_SCORE_EPSILON || Math.abs(score - bestCandidate.score) <= JUSTIFY_SCORE_EPSILON && wordShare > bestCandidate.wordShare) bestCandidate = {
2902
+ spacing: createJustifySpacing(wordSpacingPx, letterSpacingPx),
2903
+ score,
2904
+ wordShare
2905
+ };
2906
+ }
2907
+ return bestCandidate?.spacing ?? null;
2908
+ }
2909
+ //#endregion
2752
2910
  //#region src/nodes/text.ts
2753
2911
  function resolvePhysicalTextAlign(options) {
2754
2912
  if (options.physicalAlign != null) return options.physicalAlign;
@@ -2763,6 +2921,26 @@ function normalizeTextMaxWidth(maxWidth) {
2763
2921
  if (maxWidth == null) return;
2764
2922
  return Math.max(0, maxWidth);
2765
2923
  }
2924
+ const DEFAULT_TEXT_SPACING = {
2925
+ wordSpacing: "0px",
2926
+ letterSpacing: "0px"
2927
+ };
2928
+ function supportsTextSpacing(g) {
2929
+ return typeof g.wordSpacing === "string" && typeof g.letterSpacing === "string";
2930
+ }
2931
+ function withTextSpacing(g, spacing, cb) {
2932
+ if (!supportsTextSpacing(g)) return cb();
2933
+ const savedWordSpacing = g.wordSpacing;
2934
+ const savedLetterSpacing = g.letterSpacing;
2935
+ try {
2936
+ g.wordSpacing = spacing.wordSpacing;
2937
+ g.letterSpacing = spacing.letterSpacing;
2938
+ return cb();
2939
+ } finally {
2940
+ g.wordSpacing = savedWordSpacing;
2941
+ g.letterSpacing = savedLetterSpacing;
2942
+ }
2943
+ }
2766
2944
  function getTextLayoutContext(ctx) {
2767
2945
  return ctx;
2768
2946
  }
@@ -2866,7 +3044,9 @@ function drawRichLine(ctx, line, fallbackColor, x, y, lineHeight) {
2866
3044
  g.font = fragment.font;
2867
3045
  g.fillStyle = ctx.resolveDynValue(fragment.color ?? fallbackColor);
2868
3046
  g.textAlign = "left";
2869
- g.fillText(fragment.text, cursorX, y + (lineHeight + fragment.shift) / 2);
3047
+ withTextSpacing(g, DEFAULT_TEXT_SPACING, () => {
3048
+ g.fillText(fragment.text, cursorX, y + (lineHeight + fragment.shift) / 2);
3049
+ });
2870
3050
  });
2871
3051
  cursorX += fragment.occupiedWidth;
2872
3052
  }
@@ -2945,23 +3125,83 @@ var MultilineText = class {
2945
3125
  const spans = this.text;
2946
3126
  const { width, lines } = getRichMultiLineDrawLayout(this, ctx, spans, this.options);
2947
3127
  const align = resolvePhysicalTextAlign(this.options);
2948
- const startX = align === "right" ? x + width : align === "center" ? x + width / 2 : x;
2949
- for (const line of lines) {
2950
- let cursorX = startX;
2951
- for (let fi = 0; fi < line.fragments.length; fi++) {
2952
- const frag = line.fragments[fi];
2953
- cursorX += frag.gapBefore;
2954
- ctx.with((g) => {
2955
- g.font = frag.font;
2956
- g.fillStyle = ctx.resolveDynValue(frag.color ?? this.options.color);
2957
- if (align === "right") g.textAlign = "right";
2958
- else if (align === "center") g.textAlign = "center";
2959
- else g.textAlign = "left";
2960
- g.fillText(frag.text, cursorX, y + (this.options.lineHeight + frag.shift) / 2);
2961
- });
2962
- cursorX += frag.occupiedWidth;
3128
+ const maxWidth = normalizeTextMaxWidth(ctx.constraints?.maxWidth);
3129
+ const mode = resolveJustifyMode(this.options.justify);
3130
+ const canJustify = mode != null && maxWidth != null && maxWidth > 0 && isJustifySupported(ctx.graphics);
3131
+ const threshold = this.options.justifyGapThreshold ?? 2;
3132
+ if (canJustify) {
3133
+ const prepared = readPreparedInlineLayout(getRichPreparedKey(spans, this.options.font, this.options.whiteSpace ?? "normal", this.options.wordBreak ?? "normal"), createRichSourceItems(spans, this.options.font), this.options.whiteSpace ?? "normal", this.options.wordBreak ?? "normal");
3134
+ let lineIndex = 0;
3135
+ const totalLines = lines.length;
3136
+ walkPreparedLineRanges(prepared, maxWidth, (lineRange) => {
3137
+ if (lineIndex >= totalLines) return false;
3138
+ const line = lines[lineIndex];
3139
+ const isLastLine = lineIndex === totalLines - 1;
3140
+ if (!(isLastLine && shouldUseMultilineOverflowLayout(this.options) && this.options.overflow === "ellipsis") && (!isLastLine || this.options.justifyLastLine === true)) {
3141
+ const info = analyzeLineForJustify(prepared, lineRange);
3142
+ const spacing = computeJustifySpacing(lineRange.width, maxWidth, info, mode, threshold);
3143
+ if (spacing != null) {
3144
+ let cursorX = x;
3145
+ for (let fi = 0; fi < line.fragments.length; fi++) {
3146
+ const frag = line.fragments[fi];
3147
+ const leadingLetterGapCount = fi > 0 ? frag.gapAtomCount + 1 : 0;
3148
+ const internalLetterGapCount = Math.max(frag.atomCount - 1, 0);
3149
+ cursorX += frag.gapBefore + leadingLetterGapCount * spacing.letterSpacingPx + frag.gapSpaceCount * spacing.wordSpacingPx;
3150
+ ctx.with((g) => {
3151
+ g.font = frag.font;
3152
+ g.fillStyle = ctx.resolveDynValue(frag.color ?? this.options.color);
3153
+ g.textAlign = "left";
3154
+ withTextSpacing(g, spacing, () => {
3155
+ g.fillText(frag.text, cursorX, y + (this.options.lineHeight + frag.shift) / 2);
3156
+ });
3157
+ });
3158
+ cursorX += frag.occupiedWidth + internalLetterGapCount * spacing.letterSpacingPx + frag.spaceCount * spacing.wordSpacingPx;
3159
+ }
3160
+ y += this.options.lineHeight;
3161
+ lineIndex++;
3162
+ return;
3163
+ }
3164
+ }
3165
+ let cursorX = align === "right" ? x + width : align === "center" ? x + width / 2 : x;
3166
+ for (let fi = 0; fi < line.fragments.length; fi++) {
3167
+ const frag = line.fragments[fi];
3168
+ cursorX += frag.gapBefore;
3169
+ ctx.with((g) => {
3170
+ g.font = frag.font;
3171
+ g.fillStyle = ctx.resolveDynValue(frag.color ?? this.options.color);
3172
+ if (align === "right") g.textAlign = "right";
3173
+ else if (align === "center") g.textAlign = "center";
3174
+ else g.textAlign = "left";
3175
+ withTextSpacing(g, DEFAULT_TEXT_SPACING, () => {
3176
+ g.fillText(frag.text, cursorX, y + (this.options.lineHeight + frag.shift) / 2);
3177
+ });
3178
+ });
3179
+ cursorX += frag.occupiedWidth;
3180
+ }
3181
+ y += this.options.lineHeight;
3182
+ lineIndex++;
3183
+ });
3184
+ } else {
3185
+ const startX = align === "right" ? x + width : align === "center" ? x + width / 2 : x;
3186
+ for (const line of lines) {
3187
+ let cursorX = startX;
3188
+ for (let fi = 0; fi < line.fragments.length; fi++) {
3189
+ const frag = line.fragments[fi];
3190
+ cursorX += frag.gapBefore;
3191
+ ctx.with((g) => {
3192
+ g.font = frag.font;
3193
+ g.fillStyle = ctx.resolveDynValue(frag.color ?? this.options.color);
3194
+ if (align === "right") g.textAlign = "right";
3195
+ else if (align === "center") g.textAlign = "center";
3196
+ else g.textAlign = "left";
3197
+ withTextSpacing(g, DEFAULT_TEXT_SPACING, () => {
3198
+ g.fillText(frag.text, cursorX, y + (this.options.lineHeight + frag.shift) / 2);
3199
+ });
3200
+ });
3201
+ cursorX += frag.occupiedWidth;
3202
+ }
3203
+ y += this.options.lineHeight;
2963
3204
  }
2964
- y += this.options.lineHeight;
2965
3205
  }
2966
3206
  return false;
2967
3207
  }
@@ -2969,30 +3209,76 @@ var MultilineText = class {
2969
3209
  g.font = this.options.font;
2970
3210
  g.fillStyle = ctx.resolveDynValue(this.options.color);
2971
3211
  const { width, lines } = getMultiLineDrawLayout(this, ctx, this.text, this.options);
2972
- switch (resolvePhysicalTextAlign(this.options)) {
2973
- case "left":
2974
- for (const { text, shift } of lines) {
2975
- g.fillText(text, x, y + (this.options.lineHeight + shift) / 2);
2976
- y += this.options.lineHeight;
3212
+ const maxWidth = normalizeTextMaxWidth(ctx.constraints?.maxWidth);
3213
+ const mode = resolveJustifyMode(this.options.justify);
3214
+ const canJustify = mode != null && maxWidth != null && maxWidth > 0 && isJustifySupported(g);
3215
+ const threshold = this.options.justifyGapThreshold ?? 2;
3216
+ if (canJustify) {
3217
+ const prepared = readPreparedText(this.text, this.options.font, this.options.whiteSpace ?? "normal", this.options.wordBreak ?? "normal");
3218
+ let lineIndex = 0;
3219
+ const totalLines = lines.length;
3220
+ walkPreparedLineRanges(prepared, maxWidth, (lineRange) => {
3221
+ if (lineIndex >= totalLines) return false;
3222
+ const layout = lines[lineIndex];
3223
+ const isLastLine = lineIndex === totalLines - 1;
3224
+ if (!(isLastLine && shouldUseMultilineOverflowLayout(this.options) && this.options.overflow === "ellipsis") && (!isLastLine || this.options.justifyLastLine === true)) {
3225
+ const info = analyzeLineForJustify(prepared, lineRange);
3226
+ const spacing = computeJustifySpacing(lineRange.width, maxWidth, info, mode, threshold);
3227
+ if (spacing != null) {
3228
+ withTextSpacing(g, spacing, () => {
3229
+ g.textAlign = "left";
3230
+ g.fillText(layout.text, x, y + (this.options.lineHeight + layout.shift) / 2);
3231
+ });
3232
+ y += this.options.lineHeight;
3233
+ lineIndex++;
3234
+ return;
3235
+ }
2977
3236
  }
2978
- break;
2979
- case "right":
2980
- x += width;
2981
- g.textAlign = "right";
2982
- for (const { text, shift } of lines) {
2983
- g.fillText(text, x, y + (this.options.lineHeight + shift) / 2);
2984
- y += this.options.lineHeight;
2985
- }
2986
- break;
2987
- case "center":
2988
- x += width / 2;
2989
- g.textAlign = "center";
2990
- for (const { text, shift } of lines) {
2991
- g.fillText(text, x, y + (this.options.lineHeight + shift) / 2);
2992
- y += this.options.lineHeight;
2993
- }
2994
- break;
2995
- }
3237
+ withTextSpacing(g, DEFAULT_TEXT_SPACING, () => {
3238
+ switch (resolvePhysicalTextAlign(this.options)) {
3239
+ case "left":
3240
+ g.textAlign = "left";
3241
+ g.fillText(layout.text, x, y + (this.options.lineHeight + layout.shift) / 2);
3242
+ break;
3243
+ case "right":
3244
+ g.textAlign = "right";
3245
+ g.fillText(layout.text, x + width, y + (this.options.lineHeight + layout.shift) / 2);
3246
+ break;
3247
+ case "center":
3248
+ g.textAlign = "center";
3249
+ g.fillText(layout.text, x + width / 2, y + (this.options.lineHeight + layout.shift) / 2);
3250
+ break;
3251
+ }
3252
+ });
3253
+ y += this.options.lineHeight;
3254
+ lineIndex++;
3255
+ });
3256
+ } else withTextSpacing(g, DEFAULT_TEXT_SPACING, () => {
3257
+ switch (resolvePhysicalTextAlign(this.options)) {
3258
+ case "left":
3259
+ for (const { text, shift } of lines) {
3260
+ g.fillText(text, x, y + (this.options.lineHeight + shift) / 2);
3261
+ y += this.options.lineHeight;
3262
+ }
3263
+ break;
3264
+ case "right":
3265
+ x += width;
3266
+ g.textAlign = "right";
3267
+ for (const { text, shift } of lines) {
3268
+ g.fillText(text, x, y + (this.options.lineHeight + shift) / 2);
3269
+ y += this.options.lineHeight;
3270
+ }
3271
+ break;
3272
+ case "center":
3273
+ x += width / 2;
3274
+ g.textAlign = "center";
3275
+ for (const { text, shift } of lines) {
3276
+ g.fillText(text, x, y + (this.options.lineHeight + shift) / 2);
3277
+ y += this.options.lineHeight;
3278
+ }
3279
+ break;
3280
+ }
3281
+ });
2996
3282
  return false;
2997
3283
  });
2998
3284
  }
@@ -3055,7 +3341,9 @@ var Text = class {
3055
3341
  g.font = this.options.font;
3056
3342
  g.fillStyle = ctx.resolveDynValue(this.options.color);
3057
3343
  const layout = getSingleLineLayout(this, ctx, text, this.options);
3058
- g.fillText(layout.text, x, y + (this.options.lineHeight + layout.shift) / 2);
3344
+ withTextSpacing(g, DEFAULT_TEXT_SPACING, () => {
3345
+ g.fillText(layout.text, x, y + (this.options.lineHeight + layout.shift) / 2);
3346
+ });
3059
3347
  return false;
3060
3348
  });
3061
3349
  }
@@ -3337,6 +3625,24 @@ function subscribeListState(list, owner, listener) {
3337
3625
  token
3338
3626
  });
3339
3627
  }
3628
+ function isObjectIdentityCandidate(value) {
3629
+ return typeof value === "object" && value !== null || typeof value === "function";
3630
+ }
3631
+ function assertUniqueItemReferences(items, existingItems) {
3632
+ const seen = /* @__PURE__ */ new Set();
3633
+ if (existingItems != null) {
3634
+ for (const item of existingItems) if (isObjectIdentityCandidate(item)) seen.add(item);
3635
+ }
3636
+ for (const item of items) {
3637
+ if (!isObjectIdentityCandidate(item)) continue;
3638
+ if (seen.has(item)) throw new Error("ListState items must use unique object references.");
3639
+ seen.add(item);
3640
+ }
3641
+ }
3642
+ function normalizeUpdateAnimation(animation) {
3643
+ if (animation == null) return;
3644
+ return Number.isFinite(animation.duration) ? { duration: animation.duration } : {};
3645
+ }
3340
3646
  var ListState = class {
3341
3647
  #items;
3342
3648
  /** Pixel offset from the anchored item edge. */
@@ -3349,14 +3655,18 @@ var ListState = class {
3349
3655
  }
3350
3656
  /** Replaces the full item collection while preserving scroll state. */
3351
3657
  set items(value) {
3352
- this.#items = [...value];
3658
+ const nextItems = [...value];
3659
+ assertUniqueItemReferences(nextItems);
3660
+ this.#items = nextItems;
3353
3661
  emitListStateChange(this, { type: "set" });
3354
3662
  }
3355
3663
  /**
3356
3664
  * @param items Initial list items.
3357
3665
  */
3358
3666
  constructor(items = []) {
3359
- this.#items = [...items];
3667
+ const nextItems = [...items];
3668
+ assertUniqueItemReferences(nextItems);
3669
+ this.#items = nextItems;
3360
3670
  }
3361
3671
  /** Prepends one or more items. */
3362
3672
  unshift(...items) {
@@ -3365,6 +3675,7 @@ var ListState = class {
3365
3675
  /** Prepends an array of items. */
3366
3676
  unshiftAll(items) {
3367
3677
  if (items.length === 0) return;
3678
+ assertUniqueItemReferences(items, this.#items);
3368
3679
  if (this.position != null) this.position += items.length;
3369
3680
  this.#items = items.concat(this.#items);
3370
3681
  emitListStateChange(this, {
@@ -3379,6 +3690,7 @@ var ListState = class {
3379
3690
  /** Appends an array of items. */
3380
3691
  pushAll(items) {
3381
3692
  if (items.length === 0) return;
3693
+ assertUniqueItemReferences(items, this.#items);
3382
3694
  this.#items.push(...items);
3383
3695
  emitListStateChange(this, {
3384
3696
  type: "push",
@@ -3386,19 +3698,21 @@ var ListState = class {
3386
3698
  });
3387
3699
  }
3388
3700
  /**
3389
- * Replaces an existing item by index.
3701
+ * Updates an existing item by object identity.
3390
3702
  */
3391
- replace(index, item, animation) {
3392
- const normalizedIndex = Number.isFinite(index) ? Math.trunc(index) : NaN;
3393
- if (!Number.isInteger(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= this.#items.length) throw new RangeError(`replace() index ${index} is out of range for list length ${this.#items.length}.`);
3394
- const prevItem = this.#items[normalizedIndex];
3395
- this.#items[normalizedIndex] = item;
3703
+ update(targetItem, nextItem, animation) {
3704
+ if (!isObjectIdentityCandidate(targetItem) || !isObjectIdentityCandidate(nextItem)) throw new TypeError("update() only supports object items.");
3705
+ if (targetItem === nextItem) throw new Error("update() requires nextItem to be a new object reference.");
3706
+ const index = this.#items.indexOf(targetItem);
3707
+ if (index < 0) throw new Error("update() targetItem is not present in the list.");
3708
+ if (this.#items.includes(nextItem)) throw new Error("update() nextItem is already present in the list.");
3709
+ const prevItem = this.#items[index];
3710
+ this.#items[index] = nextItem;
3396
3711
  emitListStateChange(this, {
3397
- type: "replace",
3398
- index: normalizedIndex,
3712
+ type: "update",
3399
3713
  prevItem,
3400
- nextItem: item,
3401
- animation: animation != null && Number.isFinite(animation.duration) ? { duration: animation.duration } : animation == null ? void 0 : {}
3714
+ nextItem,
3715
+ animation: normalizeUpdateAnimation(animation)
3402
3716
  });
3403
3717
  }
3404
3718
  /**
@@ -3412,7 +3726,9 @@ var ListState = class {
3412
3726
  * Replaces all items and clears scroll state.
3413
3727
  */
3414
3728
  reset(items = []) {
3415
- this.#items = [...items];
3729
+ const nextItems = [...items];
3730
+ assertUniqueItemReferences(nextItems);
3731
+ this.#items = nextItems;
3416
3732
  this.offset = 0;
3417
3733
  this.position = void 0;
3418
3734
  emitListStateChange(this, { type: "reset" });
@@ -3520,7 +3836,8 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
3520
3836
  static JUMP_DURATION_PER_ITEM = 28;
3521
3837
  #controlledState;
3522
3838
  #jumpAnimation;
3523
- #replacementAnimations = /* @__PURE__ */ new Map();
3839
+ #replacementAnimations = /* @__PURE__ */ new WeakMap();
3840
+ #activeReplacementItems = /* @__PURE__ */ new Set();
3524
3841
  #nextReplacementLayerKey = 0;
3525
3842
  constructor(graphics, options) {
3526
3843
  super(graphics, options);
@@ -3683,14 +4000,14 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
3683
4000
  }
3684
4001
  _getItemHeight(index) {
3685
4002
  const now = getNow();
3686
- const replacement = this.#readReplacementAnimation(index, now);
3687
- if (replacement != null) return this.#sampleReplacementHeight(replacement, now);
3688
4003
  const item = this.items[index];
4004
+ const replacement = this.#readReplacementAnimation(item, now);
4005
+ if (replacement != null) return this.#sampleReplacementHeight(replacement, now);
3689
4006
  const node = this.options.renderItem(item);
3690
4007
  return this.measureRootNode(node).height;
3691
4008
  }
3692
- _resolveItem(item, index, now) {
3693
- const replacement = this.#readReplacementAnimation(index, now);
4009
+ _resolveItem(item, _index, now) {
4010
+ const replacement = this.#readReplacementAnimation(item, now);
3694
4011
  if (replacement == null) {
3695
4012
  const node = this.options.renderItem(item);
3696
4013
  return {
@@ -3762,24 +4079,26 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
3762
4079
  #isLayerComplete(layer, now) {
3763
4080
  return getProgress(layer.startTime, layer.duration, now) >= 1 && Math.abs(layer.toAlpha - this.#sampleLayerAlpha(layer, now)) <= ALPHA_EPSILON;
3764
4081
  }
3765
- #readReplacementAnimation(index, now) {
3766
- const animation = this.#replacementAnimations.get(index);
4082
+ #readReplacementAnimation(item, now) {
4083
+ const animation = this.#replacementAnimations.get(item);
3767
4084
  if (animation == null) return;
3768
4085
  const currentLayer = animation.layers.find((layer) => layer.key === animation.currentLayerKey);
3769
4086
  if (currentLayer == null) {
3770
- this.#replacementAnimations.delete(index);
4087
+ this.#replacementAnimations.delete(item);
4088
+ this.#activeReplacementItems.delete(item);
3771
4089
  return;
3772
4090
  }
3773
4091
  animation.layers = animation.layers.filter((layer) => layer.key === animation.currentLayerKey || !this.#isLayerComplete(layer, now));
3774
4092
  if (getProgress(animation.startTime, animation.duration, now) >= 1 && this.#isLayerComplete(currentLayer, now) && animation.layers.length === 1) {
3775
- this.#replacementAnimations.delete(index);
4093
+ this.#replacementAnimations.delete(item);
4094
+ this.#activeReplacementItems.delete(item);
3776
4095
  return;
3777
4096
  }
3778
4097
  return animation;
3779
4098
  }
3780
4099
  #prepareReplacementAnimations(now) {
3781
4100
  let keepAnimating = false;
3782
- for (const index of [...this.#replacementAnimations.keys()]) if (this.#readReplacementAnimation(index, now) != null) keepAnimating = true;
4101
+ for (const item of [...this.#activeReplacementItems]) if (this.#readReplacementAnimation(item, now) != null) keepAnimating = true;
3783
4102
  return keepAnimating;
3784
4103
  }
3785
4104
  #drawReplacementLayers(layers, slotHeight, y) {
@@ -3805,38 +4124,34 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
3805
4124
  }
3806
4125
  #handleListStateChange(change) {
3807
4126
  switch (change.type) {
3808
- case "replace":
3809
- this.#handleReplace(change.index, change.prevItem, change.nextItem, change.animation?.duration);
4127
+ case "update":
4128
+ this.#handleUpdate(change.prevItem, change.nextItem, change.animation?.duration);
3810
4129
  break;
3811
- case "unshift": {
3812
- if (change.count <= 0 || this.#replacementAnimations.size === 0) return;
3813
- const shifted = /* @__PURE__ */ new Map();
3814
- for (const [index, animation] of this.#replacementAnimations) shifted.set(index + change.count, animation);
3815
- this.#replacementAnimations = shifted;
3816
- break;
3817
- }
4130
+ case "unshift":
3818
4131
  case "push": break;
3819
4132
  case "reset":
3820
4133
  case "set":
3821
- this.#replacementAnimations.clear();
4134
+ this.#replacementAnimations = /* @__PURE__ */ new WeakMap();
4135
+ this.#activeReplacementItems.clear();
3822
4136
  break;
3823
4137
  }
3824
4138
  }
3825
- #handleReplace(index, prevItem, nextItem, duration) {
3826
- const normalizedDuration = Number.isFinite(duration) ? Math.max(0, duration) : 0;
4139
+ #handleUpdate(prevItem, nextItem, duration) {
4140
+ const normalizedDuration = Math.max(0, typeof duration === "number" && Number.isFinite(duration) ? duration : 0);
3827
4141
  if (normalizedDuration <= 0) {
3828
- this.#replacementAnimations.delete(index);
4142
+ this.#replacementAnimations.delete(prevItem);
4143
+ this.#activeReplacementItems.delete(prevItem);
3829
4144
  return;
3830
4145
  }
3831
4146
  const now = getNow();
3832
4147
  const nextNode = this.options.renderItem(nextItem);
3833
4148
  const nextHeight = this.measureRootNode(nextNode).height;
3834
- const animation = this.#readReplacementAnimation(index, now);
4149
+ const animation = this.#readReplacementAnimation(prevItem, now);
3835
4150
  if (animation == null) {
3836
4151
  const prevNode = this.options.renderItem(prevItem);
3837
4152
  const outgoing = this.#createReplacementLayer(prevNode, 1, 0, now, normalizedDuration);
3838
4153
  const incoming = this.#createReplacementLayer(nextNode, 0, 1, now, normalizedDuration);
3839
- this.#replacementAnimations.set(index, {
4154
+ this.#replacementAnimations.set(nextItem, {
3840
4155
  currentLayerKey: incoming.key,
3841
4156
  layers: [outgoing, incoming],
3842
4157
  fromHeight: this.measureRootNode(prevNode).height,
@@ -3844,6 +4159,8 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
3844
4159
  startTime: now,
3845
4160
  duration: normalizedDuration
3846
4161
  });
4162
+ this.#activeReplacementItems.delete(prevItem);
4163
+ this.#activeReplacementItems.add(nextItem);
3847
4164
  return;
3848
4165
  }
3849
4166
  const currentLayer = animation.layers.find((layer) => layer.key === animation.currentLayerKey);
@@ -3853,7 +4170,8 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
3853
4170
  if (currentAlpha > ALPHA_EPSILON) layers.push(this.#createReplacementLayer(currentNode, currentAlpha, 0, now, normalizedDuration));
3854
4171
  const incoming = this.#createReplacementLayer(nextNode, 0, 1, now, normalizedDuration);
3855
4172
  layers.push(incoming);
3856
- this.#replacementAnimations.set(index, {
4173
+ this.#replacementAnimations.delete(prevItem);
4174
+ this.#replacementAnimations.set(nextItem, {
3857
4175
  currentLayerKey: incoming.key,
3858
4176
  layers,
3859
4177
  fromHeight: this.#sampleReplacementHeight(animation, now),
@@ -3861,6 +4179,8 @@ var VirtualizedRenderer = class VirtualizedRenderer extends BaseRenderer {
3861
4179
  startTime: now,
3862
4180
  duration: normalizedDuration
3863
4181
  });
4182
+ this.#activeReplacementItems.delete(prevItem);
4183
+ this.#activeReplacementItems.add(nextItem);
3864
4184
  }
3865
4185
  };
3866
4186
  //#endregion