chat-layout 1.2.0-0 → 1.2.0-1

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
@@ -7,13 +7,14 @@ The current v2-style APIs are:
7
7
  - `Flex`: row/column layout
8
8
  - `FlexItem`: explicit `grow` / `shrink` / `alignSelf`
9
9
  - `Place`: place a single child at `start` / `center` / `end`
10
+ - `ShrinkWrap`: search the narrowest width that keeps the current height stable
10
11
  - `MultilineText`: text layout with logical `align` or physical `physicalAlign`
11
12
  - `ChatRenderer` + `ListState`: virtualized chat rendering
12
13
  - `memoRenderItem` / `memoRenderItemBy`: item render memoization
13
14
 
14
15
  ## Quick example
15
16
 
16
- Use `Flex` to build structure, `FlexItem` to control resize behavior, and `Place` to align the final bubble:
17
+ Use `Flex` to build structure, `FlexItem` to control resize behavior, `ShrinkWrap` to keep the bubble as narrow as possible without adding lines, and `Place` to align the final bubble:
17
18
 
18
19
  ```ts
19
20
  const bubble = new RoundedBox(
@@ -26,17 +27,27 @@ const bubble = new RoundedBox(
26
27
  { top: 6, bottom: 6, left: 10, right: 10, radii: 8, fill: "#ccc" },
27
28
  );
28
29
 
30
+ const body = new ShrinkWrap(
31
+ new Flex(
32
+ [senderLine, bubble],
33
+ { direction: "column", gap: 4, alignItems: item.sender === "A" ? "end" : "start" },
34
+ ),
35
+ );
36
+
29
37
  const row = new Flex(
30
38
  [
31
39
  avatar,
32
- new FlexItem(bubble, { grow: 1, shrink: 1 }),
40
+ new FlexItem(
41
+ new Place(body, {
42
+ align: item.sender === "A" ? "end" : "start",
43
+ }),
44
+ { grow: 1, shrink: 1 },
45
+ ),
33
46
  ],
34
47
  { direction: "row", gap: 4, reverse: item.sender === "A" },
35
48
  );
36
49
 
37
- return new Place(row, {
38
- align: item.sender === "A" ? "end" : "start",
39
- });
50
+ return row;
40
51
  ```
41
52
 
42
53
  See [example/chat.ts](./example/chat.ts) for a full chat example.
@@ -47,6 +58,7 @@ See [example/chat.ts](./example/chat.ts) for a full chat example.
47
58
  - `maxWidth` / `maxHeight` limit measurement, but do not automatically make children fill the cross axis.
48
59
  - Use `alignItems: "stretch"` or `alignSelf: "stretch"` when a child should fill the computed cross size.
49
60
  - `Place` is the simplest way to align a single bubble left, center, or right.
61
+ - `ShrinkWrap` is useful when a bubble sits inside a growable slot but should still collapse to the narrowest width that preserves its current line count.
50
62
  - `MultilineText.align` uses logical values: `start`, `center`, `end`.
51
63
  - `MultilineText.physicalAlign` uses physical values: `left`, `center`, `right`.
52
64
  - `Text` and `MultilineText` default to `whiteSpace: "normal"`, using the library's canvas-first collapsible whitespace behavior.
@@ -103,6 +115,7 @@ Notes:
103
115
  - Shrink only applies when there is a finite main-axis constraint and total content size overflows it.
104
116
  - Overflow is redistributed by `shrink * basis`; today `basis` is internal-only and always `"auto"`.
105
117
  - Custom nodes can implement `measureMinContent()` for better shrink results.
118
+ - `ShrinkWrap` complements flex shrink: it keeps probing narrower `maxWidth` values until the child would become taller, then uses the last safe width as the final layout.
106
119
  - Known limitation: column shrink with `MultilineText` does not clip drawing by itself.
107
120
 
108
121
  ## Migration notes
package/example/chat.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  MultilineText,
8
8
  PaddingBox,
9
9
  Place,
10
+ ShrinkWrap,
10
11
  Text,
11
12
  Wrapper,
12
13
  memoRenderItem,
@@ -357,7 +358,11 @@ const renderItem = memoRenderItem((item: ChatItem): Node<C> => {
357
358
  alignItems: item.sender === "A" ? "end" : "start",
358
359
  });
359
360
 
360
- const alignedBody = new Place<C>(body, {
361
+ const shrinkWrappedBody = new ShrinkWrap<C>(body, {
362
+ preferredMinWidth: 160,
363
+ });
364
+
365
+ const alignedBody = new Place<C>(shrinkWrappedBody, {
361
366
  align: item.sender === "A" ? "end" : "start",
362
367
  });
363
368
 
package/index.d.mts CHANGED
@@ -364,6 +364,24 @@ declare class Place<C extends CanvasRenderingContext2D> extends Wrapper<C> {
364
364
  hittest(ctx: Context<C>, test: HitTest): boolean;
365
365
  }
366
366
  //#endregion
367
+ //#region src/nodes/shrinkwrap.d.ts
368
+ interface ShrinkWrapOptions {
369
+ tolerance?: number;
370
+ preferredMinWidth?: number;
371
+ }
372
+ /**
373
+ * Shrinks a single child to the narrowest width that does not increase its reference height.
374
+ */
375
+ declare class ShrinkWrap<C extends CanvasRenderingContext2D> extends Wrapper<C> {
376
+ #private;
377
+ readonly options: ShrinkWrapOptions;
378
+ constructor(inner: Node<C>, options?: ShrinkWrapOptions);
379
+ measure(ctx: Context<C>): Box;
380
+ measureMinContent(ctx: Context<C>): Box;
381
+ draw(ctx: Context<C>, x: number, y: number): boolean;
382
+ hittest(ctx: Context<C>, test: HitTest): boolean;
383
+ }
384
+ //#endregion
367
385
  //#region src/nodes/text.d.ts
368
386
  /**
369
387
  * Draws wrapped text using the configured line height and alignment.
@@ -658,5 +676,5 @@ declare class TimelineRenderer<C extends CanvasRenderingContext2D, T extends {}>
658
676
  hittest(test: HitTest): boolean;
659
677
  }
660
678
  //#endregion
661
- export { Axis, BaseRenderer, Box, ChatRenderer, ChildLayoutResult, Context, CrossAxisAlignment, DebugRenderer, DynValue, Fixed, Flex, FlexContainerOptions, FlexItem, FlexItemOptions, FlexLayoutResult, Group, HitTest, InlineSpan, JumpToOptions, LayoutConstraints, LayoutRect, ListState, MainAxisAlignment, MainAxisSize, MultilineText, MultilineTextOptions, Node, PaddingBox, PhysicalTextAlign, Place, RenderFeedback, RendererOptions, ReplaceListItemAnimationOptions, Text, TextAlign, TextEllipsisPosition, TextOptions, TextOverflowMode, TextOverflowWrapMode, TextStyleOptions, TextWhiteSpaceMode, TextWordBreakMode, TimelineRenderer, VirtualizedRenderer, Wrapper, memoRenderItem, memoRenderItemBy };
679
+ export { Axis, BaseRenderer, Box, ChatRenderer, ChildLayoutResult, Context, CrossAxisAlignment, DebugRenderer, DynValue, Fixed, Flex, FlexContainerOptions, FlexItem, FlexItemOptions, FlexLayoutResult, Group, HitTest, InlineSpan, JumpToOptions, LayoutConstraints, LayoutRect, ListState, MainAxisAlignment, MainAxisSize, MultilineText, MultilineTextOptions, Node, PaddingBox, PhysicalTextAlign, Place, RenderFeedback, RendererOptions, ReplaceListItemAnimationOptions, ShrinkWrap, Text, TextAlign, TextEllipsisPosition, TextOptions, TextOverflowMode, TextOverflowWrapMode, TextStyleOptions, TextWhiteSpaceMode, TextWordBreakMode, TimelineRenderer, VirtualizedRenderer, Wrapper, memoRenderItem, memoRenderItemBy };
662
680
  //# sourceMappingURL=index.d.mts.map
package/index.mjs CHANGED
@@ -814,6 +814,112 @@ var Place = class extends Wrapper {
814
814
  });
815
815
  }
816
816
  };
817
+ //#endregion
818
+ //#region src/nodes/shrinkwrap.ts
819
+ const DEFAULT_TOLERANCE = .5;
820
+ const HEIGHT_EPSILON = 1e-6;
821
+ function withMaxWidth(constraints, maxWidth) {
822
+ return {
823
+ ...constraints,
824
+ maxWidth
825
+ };
826
+ }
827
+ function computeShrinkwrapWidth(measure, lowerBound, upperBound, referenceHeight, tolerance = DEFAULT_TOLERANCE) {
828
+ const minWidth = Math.min(lowerBound, upperBound);
829
+ const maxWidth = Math.max(lowerBound, upperBound);
830
+ const effectiveTolerance = Math.max(tolerance, HEIGHT_EPSILON);
831
+ const lowerBoundBox = measure(minWidth);
832
+ if (lowerBoundBox.height <= referenceHeight + HEIGHT_EPSILON) return {
833
+ maxWidth: minWidth,
834
+ box: lowerBoundBox
835
+ };
836
+ let lo = minWidth;
837
+ let hi = maxWidth;
838
+ let hiBox = measure(maxWidth);
839
+ while (hi - lo > effectiveTolerance) {
840
+ const probeWidth = (lo + hi) / 2;
841
+ const probeBox = measure(probeWidth);
842
+ if (probeBox.height <= referenceHeight + HEIGHT_EPSILON) {
843
+ hi = probeWidth;
844
+ hiBox = probeBox;
845
+ continue;
846
+ }
847
+ lo = probeWidth;
848
+ }
849
+ return {
850
+ maxWidth: hi,
851
+ box: hiBox
852
+ };
853
+ }
854
+ /**
855
+ * Shrinks a single child to the narrowest width that does not increase its reference height.
856
+ */
857
+ var ShrinkWrap = class extends Wrapper {
858
+ constructor(inner, options = {}) {
859
+ super(inner);
860
+ this.options = options;
861
+ }
862
+ measure(ctx) {
863
+ const constraints = ctx.constraints;
864
+ const availableWidth = constraints?.maxWidth;
865
+ if (availableWidth == null) {
866
+ const childConstraints = constraints == null ? void 0 : { ...constraints };
867
+ const childBox = ctx.measureNode(this.inner, childConstraints);
868
+ this.#writeLayout(ctx, childBox, childConstraints);
869
+ return childBox;
870
+ }
871
+ const boundedConstraints = constraints == null ? { maxWidth: availableWidth } : constraints;
872
+ const referenceConstraints = { ...boundedConstraints };
873
+ const referenceBox = ctx.measureNode(this.inner, referenceConstraints);
874
+ let lowerBound = measureNodeMinContent(ctx, this.inner, boundedConstraints).width;
875
+ const preferredMinWidth = this.options.preferredMinWidth == null ? void 0 : Math.max(0, this.options.preferredMinWidth);
876
+ if (preferredMinWidth != null && preferredMinWidth <= availableWidth) lowerBound = Math.max(lowerBound, preferredMinWidth);
877
+ if (boundedConstraints.minWidth != null) lowerBound = Math.max(lowerBound, boundedConstraints.minWidth);
878
+ if (lowerBound >= availableWidth) {
879
+ this.#writeLayout(ctx, referenceBox, referenceConstraints);
880
+ return referenceBox;
881
+ }
882
+ const finalConstraints = withMaxWidth(boundedConstraints, computeShrinkwrapWidth((maxWidth) => ctx.measureNode(this.inner, withMaxWidth(boundedConstraints, maxWidth)), lowerBound, availableWidth, referenceBox.height, this.options.tolerance ?? DEFAULT_TOLERANCE).maxWidth);
883
+ const finalBox = ctx.measureNode(this.inner, finalConstraints);
884
+ this.#writeLayout(ctx, finalBox, finalConstraints);
885
+ return finalBox;
886
+ }
887
+ measureMinContent(ctx) {
888
+ return measureNodeMinContent(ctx, this.inner);
889
+ }
890
+ draw(ctx, x, y) {
891
+ const layoutResult = readLayoutResult(this, ctx);
892
+ if (!layoutResult) return this.inner.draw(ctx, x, y);
893
+ const childResult = getSingleChildLayout(layoutResult);
894
+ if (!childResult) return false;
895
+ return childResult.node.draw(withConstraints(ctx, childResult.constraints), x + childResult.rect.x, y + childResult.rect.y);
896
+ }
897
+ hittest(ctx, test) {
898
+ const layoutResult = readLayoutResult(this, ctx);
899
+ if (!layoutResult) return false;
900
+ const hit = findChildAtPoint(layoutResult.children, test.x, test.y, "rect");
901
+ if (!hit) return false;
902
+ return hit.child.node.hittest(withConstraints(ctx, hit.child.constraints), {
903
+ ...test,
904
+ x: hit.localX,
905
+ y: hit.localY
906
+ });
907
+ }
908
+ #writeLayout(ctx, childBox, childConstraints) {
909
+ const childRect = createRect(0, 0, childBox.width, childBox.height);
910
+ writeLayoutResult(this, ctx, {
911
+ containerBox: childRect,
912
+ contentBox: childRect,
913
+ children: [{
914
+ node: this.inner,
915
+ rect: childRect,
916
+ contentBox: childRect,
917
+ constraints: childConstraints
918
+ }],
919
+ constraints: ctx.constraints
920
+ });
921
+ }
922
+ };
817
923
  Number.POSITIVE_INFINITY;
818
924
  const MIN_CONTENT_WIDTH_EPSILON = .001;
819
925
  let sharedGraphemeSegmenter;
@@ -4066,6 +4172,6 @@ var TimelineRenderer = class extends VirtualizedRenderer {
4066
4172
  }
4067
4173
  };
4068
4174
  //#endregion
4069
- export { BaseRenderer, ChatRenderer, DebugRenderer, Fixed, Flex, FlexItem, Group, ListState, MultilineText, PaddingBox, Place, Text, TimelineRenderer, VirtualizedRenderer, Wrapper, memoRenderItem, memoRenderItemBy };
4175
+ export { BaseRenderer, ChatRenderer, DebugRenderer, Fixed, Flex, FlexItem, Group, ListState, MultilineText, PaddingBox, Place, ShrinkWrap, Text, TimelineRenderer, VirtualizedRenderer, Wrapper, memoRenderItem, memoRenderItemBy };
4070
4176
 
4071
4177
  //# sourceMappingURL=index.mjs.map