@tldraw/commenting 5.4.2 → 5.5.0-canary.0241f11f2e99

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.
Files changed (95) hide show
  1. package/commenting.css +0 -32
  2. package/dist-cjs/canvas/anchor-lifecycle.js +4 -5
  3. package/dist-cjs/canvas/anchor-lifecycle.js.map +2 -2
  4. package/dist-cjs/canvas/cluster-fade.js +1 -1
  5. package/dist-cjs/canvas/cluster-fade.js.map +2 -2
  6. package/dist-cjs/canvas/cluster-model.js +23 -31
  7. package/dist-cjs/canvas/cluster-model.js.map +2 -2
  8. package/dist-cjs/canvas/comment-render.js +19 -19
  9. package/dist-cjs/canvas/comment-render.js.map +2 -2
  10. package/dist-cjs/canvas/comment-store.js +10 -5
  11. package/dist-cjs/canvas/comment-store.js.map +2 -2
  12. package/dist-cjs/canvas/comments-overlay.js +12 -16
  13. package/dist-cjs/canvas/comments-overlay.js.map +2 -2
  14. package/dist-cjs/canvas/comments-sidebar.js +1 -7
  15. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  16. package/dist-cjs/canvas/hooks.js.map +1 -1
  17. package/dist-cjs/canvas/thread-pin.js +14 -26
  18. package/dist-cjs/canvas/thread-pin.js.map +2 -2
  19. package/dist-cjs/canvas/thread-stack.js +2 -6
  20. package/dist-cjs/canvas/thread-stack.js.map +2 -2
  21. package/dist-cjs/canvas/thread-state.js +13 -0
  22. package/dist-cjs/canvas/thread-state.js.map +2 -2
  23. package/dist-cjs/canvas/thread-view.js +67 -78
  24. package/dist-cjs/canvas/thread-view.js.map +2 -2
  25. package/dist-cjs/clustering/computeClusterTable.js +2 -9
  26. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  27. package/dist-cjs/clustering/replay.js +10 -23
  28. package/dist-cjs/clustering/replay.js.map +2 -2
  29. package/dist-cjs/clustering/runtime.js +9 -15
  30. package/dist-cjs/clustering/runtime.js.map +2 -2
  31. package/dist-cjs/index.js +1 -1
  32. package/dist-cjs/ui/comment-composer.js +2 -4
  33. package/dist-cjs/ui/comment-composer.js.map +2 -2
  34. package/dist-cjs/ui/format-time.js +20 -8
  35. package/dist-cjs/ui/format-time.js.map +2 -2
  36. package/dist-cjs/ui/reaction.js +5 -15
  37. package/dist-cjs/ui/reaction.js.map +2 -2
  38. package/dist-esm/canvas/anchor-lifecycle.mjs +5 -6
  39. package/dist-esm/canvas/anchor-lifecycle.mjs.map +2 -2
  40. package/dist-esm/canvas/cluster-fade.mjs +1 -1
  41. package/dist-esm/canvas/cluster-fade.mjs.map +2 -2
  42. package/dist-esm/canvas/cluster-model.mjs +24 -32
  43. package/dist-esm/canvas/cluster-model.mjs.map +2 -2
  44. package/dist-esm/canvas/comment-render.mjs +19 -19
  45. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  46. package/dist-esm/canvas/comment-store.mjs +10 -5
  47. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  48. package/dist-esm/canvas/comments-overlay.mjs +13 -17
  49. package/dist-esm/canvas/comments-overlay.mjs.map +2 -2
  50. package/dist-esm/canvas/comments-sidebar.mjs +3 -14
  51. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  52. package/dist-esm/canvas/hooks.mjs.map +1 -1
  53. package/dist-esm/canvas/thread-pin.mjs +16 -26
  54. package/dist-esm/canvas/thread-pin.mjs.map +2 -2
  55. package/dist-esm/canvas/thread-stack.mjs +3 -7
  56. package/dist-esm/canvas/thread-stack.mjs.map +2 -2
  57. package/dist-esm/canvas/thread-state.mjs +13 -0
  58. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  59. package/dist-esm/canvas/thread-view.mjs +67 -78
  60. package/dist-esm/canvas/thread-view.mjs.map +2 -2
  61. package/dist-esm/clustering/computeClusterTable.mjs +2 -9
  62. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  63. package/dist-esm/clustering/replay.mjs +10 -23
  64. package/dist-esm/clustering/replay.mjs.map +2 -2
  65. package/dist-esm/clustering/runtime.mjs +9 -15
  66. package/dist-esm/clustering/runtime.mjs.map +2 -2
  67. package/dist-esm/index.mjs +1 -1
  68. package/dist-esm/ui/comment-composer.mjs +2 -4
  69. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  70. package/dist-esm/ui/format-time.mjs +20 -8
  71. package/dist-esm/ui/format-time.mjs.map +2 -2
  72. package/dist-esm/ui/reaction.mjs +7 -15
  73. package/dist-esm/ui/reaction.mjs.map +2 -2
  74. package/package.json +5 -5
  75. package/src/canvas/anchor-lifecycle.ts +5 -6
  76. package/src/canvas/cluster-fade.ts +6 -6
  77. package/src/canvas/cluster-model.test.ts +64 -1
  78. package/src/canvas/cluster-model.ts +28 -33
  79. package/src/canvas/comment-render.ts +30 -28
  80. package/src/canvas/comment-store.ts +16 -6
  81. package/src/canvas/comments-overlay.tsx +18 -19
  82. package/src/canvas/comments-sidebar.tsx +3 -18
  83. package/src/canvas/hooks.ts +1 -1
  84. package/src/canvas/thread-pin.tsx +21 -31
  85. package/src/canvas/thread-stack.tsx +3 -7
  86. package/src/canvas/thread-state.test.ts +35 -0
  87. package/src/canvas/thread-state.ts +27 -0
  88. package/src/canvas/thread-view.tsx +93 -95
  89. package/src/clustering/computeClusterTable.ts +3 -20
  90. package/src/clustering/replay.ts +10 -23
  91. package/src/clustering/runtime.ts +9 -15
  92. package/src/ui/comment-composer.tsx +2 -4
  93. package/src/ui/comments.css +0 -32
  94. package/src/ui/format-time.ts +24 -11
  95. package/src/ui/reaction.tsx +11 -23
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/ui/reaction.tsx"],
4
- "sourcesContent": ["import { ComponentType, ReactNode } from 'react'\nimport { TldrawUiTooltip, useTranslation } from 'tldraw'\nimport { ReactionReactor } from './reactions'\n\n/** Render a reaction token to its visual \u2014 the emoji glyph by default. @public */\nexport type RenderReaction = (token: string) => ReactNode\n\n/** The default reaction renderer: emits the token string for the OS emoji font to draw. @public */\nexport function defaultRenderReaction(token: string): ReactNode {\n\treturn token\n}\n\n/** @public */\nexport interface ReactionProps {\n\temoji: string\n\tcount: number\n\tactive: boolean\n\t/** How to draw the emoji token. Defaults to the token string (OS emoji font). */\n\trenderReaction?: RenderReaction\n\t/** Who reacted with this emoji, in reaction order \u2014 shown when the pill is hovered. */\n\treactors: ReactionReactor[]\n\t/** Whether hovering shows the reactor list. Pass false to suppress it \u2014 e.g. while another\n\t * popup menu on the comment is open. Defaults to true. */\n\tenableHoverList?: boolean\n\t/** The hover affordance for the pill, naming who reacted. It receives the reactors and the pill\n\t * itself (as `children`) and returns the whole thing \u2014 so it owns the tooltip, its box, size,\n\t * shape, and position, not just the text inside. Defaults to `DefaultReactionTooltip`, which\n\t * hangs the built-in inline sentence below the pill. */\n\tReactionTooltip?: ComponentType<ReactionTooltipProps>\n\t/** Called when the pill is pressed \u2014 toggles the current user's reaction. */\n\tonClick?(): void\n}\n\n/**\n * A single emoji reaction pill with a count, highlighted when the user reacted. Hovering it shows a\n * tooltip naming who reacted (the current user included), when `enableHoverList` is set.\n * @public @react\n */\nexport function Reaction({\n\temoji,\n\tcount,\n\tactive,\n\treactors,\n\tenableHoverList = true,\n\trenderReaction = defaultRenderReaction,\n\tReactionTooltip = DefaultReactionTooltip,\n\tonClick,\n}: ReactionProps) {\n\t// An inert pill (no handler) is plain content, not a control \u2014 rendering it as a span keeps it\n\t// valid inside an anchor row (e.g. a notification list item) and out of the tab order.\n\tconst Tag = onClick ? 'button' : 'span'\n\tconst pill = (\n\t\t<Tag\n\t\t\tclassName={active ? 'tlui-cmt-reaction tlui-cmt-reaction--active' : 'tlui-cmt-reaction'}\n\t\t\t{...(onClick\n\t\t\t\t? { type: 'button' as const, 'aria-pressed': active, onClick }\n\t\t\t\t: { role: 'img' as const })}\n\t\t\t// The emoji alone announces as its unicode name (\"thumbs up\"); pairing it with the count\n\t\t\t// is what makes the pill's state legible to a screen reader. The inert span needs the\n\t\t\t// img role for the label to be announced at all.\n\t\t\taria-label={`${emoji} ${count}`}\n\t\t>\n\t\t\t<span className=\"tlui-cmt-reaction__emoji\">{renderReaction(emoji)}</span>\n\t\t\t<span className=\"tlui-cmt-reaction__count\">{count}</span>\n\t\t</Tag>\n\t)\n\n\t// A bare pill when hovering is suppressed, or when there's no one to name.\n\tif (!enableHoverList || reactors.length === 0) return pill\n\n\t// Hand the whole hover affordance to the tooltip component: it wraps the pill and owns how the\n\t// reactor list is presented.\n\treturn <ReactionTooltip reactors={reactors}>{pill}</ReactionTooltip>\n}\n\n/** @public */\nexport interface ReactionTooltipProps {\n\t/** Who reacted with this emoji, in reaction order. */\n\treactors: ReactionReactor[]\n\t/** The reaction pill to wrap. Anchor the hover affordance to this and render it. */\n\tchildren: ReactNode\n}\n\n/**\n * The default reactor tooltip: hangs the built-in inline sentence (`DefaultReactionTooltipContent`)\n * in a standard tooltip below the pill \u2014 the same tooltip the toolbar uses. Replace it via\n * `CommentingComponents.ReactionTooltip` to present the reactor list any other way (a different box,\n * avatars, a banner, anywhere on screen); it receives the pill as `children`.\n * @public @react\n */\nexport function DefaultReactionTooltip({ reactors, children }: ReactionTooltipProps) {\n\treturn (\n\t\t<TldrawUiTooltip side=\"bottom\" content={<DefaultReactionTooltipContent reactors={reactors} />}>\n\t\t\t{children}\n\t\t</TldrawUiTooltip>\n\t)\n}\n\n/**\n * The default reactor sentence naming who reacted \u2014 up to three names spelled out, then \"and N\n * others\" (e.g. \"You reacted\", \"You and Bo reacted\", \"You, Bo and Ada reacted\", \"You, Bo, Ada and 2\n * others reacted\"). The wording lives in the `comments.reacted-*` translation strings so each locale\n * controls the grammar. Exported so a custom `ReactionTooltip` can reuse it inside its own box.\n * @public @react\n */\nexport function DefaultReactionTooltipContent({ reactors }: { reactors: ReactionReactor[] }) {\n\tconst msg = useTranslation()\n\tconst names = reactors.map((reactor) =>\n\t\treactor.you ? msg('comments.mention-you') : reactor.name\n\t)\n\tconst [a, b, c] = names\n\tswitch (names.length) {\n\t\tcase 0:\n\t\t\treturn null\n\t\tcase 1:\n\t\t\treturn <>{msg('comments.reacted-1').replace('{a}', a)}</>\n\t\tcase 2:\n\t\t\treturn <>{msg('comments.reacted-2').replace('{a}', a).replace('{b}', b)}</>\n\t\tcase 3:\n\t\t\treturn <>{msg('comments.reacted-3').replace('{a}', a).replace('{b}', b).replace('{c}', c)}</>\n\t\tdefault: {\n\t\t\tconst others = names.length - 3\n\t\t\tconst template = others === 1 ? 'comments.reacted-more-one' : 'comments.reacted-more'\n\t\t\treturn (\n\t\t\t\t<>\n\t\t\t\t\t{msg(template)\n\t\t\t\t\t\t.replace('{a}', a)\n\t\t\t\t\t\t.replace('{b}', b)\n\t\t\t\t\t\t.replace('{c}', c)\n\t\t\t\t\t\t.replace('{count}', String(others))}\n\t\t\t\t</>\n\t\t\t)\n\t\t}\n\t}\n}\n"],
5
- "mappings": "AAoDE,SA+DQ,UArDP,KAVD;AAnDF,SAAS,iBAAiB,sBAAsB;AAOzC,SAAS,sBAAsB,OAA0B;AAC/D,SAAO;AACR;AA4BO,SAAS,SAAS;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB,iBAAiB;AAAA,EACjB,kBAAkB;AAAA,EAClB;AACD,GAAkB;AAGjB,QAAM,MAAM,UAAU,WAAW;AACjC,QAAM,OACL;AAAA,IAAC;AAAA;AAAA,MACA,WAAW,SAAS,gDAAgD;AAAA,MACnE,GAAI,UACF,EAAE,MAAM,UAAmB,gBAAgB,QAAQ,QAAQ,IAC3D,EAAE,MAAM,MAAe;AAAA,MAI1B,cAAY,GAAG,KAAK,IAAI,KAAK;AAAA,MAE7B;AAAA,4BAAC,UAAK,WAAU,4BAA4B,yBAAe,KAAK,GAAE;AAAA,QAClE,oBAAC,UAAK,WAAU,4BAA4B,iBAAM;AAAA;AAAA;AAAA,EACnD;AAID,MAAI,CAAC,mBAAmB,SAAS,WAAW,EAAG,QAAO;AAItD,SAAO,oBAAC,mBAAgB,UAAqB,gBAAK;AACnD;AAiBO,SAAS,uBAAuB,EAAE,UAAU,SAAS,GAAyB;AACpF,SACC,oBAAC,mBAAgB,MAAK,UAAS,SAAS,oBAAC,iCAA8B,UAAoB,GACzF,UACF;AAEF;AASO,SAAS,8BAA8B,EAAE,SAAS,GAAoC;AAC5F,QAAM,MAAM,eAAe;AAC3B,QAAM,QAAQ,SAAS;AAAA,IAAI,CAAC,YAC3B,QAAQ,MAAM,IAAI,sBAAsB,IAAI,QAAQ;AAAA,EACrD;AACA,QAAM,CAAC,GAAG,GAAG,CAAC,IAAI;AAClB,UAAQ,MAAM,QAAQ;AAAA,IACrB,KAAK;AACJ,aAAO;AAAA,IACR,KAAK;AACJ,aAAO,gCAAG,cAAI,oBAAoB,EAAE,QAAQ,OAAO,CAAC,GAAE;AAAA,IACvD,KAAK;AACJ,aAAO,gCAAG,cAAI,oBAAoB,EAAE,QAAQ,OAAO,CAAC,EAAE,QAAQ,OAAO,CAAC,GAAE;AAAA,IACzE,KAAK;AACJ,aAAO,gCAAG,cAAI,oBAAoB,EAAE,QAAQ,OAAO,CAAC,EAAE,QAAQ,OAAO,CAAC,EAAE,QAAQ,OAAO,CAAC,GAAE;AAAA,IAC3F,SAAS;AACR,YAAM,SAAS,MAAM,SAAS;AAC9B,YAAM,WAAW,WAAW,IAAI,8BAA8B;AAC9D,aACC,gCACE,cAAI,QAAQ,EACX,QAAQ,OAAO,CAAC,EAChB,QAAQ,OAAO,CAAC,EAChB,QAAQ,OAAO,CAAC,EAChB,QAAQ,WAAW,OAAO,MAAM,CAAC,GACpC;AAAA,IAEF;AAAA,EACD;AACD;",
4
+ "sourcesContent": ["import { ComponentType, ReactNode } from 'react'\nimport { TldrawUiTooltip, useTranslation } from 'tldraw'\nimport { ReactionReactor } from './reactions'\n\n/** Render a reaction token to its visual \u2014 the emoji glyph by default. @public */\nexport type RenderReaction = (token: string) => ReactNode\n\n/** The default reaction renderer: emits the token string for the OS emoji font to draw. @public */\nexport function defaultRenderReaction(token: string): ReactNode {\n\treturn token\n}\n\n/** @public */\nexport interface ReactionProps {\n\temoji: string\n\tcount: number\n\tactive: boolean\n\t/** How to draw the emoji token. Defaults to the token string (OS emoji font). */\n\trenderReaction?: RenderReaction\n\t/** Who reacted with this emoji, in reaction order \u2014 shown when the pill is hovered. */\n\treactors: ReactionReactor[]\n\t/** Whether hovering shows the reactor list. Pass false to suppress it \u2014 e.g. while another\n\t * popup menu on the comment is open. Defaults to true. */\n\tenableHoverList?: boolean\n\t/** The hover affordance for the pill, naming who reacted. It receives the reactors and the pill\n\t * itself (as `children`) and returns the whole thing \u2014 so it owns the tooltip, its box, size,\n\t * shape, and position, not just the text inside. Defaults to `DefaultReactionTooltip`, which\n\t * hangs the built-in inline sentence below the pill. */\n\tReactionTooltip?: ComponentType<ReactionTooltipProps>\n\t/** Called when the pill is pressed \u2014 toggles the current user's reaction. */\n\tonClick?(): void\n}\n\n/**\n * A single emoji reaction pill with a count, highlighted when the user reacted. Hovering it shows a\n * tooltip naming who reacted (the current user included), when `enableHoverList` is set.\n * @public @react\n */\nexport function Reaction({\n\temoji,\n\tcount,\n\tactive,\n\treactors,\n\tenableHoverList = true,\n\trenderReaction = defaultRenderReaction,\n\tReactionTooltip = DefaultReactionTooltip,\n\tonClick,\n}: ReactionProps) {\n\t// An inert pill (no handler) is plain content, not a control \u2014 rendering it as a span keeps it\n\t// valid inside an anchor row (e.g. a notification list item) and out of the tab order.\n\tconst Tag = onClick ? 'button' : 'span'\n\tconst pill = (\n\t\t<Tag\n\t\t\tclassName={active ? 'tlui-cmt-reaction tlui-cmt-reaction--active' : 'tlui-cmt-reaction'}\n\t\t\t{...(onClick\n\t\t\t\t? { type: 'button' as const, 'aria-pressed': active, onClick }\n\t\t\t\t: { role: 'img' as const })}\n\t\t\t// The emoji alone announces as its unicode name (\"thumbs up\"); pairing it with the count\n\t\t\t// is what makes the pill's state legible to a screen reader. The inert span needs the\n\t\t\t// img role for the label to be announced at all.\n\t\t\taria-label={`${emoji} ${count}`}\n\t\t>\n\t\t\t<span className=\"tlui-cmt-reaction__emoji\">{renderReaction(emoji)}</span>\n\t\t\t<span className=\"tlui-cmt-reaction__count\">{count}</span>\n\t\t</Tag>\n\t)\n\n\t// A bare pill when hovering is suppressed, or when there's no one to name.\n\tif (!enableHoverList || reactors.length === 0) return pill\n\n\t// Hand the whole hover affordance to the tooltip component: it wraps the pill and owns how the\n\t// reactor list is presented.\n\treturn <ReactionTooltip reactors={reactors}>{pill}</ReactionTooltip>\n}\n\n/** @public */\nexport interface ReactionTooltipProps {\n\t/** Who reacted with this emoji, in reaction order. */\n\treactors: ReactionReactor[]\n\t/** The reaction pill to wrap. Anchor the hover affordance to this and render it. */\n\tchildren: ReactNode\n}\n\n/**\n * The default reactor tooltip: hangs the built-in inline sentence (`DefaultReactionTooltipContent`)\n * in a standard tooltip below the pill \u2014 the same tooltip the toolbar uses. Replace it via\n * `CommentingComponents.ReactionTooltip` to present the reactor list any other way (a different box,\n * avatars, a banner, anywhere on screen); it receives the pill as `children`.\n * @public @react\n */\nexport function DefaultReactionTooltip({ reactors, children }: ReactionTooltipProps) {\n\treturn (\n\t\t<TldrawUiTooltip side=\"bottom\" content={<DefaultReactionTooltipContent reactors={reactors} />}>\n\t\t\t{children}\n\t\t</TldrawUiTooltip>\n\t)\n}\n\n/**\n * The default reactor sentence naming who reacted \u2014 up to three names spelled out, then \"and N\n * others\" (e.g. \"You reacted\", \"You and Bo reacted\", \"You, Bo and Ada reacted\", \"You, Bo, Ada and 2\n * others reacted\"). The wording lives in the `comments.reacted-*` translation strings so each locale\n * controls the grammar. Exported so a custom `ReactionTooltip` can reuse it inside its own box.\n * @public @react\n */\nexport function DefaultReactionTooltipContent({ reactors }: { reactors: ReactionReactor[] }) {\n\tconst msg = useTranslation()\n\tconst names = reactors.map((reactor) =>\n\t\treactor.you ? msg('comments.mention-you') : reactor.name\n\t)\n\tif (names.length === 0) return null\n\tconst [a, b, c] = names\n\tconst others = names.length - 3\n\tconst key =\n\t\tnames.length <= 3\n\t\t\t? `comments.reacted-${names.length}`\n\t\t\t: others === 1\n\t\t\t\t? 'comments.reacted-more-one'\n\t\t\t\t: 'comments.reacted-more'\n\t// Single pass, so a substituted name containing a placeholder can't be re-substituted.\n\tconst vars: Record<string, string | undefined> = { a, b, c, count: String(others) }\n\treturn <>{msg(key).replace(/\\{(a|b|c|count)\\}/g, (_, k) => vars[k] ?? '')}</>\n}\n"],
5
+ "mappings": "AAoDE,SAqEM,UA3DL,KAVD;AAnDF,SAAS,iBAAiB,sBAAsB;AAOzC,SAAS,sBAAsB,OAA0B;AAC/D,SAAO;AACR;AA4BO,SAAS,SAAS;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB,iBAAiB;AAAA,EACjB,kBAAkB;AAAA,EAClB;AACD,GAAkB;AAGjB,QAAM,MAAM,UAAU,WAAW;AACjC,QAAM,OACL;AAAA,IAAC;AAAA;AAAA,MACA,WAAW,SAAS,gDAAgD;AAAA,MACnE,GAAI,UACF,EAAE,MAAM,UAAmB,gBAAgB,QAAQ,QAAQ,IAC3D,EAAE,MAAM,MAAe;AAAA,MAI1B,cAAY,GAAG,KAAK,IAAI,KAAK;AAAA,MAE7B;AAAA,4BAAC,UAAK,WAAU,4BAA4B,yBAAe,KAAK,GAAE;AAAA,QAClE,oBAAC,UAAK,WAAU,4BAA4B,iBAAM;AAAA;AAAA;AAAA,EACnD;AAID,MAAI,CAAC,mBAAmB,SAAS,WAAW,EAAG,QAAO;AAItD,SAAO,oBAAC,mBAAgB,UAAqB,gBAAK;AACnD;AAiBO,SAAS,uBAAuB,EAAE,UAAU,SAAS,GAAyB;AACpF,SACC,oBAAC,mBAAgB,MAAK,UAAS,SAAS,oBAAC,iCAA8B,UAAoB,GACzF,UACF;AAEF;AASO,SAAS,8BAA8B,EAAE,SAAS,GAAoC;AAC5F,QAAM,MAAM,eAAe;AAC3B,QAAM,QAAQ,SAAS;AAAA,IAAI,CAAC,YAC3B,QAAQ,MAAM,IAAI,sBAAsB,IAAI,QAAQ;AAAA,EACrD;AACA,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,CAAC,GAAG,GAAG,CAAC,IAAI;AAClB,QAAM,SAAS,MAAM,SAAS;AAC9B,QAAM,MACL,MAAM,UAAU,IACb,oBAAoB,MAAM,MAAM,KAChC,WAAW,IACV,8BACA;AAEL,QAAM,OAA2C,EAAE,GAAG,GAAG,GAAG,OAAO,OAAO,MAAM,EAAE;AAClF,SAAO,gCAAG,cAAI,GAAG,EAAE,QAAQ,sBAAsB,CAAC,GAAG,MAAM,KAAK,CAAC,KAAK,EAAE,GAAE;AAC3E;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tldraw/commenting",
3
3
  "description": "tldraw commenting UI components.",
4
- "version": "5.4.2",
4
+ "version": "5.5.0-canary.0241f11f2e99",
5
5
  "author": {
6
6
  "name": "tldraw Inc.",
7
7
  "email": "hello@tldraw.com"
@@ -46,7 +46,7 @@
46
46
  "lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
47
47
  },
48
48
  "devDependencies": {
49
- "@tldraw/driver": "5.4.2",
49
+ "@tldraw/driver": "5.5.0-canary.0241f11f2e99",
50
50
  "@types/react": "^19.2.7",
51
51
  "@types/react-dom": "^19.2.3",
52
52
  "react": "^19.2.1",
@@ -64,9 +64,9 @@
64
64
  "@tiptap/core": "^3.12.1",
65
65
  "@tiptap/pm": "^3.12.1",
66
66
  "@tiptap/react": "^3.12.1",
67
- "@tldraw/mentions": "5.4.2",
68
- "@tldraw/utils": "5.4.2",
69
- "tldraw": "5.4.2"
67
+ "@tldraw/mentions": "5.5.0-canary.0241f11f2e99",
68
+ "@tldraw/utils": "5.5.0-canary.0241f11f2e99",
69
+ "tldraw": "5.5.0-canary.0241f11f2e99"
70
70
  },
71
71
  "module": "dist-esm/index.mjs",
72
72
  "source": "src/index.ts",
@@ -1,7 +1,7 @@
1
1
  import { WeakCache } from '@tldraw/utils'
2
2
  import { Editor, TLCommentAnchor, TLCommentThread, TLPageId, TLShapeId, VecLike } from 'tldraw'
3
3
  import { commitCommentMutation } from './comment-mutations'
4
- import { getCommentRecord, getComments, getCommentThreads, TLCommentRecord } from './comment-store'
4
+ import { getComments, getCommentThread, getCommentThreads, TLCommentRecord } from './comment-store'
5
5
  import { anchorPagePoint, impreciseShapePinInset } from './thread-state'
6
6
 
7
7
  type ShapeAnchor = Extract<TLCommentAnchor, { type: 'shape' }>
@@ -108,9 +108,8 @@ export function registerCommentAnchorLifecycle(editor: Editor): () => void {
108
108
  if (editor.getShape(shapeId)) continue
109
109
  for (const [threadId, point] of threadPoints) {
110
110
  if (!point) continue
111
- const thread = getCommentRecord(editor, threadId)
112
- if (!thread || thread.typeName !== 'comment-thread') continue
113
- if (!isAnchoredToShape(thread, shapeId)) continue
111
+ const thread = getCommentThread(editor, threadId)
112
+ if (!thread || !isAnchoredToShape(thread, shapeId)) continue
114
113
  let converted = convertedByShape.get(shapeId)
115
114
  if (!converted) {
116
115
  converted = []
@@ -130,8 +129,8 @@ export function registerCommentAnchorLifecycle(editor: Editor): () => void {
130
129
 
131
130
  const pageId = editor.getAncestorPageId(shape)
132
131
  for (const { threadId, anchor, point } of converted) {
133
- const thread = getCommentRecord(editor, threadId)
134
- if (!thread || thread.typeName !== 'comment-thread') continue
132
+ const thread = getCommentThread(editor, threadId)
133
+ if (!thread) continue
135
134
  // Re-attach only threads still sitting exactly where the conversion left them — a
136
135
  // pin moved since then was placed deliberately, and that placement wins.
137
136
  const current = thread.anchor
@@ -78,14 +78,14 @@ function reconcileFadeNodes(
78
78
 
79
79
  for (const node of nextNodes) {
80
80
  const previousItem = previousById.get(node.id)
81
+ // A node that was fading out and came back snaps to present rather than re-entering.
81
82
  next.push({
82
83
  node,
83
- phase:
84
- previousItem && previousItem.phase !== 'exiting'
85
- ? previousItem.phase
86
- : previousItem
87
- ? 'present'
88
- : 'entering',
84
+ phase: !previousItem
85
+ ? 'entering'
86
+ : previousItem.phase === 'exiting'
87
+ ? 'present'
88
+ : previousItem.phase,
89
89
  })
90
90
  }
91
91
 
@@ -1,8 +1,21 @@
1
+ import {
2
+ commentSchemaRecords,
3
+ createCommentThread,
4
+ createShapeId,
5
+ createTLSchema,
6
+ createTLStore,
7
+ defaultBindingUtils,
8
+ defaultShapeUtils,
9
+ defaultTools,
10
+ Editor,
11
+ PageRecordType,
12
+ } from 'tldraw'
1
13
  import { describe, expect, it } from 'vitest'
2
14
  import { createClusterRuntime } from '../clustering/runtime'
3
15
  import type { ClusterNode, ClusterTable, LeafInput } from '../clustering/types'
4
16
  import type { ClusterInput } from './cluster-input'
5
- import { anyFoldedLeafMoved, type ClusterModel } from './cluster-model'
17
+ import { anyFoldedLeafMoved, revealThreadPin, type ClusterModel } from './cluster-model'
18
+ import { defaultCommentingOptions } from './options'
6
19
 
7
20
  function node(ids: string[], x = 0, y = 0): ClusterNode {
8
21
  const members = ids.slice().sort()
@@ -86,3 +99,53 @@ describe('anyFoldedLeafMoved', () => {
86
99
  expect(anyFoldedLeafMoved(input(AT_REST), next, rendered(0.5))).toBe(false)
87
100
  })
88
101
  })
102
+
103
+ describe('revealThreadPin', () => {
104
+ it('records a cross-page jump as its own undo step', () => {
105
+ const editor = new Editor({
106
+ store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
107
+ shapeUtils: defaultShapeUtils,
108
+ bindingUtils: defaultBindingUtils,
109
+ tools: defaultTools,
110
+ getContainer: () => document.body,
111
+ })
112
+ try {
113
+ const page1Id = editor.getCurrentPageId()
114
+ const page2Id = PageRecordType.createId()
115
+ const boxId = createShapeId()
116
+
117
+ // Set up the second page outside of history so the only undoable steps are the ones
118
+ // the test creates: one edit on page 1, then the jump to the pin on page 2.
119
+ editor.run(() => editor.createPage({ id: page2Id, name: 'Page 2' }), { history: 'ignore' })
120
+ editor.clearHistory()
121
+
122
+ editor.markHistoryStoppingPoint('create box')
123
+ editor.createShape({ id: boxId, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } })
124
+
125
+ const thread = createCommentThread({
126
+ pageId: page2Id,
127
+ anchor: { type: 'point', x: 50, y: 50 },
128
+ createdBy: 'me',
129
+ })
130
+ revealThreadPin(
131
+ editor,
132
+ thread,
133
+ { leaves: [], events: [] },
134
+ { minZoom: 0.1, maxZoom: 8 },
135
+ { ...defaultCommentingOptions, enableClustering: false },
136
+ 0
137
+ )
138
+ expect(editor.getCurrentPageId()).toBe(page2Id)
139
+
140
+ // The first undo only takes us back to page 1; the edit made there is still intact.
141
+ editor.undo()
142
+ expect(editor.getCurrentPageId()).toBe(page1Id)
143
+ expect(editor.getShape(boxId)).toBeDefined()
144
+
145
+ editor.undo()
146
+ expect(editor.getShape(boxId)).toBeUndefined()
147
+ } finally {
148
+ editor.dispose()
149
+ }
150
+ })
151
+ })
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useRef, useState } from 'react'
2
- import { Editor, react, TLCommentThread, useValue } from 'tldraw'
2
+ import { clamp, Editor, react, TLCommentThread, useValue } from 'tldraw'
3
3
  import { computeClusterTable } from '../clustering/computeClusterTable'
4
4
  import { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'
5
5
  import type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'
@@ -169,11 +169,14 @@ export function useClusterModel(
169
169
  for (const id of newlyMovedIds) next.add(id)
170
170
  setHeldThreadIds(next)
171
171
  }
172
+ const latestLeafIds = useMemo(
173
+ () => new Set(latestModel.table.leaves.map((leaf) => leaf.id)),
174
+ [latestModel]
175
+ )
172
176
  // Local partition maintenance — the only non-zoom visual change. Any displayed leaf that has left the
173
177
  // cluster input is detached from its badge in place; the corrected rebuild already sits in latestModel
174
178
  // awaiting the next zoom-out. When the two models match, the leaf sets are identical, so skip the scan.
175
179
  if (clusterModel !== latestModel) {
176
- const latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
177
180
  const removedLeafIds: string[] = []
178
181
  for (const leaf of clusterModel.table.leaves) {
179
182
  if (!latestLeafIds.has(leaf.id)) {
@@ -188,12 +191,7 @@ export function useClusterModel(
188
191
  // never folds pins into clusters — merging is a zoom-out-only move, matching the runtime.
189
192
  useEffect(() => {
190
193
  if (heldThreadIds.size === 0) return
191
- let lastZoom = editor.getZoomLevel()
192
- return react('rejoin moved comment pins on zoom out', () => {
193
- const zoom = editor.getZoomLevel()
194
- const prevZoom = lastZoom
195
- lastZoom = zoom
196
- if (zoom >= prevZoom) return
194
+ return reactOnZoomOut(editor, 'rejoin moved comment pins on zoom out', () => {
197
195
  adoptOnRebuild.current = true
198
196
  setHeldThreadIds(EMPTY_SET)
199
197
  })
@@ -203,12 +201,7 @@ export function useClusterModel(
203
201
  // splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).
204
202
  useEffect(() => {
205
203
  if (clusterModel === latestModel) return
206
- let lastZoom = editor.getZoomLevel()
207
- return react('adopt pending cluster model on zoom out', () => {
208
- const zoom = editor.getZoomLevel()
209
- const prevZoom = lastZoom
210
- lastZoom = zoom
211
- if (zoom >= prevZoom) return
204
+ return reactOnZoomOut(editor, 'adopt pending cluster model on zoom out', (zoom) => {
212
205
  latestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())
213
206
  setRenderedModel(latestModel)
214
207
  })
@@ -223,11 +216,10 @@ export function useClusterModel(
223
216
  for (const node of clusterModel.runtime.getVisible().values()) {
224
217
  for (const member of node.members) displayed.add(member)
225
218
  }
226
- const latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
227
- return threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))
219
+ return threads.filter((thread) => latestLeafIds.has(thread.id) && !displayed.has(thread.id))
228
220
  // The runtime mutates its partition in place; partitionVersion is its change stamp.
229
221
  // eslint-disable-next-line react-hooks/exhaustive-deps
230
- }, [clusterModel, latestModel, threads, partitionVersion])
222
+ }, [clusterModel, latestModel, latestLeafIds, threads, partitionVersion])
231
223
  const heldThreads = useMemo(
232
224
  () => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),
233
225
  [threads, heldThreadIds, openId]
@@ -259,6 +251,17 @@ export function useClusterModel(
259
251
  }
260
252
  }
261
253
 
254
+ /** Run `onZoomOut` on each zoom change that decreased the zoom, starting from the current level. */
255
+ function reactOnZoomOut(editor: Editor, name: string, onZoomOut: (zoom: number) => void) {
256
+ let lastZoom = editor.getZoomLevel()
257
+ return react(name, () => {
258
+ const zoom = editor.getZoomLevel()
259
+ const prevZoom = lastZoom
260
+ lastZoom = zoom
261
+ if (zoom < prevZoom) onZoomOut(zoom)
262
+ })
263
+ }
264
+
262
265
  /**
263
266
  * Whether a position-only input change (ids equal, same order) moved a leaf folded inside a badge
264
267
  * of the rendered partition — the one move the mid-drag freeze must let through.
@@ -342,6 +345,7 @@ export function revealThreadPin(
342
345
  duration = 200
343
346
  ) {
344
347
  if (thread.pageId !== editor.getCurrentPageId()) {
348
+ editor.markHistoryStoppingPoint('change-page')
345
349
  editor.setCurrentPage(thread.pageId as any)
346
350
  }
347
351
 
@@ -357,12 +361,7 @@ export function revealThreadPin(
357
361
  Number.isFinite(parentEvent.zSplit) &&
358
362
  parentEvent.zSplit <= zoomBounds.maxZoom
359
363
  ) {
360
- const zoom = clamp(
361
- parentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
362
- zoomBounds.minZoom,
363
- zoomBounds.maxZoom
364
- )
365
- centerOnPointAtZoom(editor, point, zoom, duration)
364
+ centerOnPointAtZoom(editor, point, splitZoom(parentEvent, zoomBounds), duration)
366
365
  return
367
366
  }
368
367
  }
@@ -385,12 +384,12 @@ export function zoomToClusterSplit(
385
384
  ) {
386
385
  const event = table.events.find((e) => e.result.id === node.id)
387
386
  if (!event || !Number.isFinite(event.zSplit)) return
388
- const zoom = clamp(
389
- event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
390
- zoomBounds.minZoom,
391
- zoomBounds.maxZoom
392
- )
393
- centerOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)
387
+ centerOnPointAtZoom(editor, node.centroid, splitZoom(event, zoomBounds), CLUSTER_EXPAND_ZOOM_MS)
388
+ }
389
+
390
+ /** The zoom to land on to see a merge event's cluster just after it splits. */
391
+ function splitZoom(event: MergeEvent, zoomBounds: ClusterZoomBounds): number {
392
+ return clamp(event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR, zoomBounds.minZoom, zoomBounds.maxZoom)
394
393
  }
395
394
 
396
395
  function findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {
@@ -415,7 +414,3 @@ function centerOnPointAtZoom(
415
414
  { animation: { duration } }
416
415
  )
417
416
  }
418
-
419
- function clamp(value: number, min: number, max: number): number {
420
- return Math.max(min, Math.min(max, value))
421
- }
@@ -1,4 +1,4 @@
1
- import { generateText, JSONContent } from '@tiptap/core'
1
+ import { type Extensions, generateText, JSONContent } from '@tiptap/core'
2
2
  import { type CommentAuthor, createMentionExtension } from '@tldraw/mentions'
3
3
  import { renderHtmlFromRichTextWithExtensions, TLRichText } from 'tldraw'
4
4
  import { commentTipTapExtensions, isCommentEmpty } from '../ui/comment-extensions'
@@ -41,34 +41,46 @@ function hasMention(node: JSONContent): boolean {
41
41
  }
42
42
 
43
43
  const htmlCache = new WeakMap<TLRichText, string>()
44
+ const textCache = new WeakMap<TLRichText, string>()
44
45
 
45
46
  /**
46
- * Render a comment body to HTML through the limited comment extension set (no headings), so a body
47
- * always renders with comment formatting regardless of the host editor's rich-text config. Renders
48
- * through tldraw's shared helper, so it gets the same empty-paragraph fix that keeps blank lines
49
- * from collapsing. `resolveName` maps a member id to its current name for any @mentions.
47
+ * Render a body through the comment extension set, memoized by body identity in `cache` unless the
48
+ * body carries a mention (see {@link hasMention}), in which case the mention extension resolving
49
+ * `resolveName` is added and nothing is cached.
50
50
  */
51
- export function renderCommentHtml(
51
+ function renderWithCommentExtensions(
52
+ cache: WeakMap<TLRichText, string>,
52
53
  richText: TLRichText,
53
- resolveName?: (id: string) => string | undefined
54
+ resolveName: ((id: string) => string | undefined) | undefined,
55
+ render: (doc: JSONContent, extensions: Extensions) => string
54
56
  ): string {
55
57
  const mentions = hasMention(richText as JSONContent)
56
58
  if (!mentions) {
57
- const cached = htmlCache.get(richText)
59
+ const cached = cache.get(richText)
58
60
  if (cached !== undefined) return cached
59
61
  }
60
62
  const extensions = mentions
61
63
  ? [...commentTipTapExtensions, createMentionExtension({ resolveName })]
62
64
  : commentTipTapExtensions
63
- const html = renderHtmlFromRichTextWithExtensions(
64
- demoteHeadings(richText as JSONContent) as TLRichText,
65
- extensions
66
- )
67
- if (!mentions) htmlCache.set(richText, html)
68
- return html
65
+ const result = render(demoteHeadings(richText as JSONContent), extensions)
66
+ if (!mentions) cache.set(richText, result)
67
+ return result
69
68
  }
70
69
 
71
- const textCache = new WeakMap<TLRichText, string>()
70
+ /**
71
+ * Render a comment body to HTML through the limited comment extension set (no headings), so a body
72
+ * always renders with comment formatting regardless of the host editor's rich-text config. Renders
73
+ * through tldraw's shared helper, so it gets the same empty-paragraph fix that keeps blank lines
74
+ * from collapsing. `resolveName` maps a member id to its current name for any @mentions.
75
+ */
76
+ export function renderCommentHtml(
77
+ richText: TLRichText,
78
+ resolveName?: (id: string) => string | undefined
79
+ ): string {
80
+ return renderWithCommentExtensions(htmlCache, richText, resolveName, (doc, extensions) =>
81
+ renderHtmlFromRichTextWithExtensions(doc as TLRichText, extensions)
82
+ )
83
+ }
72
84
 
73
85
  /**
74
86
  * Flatten a comment body to plaintext through the limited comment extension set — paragraphs and
@@ -80,17 +92,7 @@ export function renderCommentPlaintext(
80
92
  resolveName?: (id: string) => string | undefined
81
93
  ): string {
82
94
  if (isCommentEmpty(richText)) return ''
83
- const mentions = hasMention(richText as JSONContent)
84
- if (!mentions) {
85
- const cached = textCache.get(richText)
86
- if (cached !== undefined) return cached
87
- }
88
- const extensions = mentions
89
- ? [...commentTipTapExtensions, createMentionExtension({ resolveName })]
90
- : commentTipTapExtensions
91
- const text = generateText(demoteHeadings(richText as JSONContent), extensions, {
92
- blockSeparator: '\n',
93
- })
94
- if (!mentions) textCache.set(richText, text)
95
- return text
95
+ return renderWithCommentExtensions(textCache, richText, resolveName, (doc, extensions) =>
96
+ generateText(doc, extensions, { blockSeparator: '\n' })
97
+ )
96
98
  }
@@ -33,6 +33,12 @@ export function getCommentRecord(editor: Editor, id: string): TLCommentRecord |
33
33
  return undefined
34
34
  }
35
35
 
36
+ /** Read one comment thread by id, or `undefined` if the id isn't a present thread. @internal */
37
+ export function getCommentThread(editor: Editor, id: string): TLCommentThread | undefined {
38
+ const record = getCommentRecord(editor, id)
39
+ return record?.typeName === 'comment-thread' ? record : undefined
40
+ }
41
+
36
42
  /**
37
43
  * Every comment thread in the store, **including soft-deleted and emptied ones** awaiting the
38
44
  * server's prune, which nothing renders. For the set the UI shows, use {@link getLiveCommentThreads}.
@@ -41,8 +47,7 @@ export function getCommentRecord(editor: Editor, id: string): TLCommentRecord |
41
47
  * @public
42
48
  */
43
49
  export function getCommentThreads(editor: Editor): TLCommentThread[] {
44
- const typeName = 'comment-thread' as TLRecord['typeName']
45
- return editor.store.query.records(typeName).get() as unknown as TLCommentThread[]
50
+ return queryCommentRecords<TLCommentThread>(editor, 'comment-thread')
46
51
  }
47
52
 
48
53
  /**
@@ -53,8 +58,7 @@ export function getCommentThreads(editor: Editor): TLCommentThread[] {
53
58
  * @public
54
59
  */
55
60
  export function getComments(editor: Editor): TLComment[] {
56
- const typeName = 'comment' as TLRecord['typeName']
57
- return editor.store.query.records(typeName).get() as unknown as TLComment[]
61
+ return queryCommentRecords<TLComment>(editor, 'comment')
58
62
  }
59
63
 
60
64
  /**
@@ -85,6 +89,12 @@ export function getLiveCommentThreads(editor: Editor): TLCommentThread[] {
85
89
 
86
90
  /** All comment reactions currently in the store (non-reactive; wrap in `useValue` to react). @public */
87
91
  export function getCommentReactions(editor: Editor): TLCommentReaction[] {
88
- const typeName = 'comment-reaction' as TLRecord['typeName']
89
- return editor.store.query.records(typeName).get() as unknown as TLCommentReaction[]
92
+ return queryCommentRecords<TLCommentReaction>(editor, 'comment-reaction')
93
+ }
94
+
95
+ function queryCommentRecords<T extends TLCommentRecord>(
96
+ editor: Editor,
97
+ typeName: T['typeName']
98
+ ): T[] {
99
+ return editor.store.query.records(typeName as TLRecord['typeName']).get() as unknown as T[]
90
100
  }
@@ -11,7 +11,7 @@ import {
11
11
  useClusterModel,
12
12
  zoomToClusterSplit,
13
13
  } from './cluster-model'
14
- import { getCommentRecord } from './comment-store'
14
+ import { getCommentRecord, getCommentThread } from './comment-store'
15
15
  import { type CommentingContext } from './context'
16
16
  import { useCommentThreads } from './hooks'
17
17
  import { useCommentingEnabled } from './license'
@@ -172,37 +172,36 @@ function CanvasCommentsLayer(props: CommentingContext) {
172
172
  const record = getCommentRecord(editor, id)
173
173
  if (!record) return null
174
174
  const thread =
175
- record.typeName === 'comment' ? getCommentRecord(editor, record.threadId) : record
175
+ record.typeName === 'comment' ? getCommentThread(editor, record.threadId) : record
176
176
  return thread?.typeName === 'comment-thread' ? thread : null
177
177
  },
178
178
  [editor]
179
179
  )
180
180
 
181
- // Serve a pending reveal request, zooming to the first cluster split that reveals the pin if it's
182
- // folded into a badge. Also unhides pins: the popover opens on the layer that hiding withholds.
181
+ // Bring a thread's pin into view (zooming to the first cluster split that unfolds it if it's
182
+ // folded into a badge) and open it.
183
+ const revealAndOpenThread = useCallback(
184
+ (thread: TLCommentThread, duration?: number) => {
185
+ revealThreadPin(editor, thread, clusterModel.table, clusterZoomBounds, options, duration)
186
+ openThreadId.set(editor, thread.id)
187
+ },
188
+ [clusterModel.table, clusterZoomBounds, editor, options]
189
+ )
190
+
191
+ // Serve a pending reveal request. Also unhides pins: the popover opens on the layer that hiding
192
+ // withholds.
183
193
  useEffect(() => {
184
194
  if (!requestedRevealThread) return
185
195
  revealThreadRequest.set(editor, null)
186
196
  commentsHidden.set(editor, false)
187
- revealThreadPin(editor, requestedRevealThread, clusterModel.table, clusterZoomBounds, options)
188
- openThreadId.set(editor, requestedRevealThread.id)
189
- }, [requestedRevealThread, clusterModel.table, clusterZoomBounds, editor, options])
197
+ revealAndOpenThread(requestedRevealThread)
198
+ }, [requestedRevealThread, revealAndOpenThread, editor])
190
199
 
191
200
  // Picking a thread out of a cluster's hover preview. `openThreadId` alone would work, but would
192
201
  // cut straight there from the badge — zoom in first, the same move the badge's own click makes.
193
202
  const revealClusteredThread = useCallback(
194
- (thread: TLCommentThread) => {
195
- revealThreadPin(
196
- editor,
197
- thread,
198
- clusterModel.table,
199
- clusterZoomBounds,
200
- options,
201
- CLUSTER_EXPAND_ZOOM_MS
202
- )
203
- openThreadId.set(editor, thread.id)
204
- },
205
- [clusterModel.table, clusterZoomBounds, editor, options]
203
+ (thread: TLCommentThread) => revealAndOpenThread(thread, CLUSTER_EXPAND_ZOOM_MS),
204
+ [revealAndOpenThread]
206
205
  )
207
206
 
208
207
  const expandCluster = useCallback(
@@ -1,18 +1,11 @@
1
1
  import { ReactNode, useCallback } from 'react'
2
- import {
3
- EditorPortal,
4
- TLComment,
5
- TLCommentThread,
6
- useEditor,
7
- useTranslation,
8
- useValue,
9
- } from 'tldraw'
2
+ import { EditorPortal, TLCommentThread, useEditor, useTranslation, useValue } from 'tldraw'
10
3
  import { CommentListItemProps, CommentListItemRenderProps, CommentsList } from '../ui/comments-list'
11
4
  import { UNKNOWN_COMMENT_AUTHOR } from './comment-render'
12
5
  import { CommentsFilterMenu } from './comments-filter-menu'
13
6
  import { CommentsVisibilityToggle } from './comments-visibility-toggle'
14
7
  import { type CommentingContext } from './context'
15
- import { useComments, useCommentThreads } from './hooks'
8
+ import { getCommentsByThread, useCommentThreads } from './hooks'
16
9
  import { useCommentingEnabled } from './license'
17
10
  import { useCommentingOptions } from './options'
18
11
  import { richTextToPlaintext } from './rich-text'
@@ -53,7 +46,7 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
53
46
  const commentingEnabled = useCommentingEnabled()
54
47
  const msg = useTranslation()
55
48
  const threads = useCommentThreads(editor)
56
- const comments = useComments(editor)
49
+ const byThread = useValue('comments by thread', () => getCommentsByThread(editor).get(), [editor])
57
50
  const currentPageId = useValue('page id', () => editor.getCurrentPageId(), [editor])
58
51
  const open = useValue('sidebar open', () => commentsSidebarOpen.get(editor), [editor])
59
52
  const openId = useValue('open thread', () => openThreadId.get(editor), [editor])
@@ -66,14 +59,6 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
66
59
 
67
60
  if (!commentingEnabled || !open) return null
68
61
 
69
- // Group comments by thread (they arrive oldest-first, so [0] is each thread's first comment).
70
- const byThread = new Map<string, TLComment[]>()
71
- for (const comment of comments) {
72
- const list = byThread.get(comment.threadId) ?? []
73
- list.push(comment)
74
- byThread.set(comment.threadId, list)
75
- }
76
-
77
62
  // Page scoping is treated as scoping, not a filter: an empty page reads "no comments yet",
78
63
  // while a list emptied by the toggles below reads "nothing matches your filters".
79
64
  const pageThreads = threads.filter(
@@ -30,7 +30,7 @@ export function useCommentThreads(editor: Editor): TLCommentThread[] {
30
30
  */
31
31
  const commentsByThread = new WeakCache<Editor, Computed<Map<TLCommentThreadId, TLComment[]>>>()
32
32
 
33
- /** Exported for tests; use {@link useThreadComments} to consume. @internal */
33
+ /** Live comments grouped by thread id, cached per editor. @internal */
34
34
  export function getCommentsByThread(editor: Editor) {
35
35
  return commentsByThread.get(editor, () =>
36
36
  computed('comments by thread', (prev) => {