{"id":22157530,"url":"https://github.com/codehz/chat-layout","last_synced_at":"2026-04-12T18:32:23.601Z","repository":{"id":265258091,"uuid":"895610141","full_name":"codehz/chat-layout","owner":"codehz","description":null,"archived":false,"fork":false,"pushed_at":"2026-04-03T04:36:56.000Z","size":261,"stargazers_count":2,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-04-03T04:58:43.241Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/codehz.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2024-11-28T14:28:49.000Z","updated_at":"2026-04-03T04:37:00.000Z","dependencies_parsed_at":"2025-03-24T14:47:57.541Z","dependency_job_id":"0949a75f-d7fd-4dc1-a27d-322dd6a0f716","html_url":"https://github.com/codehz/chat-layout","commit_stats":null,"previous_names":["codehz/chat-layout"],"tags_count":39,"template":false,"template_full_name":null,"purl":"pkg:github/codehz/chat-layout","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/codehz%2Fchat-layout","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/codehz%2Fchat-layout/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/codehz%2Fchat-layout/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/codehz%2Fchat-layout/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/codehz","download_url":"https://codeload.github.com/codehz/chat-layout/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/codehz%2Fchat-layout/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31584999,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-08T14:31:17.711Z","status":"online","status_checked_at":"2026-04-09T02:00:06.848Z","response_time":112,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-12-02T03:10:10.982Z","updated_at":"2026-04-12T18:32:23.589Z","avatar_url":"https://github.com/codehz.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# chat-layout\n\nCanvas-based layout primitives for chat and timeline UIs.\n\nThe current v2-style APIs are:\n\n- `Flex`: row/column layout\n- `FlexItem`: explicit `grow` / `shrink` / `alignSelf`\n- `Place`: place a single child at `start` / `center` / `end`\n- `ShrinkWrap`: search the narrowest width that keeps the current height stable\n- `MultilineText`: text layout with logical `align` or physical `physicalAlign`\n- `ListRenderer` + `ListState`: virtualized chat or timeline rendering\n- `memoRenderItem` / `memoRenderItemBy`: item render memoization\n\n## Quick example\n\nUse `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:\n\n```ts\nconst bubble = new RoundedBox(\n  new MultilineText(item.content, {\n    lineHeight: 20,\n    font: \"16px system-ui\",\n    color: \"black\",\n    align: \"start\",\n  }),\n  { top: 6, bottom: 6, left: 10, right: 10, radii: 8, fill: \"#ccc\" },\n);\n\nconst body = new ShrinkWrap(\n  new Flex([senderLine, bubble], {\n    direction: \"column\",\n    gap: 4,\n    alignItems: item.sender === \"A\" ? \"end\" : \"start\",\n  }),\n);\n\nconst row = new Flex(\n  [\n    avatar,\n    new FlexItem(\n      new Place(body, {\n        align: item.sender === \"A\" ? \"end\" : \"start\",\n      }),\n      { grow: 1, shrink: 1 },\n    ),\n  ],\n  { direction: \"row\", gap: 4, reverse: item.sender === \"A\" },\n);\n\nreturn row;\n```\n\nSee [example/chat.ts](./example/chat.ts) for a full chat example.\n\n## List insert animation\n\n`pushAll()` and `unshiftAll()` can opt into short-list insertion animations. They only animate when the previous rendered frame still had spare space below the last item; otherwise they fall back to the normal hard cut:\n\n```ts\nlist.pushAll([nextMessage], {\n  distance: 24, // duration defaults to 220ms when animation options are present\n});\n\nlist.unshiftAll([olderMessage], {\n  duration: 220,\n});\n```\n\nTo make chat-style inserts automatically follow the latest visible edge, pass `autoFollow: true`. When the corresponding auto-follow latch is armed, the insert behaves like a conditional `jumpToTop()` / `jumpToBottom()` after the items are inserted:\n\n```ts\nlist.pushAll([nextMessage], {\n  autoFollow: true,\n  duration: 220,\n});\n```\n\n## Layout notes\n\n- `Flex` handles the main axis only. It shrink-wraps on the cross axis unless you opt into stretch behavior.\n- `maxWidth` / `maxHeight` limit measurement, but do not automatically make children fill the cross axis.\n- Use `alignItems: \"stretch\"` or `alignSelf: \"stretch\"` when a child should fill the computed cross size.\n- `Place` is the simplest way to align a single bubble left, center, or right.\n- `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.\n- `MultilineText.align` uses logical values: `start`, `center`, `end`.\n- `MultilineText.physicalAlign` uses physical values: `left`, `center`, `right`.\n- `Text` and `MultilineText` default to `whiteSpace: \"normal\"`, using the library's canvas-first collapsible whitespace behavior.\n- Use `whiteSpace: \"pre-wrap\"` when blank lines, hard breaks, or edge spaces must stay visible.\n- `Text` and `MultilineText` default to `overflowWrap: \"break-word\"`, which preserves compatibility-first min-content sizing for shrink layouts.\n- Use `overflowWrap: \"anywhere\"` when long unspaced strings should contribute grapheme-level breakpoints to min-content sizing.\n- `Text` supports `overflow: \"ellipsis\"` with `ellipsisPosition: \"start\" | \"end\" | \"middle\"` when measured under a finite `maxWidth`.\n- `Text` and `MultilineText` both accept either a plain string or `InlineSpan[]` for mixed inline styles.\n- `MultilineText` supports `overflow: \"ellipsis\"` together with `maxLines`; values below `1` are treated as `1`.\n\n## Text ellipsis\n\nSingle-line `Text` can ellipsize at the start, end, or middle when a finite width constraint is present:\n\n```ts\nconst title = new Text(\n  [\n    { text: \"Extremely long \" },\n    { text: \"thread title\", font: \"700 16px system-ui\", color: \"#0f766e\" },\n    { text: \" that should not blow out the row\" },\n  ],\n  {\n    lineHeight: 20,\n    font: \"16px system-ui\",\n    color: \"#111\",\n    overflow: \"ellipsis\",\n    ellipsisPosition: \"middle\",\n  },\n);\n```\n\nMulti-line `MultilineText` can cap the visible line count and convert the last visible line to an end ellipsis:\n\n```ts\nconst preview = new MultilineText(reply.content, {\n  lineHeight: 16,\n  font: \"13px system-ui\",\n  color: \"#444\",\n  align: \"start\",\n  overflowWrap: \"anywhere\",\n  overflow: \"ellipsis\",\n  maxLines: 2,\n});\n```\n\nNotes:\n\n- Ellipsis is only inserted when the node is measured under a finite `maxWidth` and content actually overflows that constraint.\n- `MultilineText` only supports end ellipsis on the last visible line; start/middle ellipsis are intentionally single-line only.\n- `maxLines` defaults to unlimited, and values below `1` are clamped to `1`.\n- `overflowWrap: \"break-word\"` keeps the current min-content behavior; `overflowWrap: \"anywhere\"` lets long unspaced strings shrink inside flex layouts such as chat bubbles.\n- Current `measureMinContent()` behavior stays compatibility-first: ellipsis affects constrained measurement/drawing, but does not lower the min-content shrink floor by itself.\n\n## Text justification\n\n`MultilineText` supports two-end justification (justify) as a draw-phase decoration. It does not affect measurement or layout:\n\n```ts\nconst justified = new MultilineText(paragraph, {\n  lineHeight: 20,\n  font: \"16px system-ui\",\n  color: \"#111\",\n  align: \"start\",\n  justify: true, // or \"inter-word\" | \"inter-character\"\n  justifyLastLine: false, // default: last line uses normal alignment\n  justifyGapThreshold: 2.0, // max gap ratio before fallback\n});\n```\n\nNotes:\n\n- `justify: true` is equivalent to `\"inter-word\"` mode, which expands spaces between words via `ctx.wordSpacing`.\n- `\"inter-character\"` mode distributes extra space after every character via `ctx.letterSpacing`.\n- Requires browser support for `CanvasRenderingContext2D.wordSpacing` / `letterSpacing`. When unsupported, justify is silently disabled.\n- Lines that exceed `justifyGapThreshold`, have no expandable gaps, or are the last line (unless `justifyLastLine: true`) fall back to `align` / `physicalAlign`.\n- `overflow: \"ellipsis\"` truncated lines are never justified.\n- `measure()` and `measureMinContent()` are not affected by justify options.\n- Works with both plain text and `InlineSpan[]` rich text.\n\n## Shrink behavior\n\n- `FlexItemOptions.shrink` defaults to `0`, so old layouts keep their previous behavior unless you opt in.\n- Shrink only applies when there is a finite main-axis constraint and total content size overflows it.\n- Overflow is redistributed by `shrink * basis`; today `basis` is internal-only and always `\"auto\"`.\n- Custom nodes can implement `measureMinContent()` for better shrink results.\n- `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.\n- Known limitation: column shrink with `MultilineText` does not clip drawing by itself.\n\n## Migration notes\n\n- Use `memoRenderItemBy(keyOf, renderItem)` when list items are primitives.\n- `memoRenderItemBy()` now uses a bounded LRU cache by default; pass `{ maxEntries: Infinity }` to keep the old unbounded behavior explicitly.\n- `FlexItem` exposes `grow`, `shrink`, and `alignSelf`; `basis` is no longer public.\n- `MultilineText` now uses `align` / `physicalAlign` instead of `alignment`.\n- `ListState.position` uses `undefined` for the renderer default anchor.\n- Use `list.applyScroll(delta)` for relative scrolling, or renderer `jumpTo()` / `jumpToTop()` / `jumpToBottom()` for absolute navigation.\n\n## Development\n\nInstall dependencies:\n\n```bash\nbun install\n```\n\nType-check:\n\n```bash\nbun run typecheck\n```\n\nBuild distributable files:\n\n```bash\nbun run dist\n```\n\nBuild the chat example:\n\n```bash\nbun run example\n```\n\n文本性能观测基线见 `docs/text-performance.md`。\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcodehz%2Fchat-layout","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcodehz%2Fchat-layout","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcodehz%2Fchat-layout/lists"}