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 +40 -0
- package/example/chat.ts +20 -1
- package/index.d.mts +25 -2
- package/index.mjs +281 -20
- package/index.mjs.map +1 -1
- package/package.json +1 -1
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:
|
|
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)
|
|
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,
|
|
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
|
|
1327
|
+
function getMultiLineOverflowLayout(node, ctx, text, options) {
|
|
1091
1328
|
const maxWidth = normalizeTextMaxWidth(ctx.constraints?.maxWidth);
|
|
1092
|
-
return readCachedTextLayout(node, ctx,
|
|
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
|
|
1335
|
+
function getMultiLineMeasureLayout(node, ctx, text, options) {
|
|
1095
1336
|
const maxWidth = normalizeTextMaxWidth(ctx.constraints?.maxWidth);
|
|
1096
|
-
|
|
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,
|
|
1099
|
-
return readCachedTextLayout(node, ctx, getSingleLineMinContentLayoutKey(), () =>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
});
|