@tldraw/commenting 5.5.0-next.9665a55b2322 → 5.5.0-next.b6a9f7cd7be2

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 (93) 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 +22 -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 +12 -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 +23 -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 +12 -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 +6 -6
  75. package/src/canvas/anchor-lifecycle.ts +5 -6
  76. package/src/canvas/cluster-fade.ts +6 -6
  77. package/src/canvas/cluster-model.ts +27 -33
  78. package/src/canvas/comment-render.ts +30 -28
  79. package/src/canvas/comment-store.ts +16 -6
  80. package/src/canvas/comments-overlay.tsx +18 -19
  81. package/src/canvas/comments-sidebar.tsx +3 -18
  82. package/src/canvas/hooks.ts +1 -1
  83. package/src/canvas/thread-pin.tsx +21 -31
  84. package/src/canvas/thread-stack.tsx +3 -7
  85. package/src/canvas/thread-state.ts +26 -0
  86. package/src/canvas/thread-view.tsx +93 -95
  87. package/src/clustering/computeClusterTable.ts +3 -20
  88. package/src/clustering/replay.ts +10 -23
  89. package/src/clustering/runtime.ts +9 -15
  90. package/src/ui/comment-composer.tsx +2 -4
  91. package/src/ui/comments.css +0 -32
  92. package/src/ui/format-time.ts +24 -11
  93. 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.5.0-next.9665a55b2322",
4
+ "version": "5.5.0-next.b6a9f7cd7be2",
5
5
  "author": {
6
6
  "name": "tldraw Inc.",
7
7
  "email": "hello@tldraw.com"
@@ -46,12 +46,12 @@
46
46
  "lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
47
47
  },
48
48
  "devDependencies": {
49
- "@tldraw/driver": "5.5.0-next.9665a55b2322",
49
+ "@tldraw/driver": "5.5.0-next.b6a9f7cd7be2",
50
50
  "@types/react": "^19.2.7",
51
51
  "@types/react-dom": "^19.2.3",
52
52
  "react": "^19.2.1",
53
53
  "react-dom": "^19.2.1",
54
- "vitest": "^4.1.7"
54
+ "vitest": "^5.0.0"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "react": "^18.2.0 || ^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.5.0-next.9665a55b2322",
68
- "@tldraw/utils": "5.5.0-next.9665a55b2322",
69
- "tldraw": "5.5.0-next.9665a55b2322"
67
+ "@tldraw/mentions": "5.5.0-next.b6a9f7cd7be2",
68
+ "@tldraw/utils": "5.5.0-next.b6a9f7cd7be2",
69
+ "tldraw": "5.5.0-next.b6a9f7cd7be2"
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,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.
@@ -358,12 +361,7 @@ export function revealThreadPin(
358
361
  Number.isFinite(parentEvent.zSplit) &&
359
362
  parentEvent.zSplit <= zoomBounds.maxZoom
360
363
  ) {
361
- const zoom = clamp(
362
- parentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
363
- zoomBounds.minZoom,
364
- zoomBounds.maxZoom
365
- )
366
- centerOnPointAtZoom(editor, point, zoom, duration)
364
+ centerOnPointAtZoom(editor, point, splitZoom(parentEvent, zoomBounds), duration)
367
365
  return
368
366
  }
369
367
  }
@@ -386,12 +384,12 @@ export function zoomToClusterSplit(
386
384
  ) {
387
385
  const event = table.events.find((e) => e.result.id === node.id)
388
386
  if (!event || !Number.isFinite(event.zSplit)) return
389
- const zoom = clamp(
390
- event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
391
- zoomBounds.minZoom,
392
- zoomBounds.maxZoom
393
- )
394
- 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)
395
393
  }
396
394
 
397
395
  function findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {
@@ -416,7 +414,3 @@ function centerOnPointAtZoom(
416
414
  { animation: { duration } }
417
415
  )
418
416
  }
419
-
420
- function clamp(value: number, min: number, max: number): number {
421
- return Math.max(min, Math.min(max, value))
422
- }
@@ -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) => {
@@ -33,6 +33,7 @@ import { openThreadId } from './state'
33
33
  import { ThreadPreview, useMarkerPreview } from './thread-preview'
34
34
  import {
35
35
  anchorPagePoint,
36
+ anchorViewportPoint,
36
37
  commentTargetShapeAt,
37
38
  impreciseShapePinInset,
38
39
  isBoxInInflatedViewport,
@@ -40,6 +41,7 @@ import {
40
41
  REGION_PIN_CORNER,
41
42
  regionAnchorPinCorner,
42
43
  regionPinPoint,
44
+ regionWithPinAt,
43
45
  shapeAnchorAt,
44
46
  } from './thread-state'
45
47
  import { POPOVER_OFFSET, ThreadPopover, ThreadView } from './thread-view'
@@ -107,7 +109,7 @@ export const ThreadPin = memo(function ThreadPin({
107
109
  // A region resizes from its corners — every corner but the pin's own, which the pin owns.
108
110
  const resizeHandles = useMemo(
109
111
  () => REGION_CORNERS.filter((c) => c.x !== pinCorner.x || c.y !== pinCorner.y),
110
- [pinCorner]
112
+ [pinCorner.x, pinCorner.y]
111
113
  )
112
114
  const dragRef = useRef<{
113
115
  startX: number
@@ -165,13 +167,8 @@ export const ThreadPin = memo(function ThreadPin({
165
167
  'pin point',
166
168
  () => {
167
169
  if (thread.pageId !== editor.getCurrentPageId()) return null
168
- const pagePoint = anchorPagePoint(editor, thread.anchor)
169
- if (!pagePoint) return null
170
- const viewportPoint = editor.pageToViewport(pagePoint)
171
- const inset = impreciseShapePinInset(editor, thread.anchor)
172
- const point = inset
173
- ? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y }
174
- : viewportPoint
170
+ const point = anchorViewportPoint(editor, thread.anchor)
171
+ if (!point) return null
175
172
  // Off-screen pins (plus a pre-mount margin) unmount rather than re-render every camera frame. A
176
173
  // region thread stays mounted while any part of its box is on screen, since the box renders here too.
177
174
  if (!exemptFromCull) {
@@ -187,6 +184,9 @@ export const ThreadPin = memo(function ThreadPin({
187
184
  )
188
185
  if (!point) return null
189
186
 
187
+ const toggleOpen = () =>
188
+ openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
189
+
190
190
  const PinContent = options.components.PinContent
191
191
  const threadAuthor = resolveAuthor(thread.createdBy)
192
192
  const pinContent = PinContent ? (
@@ -247,28 +247,28 @@ export const ThreadPin = memo(function ThreadPin({
247
247
  editor.setHintingShapes(hit ? [hit.id] : [])
248
248
  }
249
249
  }
250
- // A cancelled pointer (touch gesture takeover, browser interruption) aborts the drag outright:
251
- // no re-anchor commit, no click-toggle — the pin snaps back and the hint clears.
252
- const cancelDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
250
+ // Release capture and take the drag record; null when no drag was in progress.
251
+ const takeDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
253
252
  const drag = dragRef.current
254
253
  dragRef.current = null
255
254
  if (e.currentTarget.hasPointerCapture(e.pointerId)) {
256
255
  e.currentTarget.releasePointerCapture(e.pointerId)
257
256
  }
258
- if (!drag) return
257
+ return drag
258
+ }
259
+ // A cancelled pointer (touch gesture takeover, browser interruption) aborts the drag outright:
260
+ // no re-anchor commit, no click-toggle — the pin snaps back and the hint clears.
261
+ const cancelDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
262
+ if (!takeDrag(e)) return
259
263
  setDragPagePoint(null)
260
264
  editor.setHintingShapes([])
261
265
  }
262
266
  const endDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
263
- const drag = dragRef.current
264
- dragRef.current = null
265
- if (e.currentTarget.hasPointerCapture(e.pointerId)) {
266
- e.currentTarget.releasePointerCapture(e.pointerId)
267
- }
267
+ const drag = takeDrag(e)
268
268
  if (!drag) return
269
269
  editor.setHintingShapes([])
270
270
  if (!drag.moved) {
271
- openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
271
+ toggleOpen()
272
272
  return
273
273
  }
274
274
  const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
@@ -276,12 +276,7 @@ export const ThreadPin = memo(function ThreadPin({
276
276
  setDragPagePoint(null)
277
277
  let anchor: TLCommentThread['anchor']
278
278
  if (thread.anchor.type === 'region') {
279
- // Translate so the pin (the region's pin corner) lands at the drop; size unchanged.
280
- anchor = {
281
- ...thread.anchor,
282
- x: pagePoint.x - pinCorner.x * thread.anchor.w,
283
- y: pagePoint.y - pinCorner.y * thread.anchor.h,
284
- }
279
+ anchor = regionWithPinAt(thread.anchor, pinCorner, pagePoint)
285
280
  } else {
286
281
  const hit = commentTargetShapeAt(editor, pagePoint)
287
282
  anchor = hit
@@ -316,11 +311,7 @@ export const ThreadPin = memo(function ThreadPin({
316
311
  const regionAnchor = thread.anchor.type === 'region' ? thread.anchor : undefined
317
312
  const movedRegion =
318
313
  regionAnchor && dragPagePoint
319
- ? {
320
- ...regionAnchor,
321
- x: dragPagePoint.x - pinCorner.x * regionAnchor.w,
322
- y: dragPagePoint.y - pinCorner.y * regionAnchor.h,
323
- }
314
+ ? regionWithPinAt(regionAnchor, pinCorner, dragPagePoint)
324
315
  : regionAnchor
325
316
  const regionBoxBounds = resizeBounds ?? movedRegion
326
317
  const commitResize = (bounds: BoxModel) => {
@@ -374,8 +365,7 @@ export const ThreadPin = memo(function ThreadPin({
374
365
  // from a drag), so only take keyboard-synthesised clicks here — those report
375
366
  // `detail === 0` — or the thread would toggle twice per mouse click.
376
367
  onClick={(e) => {
377
- if (e.detail !== 0) return
378
- openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
368
+ if (e.detail === 0) toggleOpen()
379
369
  }}
380
370
  onPointerEnter={previewHandlers.onPointerEnter}
381
371
  onPointerLeave={previewHandlers.onPointerLeave}