chat-layout 1.0.0-4 → 1.0.0-6

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
@@ -50,6 +50,46 @@ See [example/chat.ts](./example/chat.ts) for a full chat example.
50
50
  - `MultilineText.align` uses logical values: `start`, `center`, `end`.
51
51
  - `MultilineText.physicalAlign` uses physical values: `left`, `center`, `right`.
52
52
  - `Text` and `MultilineText` preserve blank lines and edge whitespace by default. Use `whitespace: "trim-and-collapse"` if you want cleanup.
53
+ - `Text` and `MultilineText` default to `overflowWrap: "break-word"`, which preserves compatibility-first min-content sizing for shrink layouts.
54
+ - Use `overflowWrap: "anywhere"` when long unspaced strings should contribute grapheme-level breakpoints to min-content sizing.
55
+ - `Text` supports `overflow: "ellipsis"` with `ellipsisPosition: "start" | "end" | "middle"` when measured under a finite `maxWidth`.
56
+ - `MultilineText` supports `overflow: "ellipsis"` together with `maxLines`; values below `1` are treated as `1`.
57
+
58
+ ## Text ellipsis
59
+
60
+ Single-line `Text` can ellipsize at the start, end, or middle when a finite width constraint is present:
61
+
62
+ ```ts
63
+ const title = new Text("Extremely long thread title that should not blow out the row", {
64
+ lineHeight: 20,
65
+ font: "16px system-ui",
66
+ style: "#111",
67
+ overflow: "ellipsis",
68
+ ellipsisPosition: "middle",
69
+ });
70
+ ```
71
+
72
+ Multi-line `MultilineText` can cap the visible line count and convert the last visible line to an end ellipsis:
73
+
74
+ ```ts
75
+ const preview = new MultilineText(reply.content, {
76
+ lineHeight: 16,
77
+ font: "13px system-ui",
78
+ style: "#444",
79
+ align: "start",
80
+ overflowWrap: "anywhere",
81
+ overflow: "ellipsis",
82
+ maxLines: 2,
83
+ });
84
+ ```
85
+
86
+ Notes:
87
+
88
+ - Ellipsis is only inserted when the node is measured under a finite `maxWidth` and content actually overflows that constraint.
89
+ - `MultilineText` only supports end ellipsis on the last visible line; start/middle ellipsis are intentionally single-line only.
90
+ - `maxLines` defaults to unlimited, and values below `1` are clamped to `1`.
91
+ - `overflowWrap: "break-word"` keeps the current min-content behavior; `overflowWrap: "anywhere"` lets long unspaced strings shrink inside flex layouts such as chat bubbles.
92
+ - Current `measureMinContent()` behavior stays compatibility-first: ellipsis affects constrained measurement/drawing, but does not lower the min-content shrink floor by itself.
53
93
 
54
94
  ## Shrink behavior
55
95
 
package/example/chat.ts CHANGED
@@ -195,6 +195,7 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
195
195
  font: "16px system-ui",
196
196
  style: "black",
197
197
  align: "start",
198
+ overflowWrap: "anywhere",
198
199
  }),
199
200
  { alignSelf: "start" },
200
201
  );
@@ -215,6 +216,9 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
215
216
  font: "13px system-ui",
216
217
  style: () => (currentHover === item ? "#222" : "#444"),
217
218
  align: "start",
219
+ overflow: "ellipsis",
220
+ overflowWrap: "anywhere",
221
+ maxLines: 2,
218
222
  }),
219
223
  ],
220
224
  {
@@ -306,13 +310,28 @@ const list = new ListState<ChatItem>([
306
310
  },
307
311
  { sender: "B", content: "aaaabbb" },
308
312
  { sender: "B", content: "测试中文" },
313
+ {
314
+ sender: "A",
315
+ content:
316
+ "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
317
+ },
309
318
  { sender: "B", content: "测试aa中文aaa" },
310
319
  {
311
320
  sender: "A",
312
321
  content: randomText(8),
313
322
  reply: {
314
323
  sender: "B",
315
- content: "测试aa中文aaa",
324
+ content:
325
+ "测试aa中文aaa hello world chat layout message render bubble timeline virtualized canvas stream session update typing history",
326
+ },
327
+ },
328
+ {
329
+ sender: "B",
330
+ content: "这里是一条会展示回复预览省略效果的消息。",
331
+ reply: {
332
+ sender: "A",
333
+ content:
334
+ "这是一条非常长的回复预览,用来演示 MultilineText 在 chat example 里的末尾 ellipsis 能力。它应该被限制在两行之内,而不是把整个气泡一路撑到天花板。",
316
335
  },
317
336
  },
318
337
  { sender: "B", content: randomText(5) },
package/index.d.mts CHANGED
@@ -48,6 +48,18 @@ type PhysicalTextAlign = "left" | "center" | "right";
48
48
  * Whitespace normalization mode for text measurement and layout.
49
49
  */
50
50
  type TextWhitespaceMode = "preserve" | "trim-and-collapse";
51
+ /**
52
+ * Text overflow behavior when content exceeds a finite width constraint.
53
+ */
54
+ type TextOverflowMode = "clip" | "ellipsis";
55
+ /**
56
+ * Controls whether soft wrap opportunities affect min-content sizing.
57
+ */
58
+ type TextOverflowWrapMode = "break-word" | "anywhere";
59
+ /**
60
+ * Placement of the ellipsis glyph for single-line text.
61
+ */
62
+ type TextEllipsisPosition = "start" | "end" | "middle";
51
63
  /**
52
64
  * Shared text styling options for text nodes.
53
65
  */
@@ -60,6 +72,8 @@ interface TextStyleOptions<C extends CanvasRenderingContext2D> {
60
72
  style: DynValue<C, string>;
61
73
  /** Default: preserve input whitespace, including blank lines and edge spaces. */
62
74
  whitespace?: TextWhitespaceMode;
75
+ /** Default: break-word; use anywhere when min-content should honor grapheme break opportunities. */
76
+ overflowWrap?: TextOverflowWrapMode;
63
77
  }
64
78
  /**
65
79
  * Options for multi-line text nodes.
@@ -69,11 +83,20 @@ interface MultilineTextOptions<C extends CanvasRenderingContext2D> extends TextS
69
83
  align?: TextAlign;
70
84
  /** Explicit physical alignment when left/right semantics are required. */
71
85
  physicalAlign?: PhysicalTextAlign;
86
+ /** Default: clip hidden overflow; `ellipsis` only applies when `maxLines` truncates visible lines. */
87
+ overflow?: TextOverflowMode;
88
+ /** Maximum visible line count. Values below `1` are clamped to `1`. */
89
+ maxLines?: number;
72
90
  }
73
91
  /**
74
92
  * Options for single-line text nodes.
75
93
  */
76
- interface TextOptions<C extends CanvasRenderingContext2D> extends TextStyleOptions<C> {}
94
+ interface TextOptions<C extends CanvasRenderingContext2D> extends TextStyleOptions<C> {
95
+ /** Default: clip overflow to the constrained first line. */
96
+ overflow?: TextOverflowMode;
97
+ /** Default: place the ellipsis at the end of the visible text. */
98
+ ellipsisPosition?: TextEllipsisPosition;
99
+ }
77
100
  /**
78
101
  * Optional layout bounds passed down during measurement and drawing.
79
102
  */
@@ -589,5 +612,5 @@ declare class TimelineRenderer<C extends CanvasRenderingContext2D, T extends {}>
589
612
  hittest(test: HitTest): boolean;
590
613
  }
591
614
  //#endregion
592
- export { Axis, BaseRenderer, Box, ChatRenderer, ChildLayoutResult, Context, CrossAxisAlignment, DebugRenderer, DynValue, Fixed, Flex, FlexContainerOptions, FlexItem, FlexItemOptions, FlexLayoutResult, Group, HitTest, JumpToOptions, LayoutConstraints, LayoutRect, ListState, MainAxisAlignment, MainAxisSize, MultilineText, MultilineTextOptions, Node, PaddingBox, PhysicalTextAlign, Place, RenderFeedback, RendererOptions, Text, TextAlign, TextOptions, TextStyleOptions, TextWhitespaceMode, TimelineRenderer, VirtualizedRenderer, Wrapper, memoRenderItem, memoRenderItemBy };
615
+ export { Axis, BaseRenderer, Box, ChatRenderer, ChildLayoutResult, Context, CrossAxisAlignment, DebugRenderer, DynValue, Fixed, Flex, FlexContainerOptions, FlexItem, FlexItemOptions, FlexLayoutResult, Group, HitTest, JumpToOptions, LayoutConstraints, LayoutRect, ListState, MainAxisAlignment, MainAxisSize, MultilineText, MultilineTextOptions, Node, PaddingBox, PhysicalTextAlign, Place, RenderFeedback, RendererOptions, Text, TextAlign, TextEllipsisPosition, TextOptions, TextOverflowMode, TextOverflowWrapMode, TextStyleOptions, TextWhitespaceMode, TimelineRenderer, VirtualizedRenderer, Wrapper, memoRenderItem, memoRenderItemBy };
593
616
  //# sourceMappingURL=index.d.mts.map
package/index.mjs CHANGED
@@ -821,8 +821,10 @@ var Place = class extends Wrapper {
821
821
  //#endregion
822
822
  //#region src/text.ts
823
823
  const FONT_SHIFT_PROBE = "M";
824
+ const ELLIPSIS_GLYPH = "…";
824
825
  const PREPARED_SEGMENT_CACHE_CAPACITY = 512;
825
826
  const FONT_SHIFT_CACHE_CAPACITY = 64;
827
+ const ELLIPSIS_WIDTH_CACHE_CAPACITY = 64;
826
828
  const LINE_START_CURSOR = {
827
829
  segmentIndex: 0,
828
830
  graphemeIndex: 0
@@ -830,6 +832,9 @@ const LINE_START_CURSOR = {
830
832
  const MIN_CONTENT_WIDTH_EPSILON = .001;
831
833
  const preparedSegmentCache = /* @__PURE__ */ new Map();
832
834
  const fontShiftCache = /* @__PURE__ */ new Map();
835
+ const ellipsisWidthCache = /* @__PURE__ */ new Map();
836
+ const preparedUnitCache = /* @__PURE__ */ new WeakMap();
837
+ let sharedGraphemeSegmenter;
833
838
  function preprocessSegments(text, whitespace = "preserve") {
834
839
  const segments = text.split("\n");
835
840
  if (whitespace === "trim-and-collapse") return segments.map((line) => line.trim()).filter((line) => line.length > 0);
@@ -867,17 +872,183 @@ function measureFontShift(ctx) {
867
872
  const { fontBoundingBoxAscent: ascent = 0, fontBoundingBoxDescent: descent = 0 } = ctx.graphics.measureText(FONT_SHIFT_PROBE);
868
873
  return writeLruValue(fontShiftCache, font, ascent - descent, FONT_SHIFT_CACHE_CAPACITY);
869
874
  }
870
- function measurePreparedMinContentWidth(prepared) {
875
+ function measurePreparedMinContentWidth(prepared, overflowWrap = "break-word") {
871
876
  let maxWidth = 0;
872
877
  let maxAnyWidth = 0;
873
878
  for (let i = 0; i < prepared.widths.length; i += 1) {
874
879
  const segmentWidth = prepared.widths[i] ?? 0;
875
880
  maxAnyWidth = Math.max(maxAnyWidth, segmentWidth);
876
881
  const segment = prepared.segments[i];
877
- if (segment != null && segment.trim().length > 0) maxWidth = Math.max(maxWidth, segmentWidth);
882
+ if (segment != null && segment.trim().length > 0) {
883
+ const breakableWidths = prepared.breakableWidths[i];
884
+ const minContentWidth = overflowWrap === "anywhere" && breakableWidths != null && breakableWidths.length > 0 ? breakableWidths.reduce((widest, width) => Math.max(widest, width), 0) : segmentWidth;
885
+ maxWidth = Math.max(maxWidth, minContentWidth);
886
+ }
878
887
  }
879
888
  return maxWidth > 0 ? maxWidth : maxAnyWidth;
880
889
  }
890
+ function measurePreparedWidth(prepared) {
891
+ let width = 0;
892
+ for (const segmentWidth of prepared.widths) width += segmentWidth ?? 0;
893
+ return width;
894
+ }
895
+ function getGraphemeSegmenter() {
896
+ if (sharedGraphemeSegmenter !== void 0) return sharedGraphemeSegmenter;
897
+ sharedGraphemeSegmenter = typeof Intl.Segmenter === "function" ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : null;
898
+ return sharedGraphemeSegmenter;
899
+ }
900
+ function splitGraphemes(text) {
901
+ const segmenter = getGraphemeSegmenter();
902
+ if (segmenter == null) return Array.from(text);
903
+ const graphemes = [];
904
+ for (const part of segmenter.segment(text)) graphemes.push(part.segment);
905
+ return graphemes;
906
+ }
907
+ function getPreparedUnits(prepared) {
908
+ const cached = preparedUnitCache.get(prepared);
909
+ if (cached != null) return cached;
910
+ const units = [];
911
+ for (let i = 0; i < prepared.segments.length; i += 1) {
912
+ const segment = prepared.segments[i] ?? "";
913
+ const segmentWidth = prepared.widths[i] ?? 0;
914
+ const breakableWidths = prepared.breakableWidths[i];
915
+ if (breakableWidths != null && segment.length > 0) {
916
+ const graphemes = splitGraphemes(segment);
917
+ if (graphemes.length === breakableWidths.length) {
918
+ for (let j = 0; j < graphemes.length; j += 1) units.push({
919
+ text: graphemes[j] ?? "",
920
+ width: breakableWidths[j] ?? 0
921
+ });
922
+ continue;
923
+ }
924
+ }
925
+ if (segment.length > 0 || segmentWidth > 0) units.push({
926
+ text: segment,
927
+ width: segmentWidth
928
+ });
929
+ }
930
+ preparedUnitCache.set(prepared, units);
931
+ return units;
932
+ }
933
+ function buildUnitPrefixWidths(units) {
934
+ const widths = [0];
935
+ let total = 0;
936
+ for (const unit of units) {
937
+ total += unit.width;
938
+ widths.push(total);
939
+ }
940
+ return widths;
941
+ }
942
+ function buildUnitSuffixWidths(units) {
943
+ const widths = [0];
944
+ let total = 0;
945
+ for (let i = units.length - 1; i >= 0; i -= 1) {
946
+ total += units[i]?.width ?? 0;
947
+ widths.push(total);
948
+ }
949
+ return widths;
950
+ }
951
+ function findMaxFittingCount(cumulativeWidths, maxWidth) {
952
+ if (maxWidth <= 0) return 0;
953
+ let low = 0;
954
+ let high = cumulativeWidths.length - 1;
955
+ while (low < high) {
956
+ const mid = Math.floor((low + high + 1) / 2);
957
+ if ((cumulativeWidths[mid] ?? 0) <= maxWidth) low = mid;
958
+ else high = mid - 1;
959
+ }
960
+ return low;
961
+ }
962
+ function joinUnitText(units, start, end) {
963
+ if (start >= end) return "";
964
+ return units.slice(start, end).map((unit) => unit.text).join("");
965
+ }
966
+ function measureEllipsisWidth(ctx) {
967
+ const font = ctx.graphics.font;
968
+ const cached = readLruValue(ellipsisWidthCache, font);
969
+ if (cached != null) return cached;
970
+ return writeLruValue(ellipsisWidthCache, font, ctx.graphics.measureText(ELLIPSIS_GLYPH).width, ELLIPSIS_WIDTH_CACHE_CAPACITY);
971
+ }
972
+ function createEllipsisOnlyLayout(ctx, maxWidth, shift) {
973
+ const ellipsisWidth = measureEllipsisWidth(ctx);
974
+ if (ellipsisWidth > maxWidth) return {
975
+ width: 0,
976
+ text: "",
977
+ shift,
978
+ overflowed: true
979
+ };
980
+ return {
981
+ width: ellipsisWidth,
982
+ text: ELLIPSIS_GLYPH,
983
+ shift,
984
+ overflowed: true
985
+ };
986
+ }
987
+ function layoutPreparedEllipsis(ctx, prepared, text, maxWidth, shift, position, forceEllipsis = false) {
988
+ const intrinsicWidth = measurePreparedWidth(prepared);
989
+ if (!forceEllipsis && intrinsicWidth <= maxWidth) return {
990
+ width: intrinsicWidth,
991
+ text,
992
+ shift,
993
+ overflowed: false
994
+ };
995
+ const ellipsisWidth = measureEllipsisWidth(ctx);
996
+ if (ellipsisWidth > maxWidth) return {
997
+ width: 0,
998
+ text: "",
999
+ shift,
1000
+ overflowed: true
1001
+ };
1002
+ const units = getPreparedUnits(prepared);
1003
+ if (units.length === 0) return createEllipsisOnlyLayout(ctx, maxWidth, shift);
1004
+ const availableWidth = Math.max(0, maxWidth - ellipsisWidth);
1005
+ const prefixWidths = buildUnitPrefixWidths(units);
1006
+ const suffixWidths = buildUnitSuffixWidths(units);
1007
+ let prefixCount = 0;
1008
+ let suffixCount = 0;
1009
+ switch (position) {
1010
+ case "start":
1011
+ suffixCount = Math.min(units.length, findMaxFittingCount(suffixWidths, availableWidth));
1012
+ break;
1013
+ case "middle": {
1014
+ let bestVisibleUnits = -1;
1015
+ let bestBalanceScore = Number.NEGATIVE_INFINITY;
1016
+ for (let nextPrefixCount = 0; nextPrefixCount <= units.length; nextPrefixCount += 1) {
1017
+ const prefixWidth = prefixWidths[nextPrefixCount] ?? 0;
1018
+ if (prefixWidth > availableWidth) break;
1019
+ const remainingWidth = availableWidth - prefixWidth;
1020
+ const maxSuffixCount = units.length - nextPrefixCount;
1021
+ const nextSuffixCount = Math.min(maxSuffixCount, findMaxFittingCount(suffixWidths, remainingWidth));
1022
+ const visibleUnits = nextPrefixCount + nextSuffixCount;
1023
+ const balanceScore = -Math.abs(nextPrefixCount - nextSuffixCount);
1024
+ if (visibleUnits > bestVisibleUnits || visibleUnits === bestVisibleUnits && balanceScore > bestBalanceScore || visibleUnits === bestVisibleUnits && balanceScore === bestBalanceScore && nextPrefixCount > prefixCount) {
1025
+ prefixCount = nextPrefixCount;
1026
+ suffixCount = nextSuffixCount;
1027
+ bestVisibleUnits = visibleUnits;
1028
+ bestBalanceScore = balanceScore;
1029
+ }
1030
+ }
1031
+ break;
1032
+ }
1033
+ case "end":
1034
+ prefixCount = Math.min(units.length, findMaxFittingCount(prefixWidths, availableWidth));
1035
+ break;
1036
+ }
1037
+ const prefixWidth = prefixWidths[prefixCount] ?? 0;
1038
+ const suffixWidth = suffixWidths[suffixCount] ?? 0;
1039
+ const prefixText = joinUnitText(units, 0, prefixCount);
1040
+ const suffixText = joinUnitText(units, units.length - suffixCount, units.length);
1041
+ return {
1042
+ width: prefixWidth + ellipsisWidth + suffixWidth,
1043
+ text: `${prefixText}${ELLIPSIS_GLYPH}${suffixText}`,
1044
+ shift,
1045
+ overflowed: true
1046
+ };
1047
+ }
1048
+ function normalizeMaxLines(maxLines) {
1049
+ if (maxLines == null || !Number.isFinite(maxLines)) return;
1050
+ return Math.max(1, Math.trunc(maxLines));
1051
+ }
881
1052
  function layoutFirstLineIntrinsic(ctx, text, whitespace = "preserve") {
882
1053
  const segment = preprocessSegments(text, whitespace)[0];
883
1054
  if (!segment) return {
@@ -954,6 +1125,28 @@ function layoutFirstLine(ctx, text, maxWidth, whitespace = "preserve") {
954
1125
  shift
955
1126
  };
956
1127
  }
1128
+ function layoutEllipsizedFirstLine(ctx, text, maxWidth, ellipsisPosition = "end", whitespace = "preserve") {
1129
+ if (maxWidth < 0) maxWidth = 0;
1130
+ const segment = preprocessSegments(text, whitespace)[0];
1131
+ if (!segment) return {
1132
+ width: 0,
1133
+ text: "",
1134
+ shift: 0,
1135
+ overflowed: false
1136
+ };
1137
+ const shift = measureFontShift(ctx);
1138
+ if (maxWidth === 0) return {
1139
+ width: 0,
1140
+ text: "",
1141
+ shift,
1142
+ overflowed: true
1143
+ };
1144
+ return layoutPreparedEllipsis(ctx, readPreparedSegment(segment, ctx.graphics.font), segment, maxWidth, shift, ellipsisPosition);
1145
+ }
1146
+ function layoutForcedEllipsizedLine(ctx, text, maxWidth, shift) {
1147
+ if (text.length === 0) return createEllipsisOnlyLayout(ctx, maxWidth, shift);
1148
+ return layoutPreparedEllipsis(ctx, readPreparedSegment(text, ctx.graphics.font), text, maxWidth, shift, "end", true);
1149
+ }
957
1150
  function measureText(ctx, text, maxWidth, whitespace = "preserve") {
958
1151
  if (maxWidth < 0) maxWidth = 0;
959
1152
  const segments = preprocessSegments(text, whitespace);
@@ -979,7 +1172,7 @@ function measureText(ctx, text, maxWidth, whitespace = "preserve") {
979
1172
  lineCount
980
1173
  };
981
1174
  }
982
- function measureTextMinContent(ctx, text, whitespace = "preserve") {
1175
+ function measureTextMinContent(ctx, text, whitespace = "preserve", overflowWrap = "break-word") {
983
1176
  const segments = preprocessSegments(text, whitespace);
984
1177
  if (segments.length === 0) return {
985
1178
  width: 0,
@@ -990,7 +1183,7 @@ function measureTextMinContent(ctx, text, whitespace = "preserve") {
990
1183
  for (const segment of segments) {
991
1184
  if (segment.length === 0) continue;
992
1185
  const prepared = readPreparedSegment(segment, font);
993
- width = Math.max(width, measurePreparedMinContentWidth(prepared));
1186
+ width = Math.max(width, measurePreparedMinContentWidth(prepared, overflowWrap));
994
1187
  }
995
1188
  let lineCount = 0;
996
1189
  const lineMaxWidth = Math.max(width, MIN_CONTENT_WIDTH_EPSILON);
@@ -1042,6 +1235,44 @@ function layoutText(ctx, text, maxWidth, whitespace = "preserve") {
1042
1235
  lines
1043
1236
  };
1044
1237
  }
1238
+ function layoutTextWithOverflow(ctx, text, maxWidth, options = {}) {
1239
+ const whitespace = options.whitespace ?? "preserve";
1240
+ const overflow = options.overflow ?? "clip";
1241
+ const normalizedMaxLines = normalizeMaxLines(options.maxLines);
1242
+ const layout = layoutText(ctx, text, maxWidth, whitespace);
1243
+ if (normalizedMaxLines == null || layout.lines.length <= normalizedMaxLines) return {
1244
+ width: layout.width,
1245
+ lines: layout.lines.map((line) => ({
1246
+ ...line,
1247
+ overflowed: false
1248
+ })),
1249
+ overflowed: false
1250
+ };
1251
+ const visibleLines = layout.lines.slice(0, normalizedMaxLines);
1252
+ if (overflow !== "ellipsis") return {
1253
+ width: visibleLines.reduce((lineWidth, line) => Math.max(lineWidth, line.width), 0),
1254
+ lines: visibleLines.map((line) => ({
1255
+ ...line,
1256
+ overflowed: false
1257
+ })),
1258
+ overflowed: true
1259
+ };
1260
+ const shift = visibleLines[visibleLines.length - 1]?.shift ?? measureFontShift(ctx);
1261
+ const lastVisibleLine = visibleLines[visibleLines.length - 1];
1262
+ const ellipsizedLastLine = lastVisibleLine == null || lastVisibleLine.text.length === 0 ? createEllipsisOnlyLayout(ctx, Math.max(0, maxWidth), shift) : layoutForcedEllipsizedLine(ctx, lastVisibleLine.text, maxWidth, shift);
1263
+ const lines = [...visibleLines.slice(0, -1).map((line) => ({
1264
+ ...line,
1265
+ overflowed: false
1266
+ })), {
1267
+ ...ellipsizedLastLine,
1268
+ shift
1269
+ }];
1270
+ return {
1271
+ width: lines.reduce((lineWidth, line) => Math.max(lineWidth, line.width), 0),
1272
+ lines,
1273
+ overflowed: true
1274
+ };
1275
+ }
1045
1276
  //#endregion
1046
1277
  //#region src/nodes/text.ts
1047
1278
  function resolvePhysicalTextAlign(options) {
@@ -1077,29 +1308,59 @@ function getMultiLineMeasureLayoutKey(maxWidth) {
1077
1308
  function getMultiLineDrawLayoutKey(maxWidth) {
1078
1309
  return maxWidth == null ? "multi:draw:intrinsic" : `multi:draw:${maxWidth}`;
1079
1310
  }
1311
+ function getMultiLineOverflowLayoutKey(maxWidth) {
1312
+ return maxWidth == null ? "multi:overflow:intrinsic" : `multi:overflow:${maxWidth}`;
1313
+ }
1314
+ function shouldUseMultilineOverflowLayout(options) {
1315
+ return options.maxLines != null;
1316
+ }
1080
1317
  function getSingleLineMinContentLayoutKey() {
1081
1318
  return "single:min-content";
1082
1319
  }
1083
1320
  function getMultiLineMinContentLayoutKey() {
1084
1321
  return "multi:min-content";
1085
1322
  }
1086
- function getSingleLineLayout(node, ctx, text, whitespace) {
1323
+ function getSingleLineLayout(node, ctx, text, options) {
1087
1324
  const maxWidth = normalizeTextMaxWidth(ctx.constraints?.maxWidth);
1088
- return readCachedTextLayout(node, ctx, getSingleLineLayoutKey(maxWidth), () => maxWidth == null ? layoutFirstLineIntrinsic(ctx, text, whitespace) : layoutFirstLine(ctx, text, maxWidth, whitespace));
1325
+ return readCachedTextLayout(node, ctx, getSingleLineLayoutKey(maxWidth), () => maxWidth == null ? layoutFirstLineIntrinsic(ctx, text, options.whitespace) : options.overflow === "ellipsis" ? layoutEllipsizedFirstLine(ctx, text, maxWidth, options.ellipsisPosition ?? "end", options.whitespace) : layoutFirstLine(ctx, text, maxWidth, options.whitespace));
1089
1326
  }
1090
- function getMultiLineMeasureLayout(node, ctx, text, whitespace) {
1327
+ function getMultiLineOverflowLayout(node, ctx, text, options) {
1091
1328
  const maxWidth = normalizeTextMaxWidth(ctx.constraints?.maxWidth);
1092
- return readCachedTextLayout(node, ctx, getMultiLineMeasureLayoutKey(maxWidth), () => maxWidth == null ? measureTextIntrinsic(ctx, text, whitespace) : measureText(ctx, text, maxWidth, whitespace));
1329
+ return readCachedTextLayout(node, ctx, getMultiLineOverflowLayoutKey(maxWidth), () => layoutTextWithOverflow(ctx, text, maxWidth ?? 0, {
1330
+ whitespace: options.whitespace,
1331
+ overflow: options.overflow,
1332
+ maxLines: options.maxLines
1333
+ }));
1093
1334
  }
1094
- function getMultiLineDrawLayout(node, ctx, text, whitespace) {
1335
+ function getMultiLineMeasureLayout(node, ctx, text, options) {
1095
1336
  const maxWidth = normalizeTextMaxWidth(ctx.constraints?.maxWidth);
1096
- return readCachedTextLayout(node, ctx, getMultiLineDrawLayoutKey(maxWidth), () => maxWidth == null ? layoutTextIntrinsic(ctx, text, whitespace) : layoutText(ctx, text, maxWidth, whitespace));
1337
+ if (maxWidth != null && shouldUseMultilineOverflowLayout(options)) {
1338
+ const layout = getMultiLineOverflowLayout(node, ctx, text, options);
1339
+ return {
1340
+ width: layout.width,
1341
+ lineCount: layout.lines.length
1342
+ };
1343
+ }
1344
+ return readCachedTextLayout(node, ctx, getMultiLineMeasureLayoutKey(maxWidth), () => maxWidth == null ? measureTextIntrinsic(ctx, text, options.whitespace) : measureText(ctx, text, maxWidth, options.whitespace));
1345
+ }
1346
+ function getMultiLineDrawLayout(node, ctx, text, options) {
1347
+ const maxWidth = normalizeTextMaxWidth(ctx.constraints?.maxWidth);
1348
+ if (maxWidth != null && shouldUseMultilineOverflowLayout(options)) return getMultiLineOverflowLayout(node, ctx, text, options);
1349
+ return readCachedTextLayout(node, ctx, getMultiLineDrawLayoutKey(maxWidth), () => maxWidth == null ? layoutTextIntrinsic(ctx, text, options.whitespace) : layoutText(ctx, text, maxWidth, options.whitespace));
1097
1350
  }
1098
- function getSingleLineMinContentLayout(node, ctx, text, whitespace) {
1099
- return readCachedTextLayout(node, ctx, getSingleLineMinContentLayoutKey(), () => layoutFirstLineIntrinsic(ctx, text, whitespace));
1351
+ function getSingleLineMinContentLayout(node, ctx, text, options) {
1352
+ return readCachedTextLayout(node, ctx, getSingleLineMinContentLayoutKey(), () => {
1353
+ const measurement = measureTextMinContent(ctx, text, options.whitespace, options.overflowWrap);
1354
+ const { shift } = layoutFirstLineIntrinsic(ctx, text, options.whitespace);
1355
+ return {
1356
+ width: measurement.width,
1357
+ text,
1358
+ shift
1359
+ };
1360
+ });
1100
1361
  }
1101
- function getMultiLineMinContentLayout(node, ctx, text, whitespace) {
1102
- return readCachedTextLayout(node, ctx, getMultiLineMinContentLayoutKey(), () => measureTextMinContent(ctx, text, whitespace));
1362
+ function getMultiLineMinContentLayout(node, ctx, text, whitespace, overflowWrap) {
1363
+ return readCachedTextLayout(node, ctx, getMultiLineMinContentLayoutKey(), () => measureTextMinContent(ctx, text, whitespace, overflowWrap));
1103
1364
  }
1104
1365
  /**
1105
1366
  * Draws wrapped text using the configured line height and alignment.
@@ -1116,7 +1377,7 @@ var MultilineText = class {
1116
1377
  measure(ctx) {
1117
1378
  return ctx.with((g) => {
1118
1379
  g.font = this.options.font;
1119
- const { width, lineCount } = getMultiLineMeasureLayout(this, ctx, this.text, this.options.whitespace);
1380
+ const { width, lineCount } = getMultiLineMeasureLayout(this, ctx, this.text, this.options);
1120
1381
  return {
1121
1382
  width,
1122
1383
  height: lineCount * this.options.lineHeight
@@ -1126,7 +1387,7 @@ var MultilineText = class {
1126
1387
  measureMinContent(ctx) {
1127
1388
  return ctx.with((g) => {
1128
1389
  g.font = this.options.font;
1129
- const { width, lineCount } = getMultiLineMinContentLayout(this, ctx, this.text, this.options.whitespace);
1390
+ const { width, lineCount } = getMultiLineMinContentLayout(this, ctx, this.text, this.options.whitespace, this.options.overflowWrap);
1130
1391
  return {
1131
1392
  width,
1132
1393
  height: lineCount * this.options.lineHeight
@@ -1137,7 +1398,7 @@ var MultilineText = class {
1137
1398
  return ctx.with((g) => {
1138
1399
  g.font = this.options.font;
1139
1400
  g.fillStyle = ctx.resolveDynValue(this.options.style);
1140
- const { width, lines } = getMultiLineDrawLayout(this, ctx, this.text, this.options.whitespace);
1401
+ const { width, lines } = getMultiLineDrawLayout(this, ctx, this.text, this.options);
1141
1402
  switch (resolvePhysicalTextAlign(this.options)) {
1142
1403
  case "left":
1143
1404
  for (const { text, shift } of lines) {
@@ -1184,7 +1445,7 @@ var Text = class {
1184
1445
  measure(ctx) {
1185
1446
  return ctx.with((g) => {
1186
1447
  g.font = this.options.font;
1187
- const { width } = getSingleLineLayout(this, ctx, this.text, this.options.whitespace);
1448
+ const { width } = getSingleLineLayout(this, ctx, this.text, this.options);
1188
1449
  return {
1189
1450
  width,
1190
1451
  height: this.options.lineHeight
@@ -1194,7 +1455,7 @@ var Text = class {
1194
1455
  measureMinContent(ctx) {
1195
1456
  return ctx.with((g) => {
1196
1457
  g.font = this.options.font;
1197
- const { width } = getSingleLineMinContentLayout(this, ctx, this.text, this.options.whitespace);
1458
+ const { width } = getSingleLineMinContentLayout(this, ctx, this.text, this.options);
1198
1459
  return {
1199
1460
  width,
1200
1461
  height: this.options.lineHeight
@@ -1205,7 +1466,7 @@ var Text = class {
1205
1466
  return ctx.with((g) => {
1206
1467
  g.font = this.options.font;
1207
1468
  g.fillStyle = ctx.resolveDynValue(this.options.style);
1208
- const { text, shift } = getSingleLineLayout(this, ctx, this.text, this.options.whitespace);
1469
+ const { text, shift } = getSingleLineLayout(this, ctx, this.text, this.options);
1209
1470
  g.fillText(text, x, y + (this.options.lineHeight + shift) / 2);
1210
1471
  return false;
1211
1472
  });