@tldraw/commenting 5.5.0-canary.4ccff5f86165 → 5.5.0-canary.4fbbdcf5e86c

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
package/commenting.css CHANGED
@@ -497,35 +497,6 @@
497
497
  user-select: none;
498
498
  }
499
499
 
500
- /* thread */
501
- .tlui-cmt-thread {
502
- display: flex;
503
- flex-direction: column;
504
- width: 300px;
505
- }
506
-
507
- .tlui-cmt-thread__head {
508
- display: flex;
509
- align-items: center;
510
- gap: 8px;
511
- font-size: 12px;
512
- font-weight: 600;
513
- color: var(--tl-color-text-3);
514
- text-transform: uppercase;
515
- letter-spacing: 0.04em;
516
- }
517
-
518
- .tlui-cmt-badge {
519
- font-size: 10px;
520
- font-weight: 600;
521
- text-transform: none;
522
- letter-spacing: 0;
523
- color: #fff;
524
- background: var(--tl-color-muted-1);
525
- padding: 1px 6px;
526
- border-radius: 999px;
527
- }
528
-
529
500
  /* reactions */
530
501
  .tlui-cmt-reactions {
531
502
  display: flex;
@@ -762,9 +733,6 @@ button.tlui-cmt-reaction--active:hover {
762
733
  .tlui-button.tlui-cmt-thread__action:hover {
763
734
  color: var(--tl-color-text-1);
764
735
  }
765
- .tlui-button.tlui-cmt-thread__action--danger:hover {
766
- color: var(--tl-color-danger);
767
- }
768
736
  .tlui-cmt-thread__resolved {
769
737
  margin: 4px 12px;
770
738
  padding: 6px 10px;
@@ -73,9 +73,8 @@ function registerCommentAnchorLifecycle(editor) {
73
73
  if (editor.getShape(shapeId)) continue;
74
74
  for (const [threadId, point] of threadPoints) {
75
75
  if (!point) continue;
76
- const thread = (0, import_comment_store.getCommentRecord)(editor, threadId);
77
- if (!thread || thread.typeName !== "comment-thread") continue;
78
- if (!isAnchoredToShape(thread, shapeId)) continue;
76
+ const thread = (0, import_comment_store.getCommentThread)(editor, threadId);
77
+ if (!thread || !isAnchoredToShape(thread, shapeId)) continue;
79
78
  let converted = convertedByShape.get(shapeId);
80
79
  if (!converted) {
81
80
  converted = [];
@@ -93,8 +92,8 @@ function registerCommentAnchorLifecycle(editor) {
93
92
  convertedByShape.delete(shapeId);
94
93
  const pageId = editor.getAncestorPageId(shape);
95
94
  for (const { threadId, anchor, point } of converted) {
96
- const thread = (0, import_comment_store.getCommentRecord)(editor, threadId);
97
- if (!thread || thread.typeName !== "comment-thread") continue;
95
+ const thread = (0, import_comment_store.getCommentThread)(editor, threadId);
96
+ if (!thread) continue;
98
97
  const current = thread.anchor;
99
98
  if (current.type !== "point" || current.x !== point.x || current.y !== point.y) {
100
99
  continue;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/canvas/anchor-lifecycle.ts"],
4
- "sourcesContent": ["import { WeakCache } from '@tldraw/utils'\nimport { Editor, TLCommentAnchor, TLCommentThread, TLPageId, TLShapeId, VecLike } from 'tldraw'\nimport { commitCommentMutation } from './comment-mutations'\nimport { getCommentRecord, getComments, getCommentThreads, TLCommentRecord } from './comment-store'\nimport { anchorPagePoint, impreciseShapePinInset } from './thread-state'\n\ntype ShapeAnchor = Extract<TLCommentAnchor, { type: 'shape' }>\n\n/**\n * Threads converted to point anchors because their shape was deleted, kept so the anchor can be\n * restored if the shape comes back. Owned by the editor, not the registration closure: the\n * registering effect can re-run, and an undo can arrive long after the delete.\n */\nconst convertedByShapeCache = new WeakCache<\n\tEditor,\n\tMap<TLShapeId, { threadId: string; anchor: ShapeAnchor; point: VecLike }[]>\n>()\n\nfunction isAnchoredToShape(\n\tthread: TLCommentThread,\n\tshapeId: TLShapeId\n): thread is TLCommentThread & { anchor: ShapeAnchor } {\n\tconst anchor = thread.anchor\n\treturn anchor.type === 'shape' && anchor.shapeId === shapeId\n}\n\n/**\n * Keep shape-anchored threads alive across their shape's lifecycle:\n *\n * - When the shape is deleted, the thread converts to a `point` anchor where its pin last sat, so\n * the conversation outlives the shape instead of becoming invisible.\n * - When the shape moves to another page, the thread follows: its `pageId` and each comment's\n * denormalized `pageId` update, and the anchor keeps riding the shape.\n * - When a deleted shape comes back, the thread re-attaches \u2014 unless its pin was manually moved in\n * the meantime, in which case the manual placement wins.\n *\n * A page move is `deleteShapes` + re-create with preserved ids inside one `editor.run`, but each\n * store write is its own operation, so \"this shape is being moved\" is never observable as a single\n * event. The handlers therefore cooperate across operations: `beforeDelete` snapshots where each\n * affected pin sits, the operation-complete pass converts threads whose shape is really gone, and\n * `afterCreate` plus that same pass restore the anchor once the store has settled. Undo/redo of a\n * move replays as a `parentId` update, so `afterChange` re-homes threads on cross-page reparents \u2014\n * including threads anchored to descendants, which move without change events of their own.\n *\n * Remote changes are ignored: the client that performed the operation runs this same maintenance\n * and syncs the result. Writes honour the {@link CommentingOptions.history} option.\n *\n * Registered by `CanvasComments` on mount; parts-built consumers can call this directly. Returns a\n * cleanup function that unregisters all handlers.\n *\n * @public\n */\nexport function registerCommentAnchorLifecycle(editor: Editor): () => void {\n\t// Pin positions snapshotted during the current operation: shape id -> (thread id -> pin page\n\t// point).\n\tconst pendingByShape = new Map<TLShapeId, Map<string, VecLike | null>>()\n\tconst convertedByShape = convertedByShapeCache.get(editor, () => new Map())\n\n\tfunction rehomeThread(thread: TLCommentThread, pageId: TLPageId, updates: TLCommentRecord[]) {\n\t\tupdates.push({ ...thread, pageId })\n\t\tfor (const comment of getComments(editor)) {\n\t\t\tif (comment.threadId === thread.id) updates.push({ ...comment, pageId })\n\t\t}\n\t}\n\n\tconst disposeBeforeDelete = editor.sideEffects.registerBeforeDeleteHandler(\n\t\t'shape',\n\t\t(shape, source) => {\n\t\t\tif (source === 'remote') return\n\t\t\tfor (const thread of getCommentThreads(editor)) {\n\t\t\t\tif (!isAnchoredToShape(thread, shape.id)) continue\n\t\t\t\tlet snapshot = pendingByShape.get(shape.id)\n\t\t\t\tif (!snapshot) {\n\t\t\t\t\tsnapshot = new Map()\n\t\t\t\t\tpendingByShape.set(shape.id, snapshot)\n\t\t\t\t}\n\t\t\t\t// Snapshot where the pin is drawn, not just the anchor point: imprecise shape pins render inset\n\t\t\t\t// toward the shape's centre, so without baking the inset in at the current zoom the pin would jump.\n\t\t\t\tlet point = anchorPagePoint(editor, thread.anchor)\n\t\t\t\tconst inset = impreciseShapePinInset(editor, thread.anchor)\n\t\t\t\tif (point && inset) {\n\t\t\t\t\tconst zoom = editor.getZoomLevel()\n\t\t\t\t\tpoint = { x: point.x + inset.x / zoom, y: point.y + inset.y / zoom }\n\t\t\t\t}\n\t\t\t\tsnapshot.set(thread.id, point)\n\t\t\t}\n\t\t}\n\t)\n\n\t// Shape ids from `convertedByShape` re-created during the current operation. Settled at operation\n\t// complete rather than in `afterCreate`: creation order within an operation is arbitrary, so a shape\n\t// can appear before its parent and not resolve an ancestor page yet.\n\tconst returnedShapeIds = new Set<TLShapeId>()\n\n\tconst disposeOperationComplete = editor.sideEffects.registerOperationCompleteHandler((source) => {\n\t\t// Only user-sourced handlers populate the maps and only local operations should settle\n\t\t// them; a remote operation completing leaves any (impossible-in-practice) leftovers for\n\t\t// the next local settle rather than judging them against remote state.\n\t\tif (source === 'remote') return\n\t\tif (pendingByShape.size === 0 && returnedShapeIds.size === 0) return\n\t\tconst settled = [...pendingByShape.entries()]\n\t\tpendingByShape.clear()\n\t\tconst returned = [...returnedShapeIds]\n\t\treturnedShapeIds.clear()\n\n\t\tconst updates: TLCommentRecord[] = []\n\t\tfor (const [shapeId, threadPoints] of settled) {\n\t\t\tif (editor.getShape(shapeId)) continue\n\t\t\tfor (const [threadId, point] of threadPoints) {\n\t\t\t\tif (!point) continue\n\t\t\t\tconst thread = getCommentRecord(editor, threadId)\n\t\t\t\tif (!thread || thread.typeName !== 'comment-thread') continue\n\t\t\t\tif (!isAnchoredToShape(thread, shapeId)) continue\n\t\t\t\tlet converted = convertedByShape.get(shapeId)\n\t\t\t\tif (!converted) {\n\t\t\t\t\tconverted = []\n\t\t\t\t\tconvertedByShape.set(shapeId, converted)\n\t\t\t\t}\n\t\t\t\tconverted.push({ threadId, anchor: thread.anchor, point })\n\t\t\t\tupdates.push({ ...thread, anchor: { type: 'point', x: point.x, y: point.y } })\n\t\t\t}\n\t\t}\n\n\t\tfor (const shapeId of returned) {\n\t\t\tconst shape = editor.getShape(shapeId)\n\t\t\tif (!shape) continue\n\t\t\tconst converted = convertedByShape.get(shapeId)\n\t\t\tif (!converted) continue\n\t\t\tconvertedByShape.delete(shapeId)\n\n\t\t\tconst pageId = editor.getAncestorPageId(shape)\n\t\t\tfor (const { threadId, anchor, point } of converted) {\n\t\t\t\tconst thread = getCommentRecord(editor, threadId)\n\t\t\t\tif (!thread || thread.typeName !== 'comment-thread') continue\n\t\t\t\t// Re-attach only threads still sitting exactly where the conversion left them \u2014 a\n\t\t\t\t// pin moved since then was placed deliberately, and that placement wins.\n\t\t\t\tconst current = thread.anchor\n\t\t\t\tif (current.type !== 'point' || current.x !== point.x || current.y !== point.y) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif (!pageId || thread.pageId === pageId) {\n\t\t\t\t\tupdates.push({ ...thread, anchor })\n\t\t\t\t} else {\n\t\t\t\t\trehomeThread({ ...thread, anchor }, pageId, updates)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (updates.length > 0) {\n\t\t\tcommitCommentMutation(editor, ({ put }) => put(updates))\n\t\t}\n\t})\n\n\tconst disposeAfterCreate = editor.sideEffects.registerAfterCreateHandler(\n\t\t'shape',\n\t\t(shape, source) => {\n\t\t\tif (source === 'remote') return\n\t\t\tif (convertedByShape.has(shape.id)) returnedShapeIds.add(shape.id)\n\t\t}\n\t)\n\n\tconst disposeAfterChange = editor.sideEffects.registerAfterChangeHandler(\n\t\t'shape',\n\t\t(prev, next, source) => {\n\t\t\tif (source === 'remote') return\n\t\t\tif (prev.parentId === next.parentId) return\n\t\t\tconst pageId = editor.getAncestorPageId(next)\n\t\t\tif (!pageId) return\n\t\t\t// Descendants move with their parent without change events of their own.\n\t\t\tconst movedIds = editor.getShapeAndDescendantIds([next.id])\n\t\t\tconst updates: TLCommentRecord[] = []\n\t\t\tfor (const thread of getCommentThreads(editor)) {\n\t\t\t\tconst anchor = thread.anchor\n\t\t\t\tif (anchor.type !== 'shape') continue\n\t\t\t\tif (!movedIds.has(anchor.shapeId)) continue\n\t\t\t\tif (thread.pageId === pageId) continue\n\t\t\t\trehomeThread(thread, pageId, updates)\n\t\t\t}\n\t\t\tif (updates.length > 0) {\n\t\t\t\tcommitCommentMutation(editor, ({ put }) => put(updates))\n\t\t\t}\n\t\t}\n\t)\n\n\treturn () => {\n\t\tdisposeBeforeDelete()\n\t\tdisposeOperationComplete()\n\t\tdisposeAfterCreate()\n\t\tdisposeAfterChange()\n\t}\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { WeakCache } from '@tldraw/utils'\nimport { Editor, TLCommentAnchor, TLCommentThread, TLPageId, TLShapeId, VecLike } from 'tldraw'\nimport { commitCommentMutation } from './comment-mutations'\nimport { getComments, getCommentThread, getCommentThreads, TLCommentRecord } from './comment-store'\nimport { anchorPagePoint, impreciseShapePinInset } from './thread-state'\n\ntype ShapeAnchor = Extract<TLCommentAnchor, { type: 'shape' }>\n\n/**\n * Threads converted to point anchors because their shape was deleted, kept so the anchor can be\n * restored if the shape comes back. Owned by the editor, not the registration closure: the\n * registering effect can re-run, and an undo can arrive long after the delete.\n */\nconst convertedByShapeCache = new WeakCache<\n\tEditor,\n\tMap<TLShapeId, { threadId: string; anchor: ShapeAnchor; point: VecLike }[]>\n>()\n\nfunction isAnchoredToShape(\n\tthread: TLCommentThread,\n\tshapeId: TLShapeId\n): thread is TLCommentThread & { anchor: ShapeAnchor } {\n\tconst anchor = thread.anchor\n\treturn anchor.type === 'shape' && anchor.shapeId === shapeId\n}\n\n/**\n * Keep shape-anchored threads alive across their shape's lifecycle:\n *\n * - When the shape is deleted, the thread converts to a `point` anchor where its pin last sat, so\n * the conversation outlives the shape instead of becoming invisible.\n * - When the shape moves to another page, the thread follows: its `pageId` and each comment's\n * denormalized `pageId` update, and the anchor keeps riding the shape.\n * - When a deleted shape comes back, the thread re-attaches \u2014 unless its pin was manually moved in\n * the meantime, in which case the manual placement wins.\n *\n * A page move is `deleteShapes` + re-create with preserved ids inside one `editor.run`, but each\n * store write is its own operation, so \"this shape is being moved\" is never observable as a single\n * event. The handlers therefore cooperate across operations: `beforeDelete` snapshots where each\n * affected pin sits, the operation-complete pass converts threads whose shape is really gone, and\n * `afterCreate` plus that same pass restore the anchor once the store has settled. Undo/redo of a\n * move replays as a `parentId` update, so `afterChange` re-homes threads on cross-page reparents \u2014\n * including threads anchored to descendants, which move without change events of their own.\n *\n * Remote changes are ignored: the client that performed the operation runs this same maintenance\n * and syncs the result. Writes honour the {@link CommentingOptions.history} option.\n *\n * Registered by `CanvasComments` on mount; parts-built consumers can call this directly. Returns a\n * cleanup function that unregisters all handlers.\n *\n * @public\n */\nexport function registerCommentAnchorLifecycle(editor: Editor): () => void {\n\t// Pin positions snapshotted during the current operation: shape id -> (thread id -> pin page\n\t// point).\n\tconst pendingByShape = new Map<TLShapeId, Map<string, VecLike | null>>()\n\tconst convertedByShape = convertedByShapeCache.get(editor, () => new Map())\n\n\tfunction rehomeThread(thread: TLCommentThread, pageId: TLPageId, updates: TLCommentRecord[]) {\n\t\tupdates.push({ ...thread, pageId })\n\t\tfor (const comment of getComments(editor)) {\n\t\t\tif (comment.threadId === thread.id) updates.push({ ...comment, pageId })\n\t\t}\n\t}\n\n\tconst disposeBeforeDelete = editor.sideEffects.registerBeforeDeleteHandler(\n\t\t'shape',\n\t\t(shape, source) => {\n\t\t\tif (source === 'remote') return\n\t\t\tfor (const thread of getCommentThreads(editor)) {\n\t\t\t\tif (!isAnchoredToShape(thread, shape.id)) continue\n\t\t\t\tlet snapshot = pendingByShape.get(shape.id)\n\t\t\t\tif (!snapshot) {\n\t\t\t\t\tsnapshot = new Map()\n\t\t\t\t\tpendingByShape.set(shape.id, snapshot)\n\t\t\t\t}\n\t\t\t\t// Snapshot where the pin is drawn, not just the anchor point: imprecise shape pins render inset\n\t\t\t\t// toward the shape's centre, so without baking the inset in at the current zoom the pin would jump.\n\t\t\t\tlet point = anchorPagePoint(editor, thread.anchor)\n\t\t\t\tconst inset = impreciseShapePinInset(editor, thread.anchor)\n\t\t\t\tif (point && inset) {\n\t\t\t\t\tconst zoom = editor.getZoomLevel()\n\t\t\t\t\tpoint = { x: point.x + inset.x / zoom, y: point.y + inset.y / zoom }\n\t\t\t\t}\n\t\t\t\tsnapshot.set(thread.id, point)\n\t\t\t}\n\t\t}\n\t)\n\n\t// Shape ids from `convertedByShape` re-created during the current operation. Settled at operation\n\t// complete rather than in `afterCreate`: creation order within an operation is arbitrary, so a shape\n\t// can appear before its parent and not resolve an ancestor page yet.\n\tconst returnedShapeIds = new Set<TLShapeId>()\n\n\tconst disposeOperationComplete = editor.sideEffects.registerOperationCompleteHandler((source) => {\n\t\t// Only user-sourced handlers populate the maps and only local operations should settle\n\t\t// them; a remote operation completing leaves any (impossible-in-practice) leftovers for\n\t\t// the next local settle rather than judging them against remote state.\n\t\tif (source === 'remote') return\n\t\tif (pendingByShape.size === 0 && returnedShapeIds.size === 0) return\n\t\tconst settled = [...pendingByShape.entries()]\n\t\tpendingByShape.clear()\n\t\tconst returned = [...returnedShapeIds]\n\t\treturnedShapeIds.clear()\n\n\t\tconst updates: TLCommentRecord[] = []\n\t\tfor (const [shapeId, threadPoints] of settled) {\n\t\t\tif (editor.getShape(shapeId)) continue\n\t\t\tfor (const [threadId, point] of threadPoints) {\n\t\t\t\tif (!point) continue\n\t\t\t\tconst thread = getCommentThread(editor, threadId)\n\t\t\t\tif (!thread || !isAnchoredToShape(thread, shapeId)) continue\n\t\t\t\tlet converted = convertedByShape.get(shapeId)\n\t\t\t\tif (!converted) {\n\t\t\t\t\tconverted = []\n\t\t\t\t\tconvertedByShape.set(shapeId, converted)\n\t\t\t\t}\n\t\t\t\tconverted.push({ threadId, anchor: thread.anchor, point })\n\t\t\t\tupdates.push({ ...thread, anchor: { type: 'point', x: point.x, y: point.y } })\n\t\t\t}\n\t\t}\n\n\t\tfor (const shapeId of returned) {\n\t\t\tconst shape = editor.getShape(shapeId)\n\t\t\tif (!shape) continue\n\t\t\tconst converted = convertedByShape.get(shapeId)\n\t\t\tif (!converted) continue\n\t\t\tconvertedByShape.delete(shapeId)\n\n\t\t\tconst pageId = editor.getAncestorPageId(shape)\n\t\t\tfor (const { threadId, anchor, point } of converted) {\n\t\t\t\tconst thread = getCommentThread(editor, threadId)\n\t\t\t\tif (!thread) continue\n\t\t\t\t// Re-attach only threads still sitting exactly where the conversion left them \u2014 a\n\t\t\t\t// pin moved since then was placed deliberately, and that placement wins.\n\t\t\t\tconst current = thread.anchor\n\t\t\t\tif (current.type !== 'point' || current.x !== point.x || current.y !== point.y) {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tif (!pageId || thread.pageId === pageId) {\n\t\t\t\t\tupdates.push({ ...thread, anchor })\n\t\t\t\t} else {\n\t\t\t\t\trehomeThread({ ...thread, anchor }, pageId, updates)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (updates.length > 0) {\n\t\t\tcommitCommentMutation(editor, ({ put }) => put(updates))\n\t\t}\n\t})\n\n\tconst disposeAfterCreate = editor.sideEffects.registerAfterCreateHandler(\n\t\t'shape',\n\t\t(shape, source) => {\n\t\t\tif (source === 'remote') return\n\t\t\tif (convertedByShape.has(shape.id)) returnedShapeIds.add(shape.id)\n\t\t}\n\t)\n\n\tconst disposeAfterChange = editor.sideEffects.registerAfterChangeHandler(\n\t\t'shape',\n\t\t(prev, next, source) => {\n\t\t\tif (source === 'remote') return\n\t\t\tif (prev.parentId === next.parentId) return\n\t\t\tconst pageId = editor.getAncestorPageId(next)\n\t\t\tif (!pageId) return\n\t\t\t// Descendants move with their parent without change events of their own.\n\t\t\tconst movedIds = editor.getShapeAndDescendantIds([next.id])\n\t\t\tconst updates: TLCommentRecord[] = []\n\t\t\tfor (const thread of getCommentThreads(editor)) {\n\t\t\t\tconst anchor = thread.anchor\n\t\t\t\tif (anchor.type !== 'shape') continue\n\t\t\t\tif (!movedIds.has(anchor.shapeId)) continue\n\t\t\t\tif (thread.pageId === pageId) continue\n\t\t\t\trehomeThread(thread, pageId, updates)\n\t\t\t}\n\t\t\tif (updates.length > 0) {\n\t\t\t\tcommitCommentMutation(editor, ({ put }) => put(updates))\n\t\t\t}\n\t\t}\n\t)\n\n\treturn () => {\n\t\tdisposeBeforeDelete()\n\t\tdisposeOperationComplete()\n\t\tdisposeAfterCreate()\n\t\tdisposeAfterChange()\n\t}\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -70,7 +70,7 @@ function reconcileFadeNodes(previous, nextNodes) {
70
70
  const previousItem = previousById.get(node.id);
71
71
  next.push({
72
72
  node,
73
- phase: previousItem && previousItem.phase !== "exiting" ? previousItem.phase : previousItem ? "present" : "entering"
73
+ phase: !previousItem ? "entering" : previousItem.phase === "exiting" ? "present" : previousItem.phase
74
74
  });
75
75
  }
76
76
  for (const item of previous) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/canvas/cluster-fade.ts"],
4
- "sourcesContent": ["import { useEffect, useRef, useState } from 'react'\nimport type { ClusterNode } from '../clustering/types'\n\nconst CLUSTER_FADE_MS = 150\n\nexport type ClusterFadePhase = 'entering' | 'present' | 'exiting'\n\nexport interface ClusterFadeNode {\n\tnode: ClusterNode\n\tphase: ClusterFadePhase\n}\n\n/**\n * Cross-fades the visible cluster nodes as the partition changes: a node that appears starts\n * `entering` and flips to `present` on the next frame, and one that leaves is kept as `exiting`\n * until the CSS transition has run. `resetKey` is compared by identity \u2014 a new cluster model\n * replaces the whole world, so its nodes snap to `present` instead of animating.\n */\nexport function useFadeVisibleNodes(\n\tnodes: readonly ClusterNode[],\n\tresetKey: object\n): ClusterFadeNode[] {\n\tconst resetKeyRef = useRef(resetKey)\n\tconst didReset = resetKeyRef.current !== resetKey\n\tif (didReset) {\n\t\tresetKeyRef.current = resetKey\n\t}\n\n\tconst [fadeNodes, setFadeNodes] = useState<ClusterFadeNode[]>(() => toPresentFadeNodes(nodes))\n\tconst renderedNodes = didReset ? toPresentFadeNodes(nodes) : fadeNodes\n\n\tuseEffect(() => {\n\t\tsetFadeNodes(toPresentFadeNodes(nodes))\n\t\t// Resets only on a new model (resetKey); node-list changes within the same model are\n\t\t// handled by the reconcile effect below, which fades entries in/out instead of snapping.\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [resetKey])\n\n\tuseEffect(() => {\n\t\tif (didReset) return\n\t\tsetFadeNodes((previous) => reconcileFadeNodes(previous, nodes))\n\t}, [didReset, nodes])\n\n\tconst hasEntering = renderedNodes.some((item) => item.phase === 'entering')\n\tuseEffect(() => {\n\t\tif (!hasEntering) return\n\t\tconst frame = requestAnimationFrame(() => {\n\t\t\tsetFadeNodes((previous) =>\n\t\t\t\tprevious.map((item) => (item.phase === 'entering' ? { ...item, phase: 'present' } : item))\n\t\t\t)\n\t\t})\n\t\treturn () => cancelAnimationFrame(frame)\n\t}, [hasEntering, renderedNodes])\n\n\tconst hasExiting = renderedNodes.some((item) => item.phase === 'exiting')\n\tuseEffect(() => {\n\t\tif (!hasExiting) return\n\t\tconst timeout = window.setTimeout(() => {\n\t\t\tsetFadeNodes((previous) => previous.filter((item) => item.phase !== 'exiting'))\n\t\t}, CLUSTER_FADE_MS)\n\t\treturn () => window.clearTimeout(timeout)\n\t}, [hasExiting, renderedNodes])\n\n\treturn renderedNodes\n}\n\nfunction toPresentFadeNodes(nodes: readonly ClusterNode[]): ClusterFadeNode[] {\n\treturn nodes.map((node) => ({ node, phase: 'present' }))\n}\n\nfunction reconcileFadeNodes(\n\tprevious: readonly ClusterFadeNode[],\n\tnextNodes: readonly ClusterNode[]\n): ClusterFadeNode[] {\n\tconst previousById = new Map(previous.map((item) => [item.node.id, item]))\n\tconst nextIds = new Set(nextNodes.map((node) => node.id))\n\tconst next: ClusterFadeNode[] = []\n\n\tfor (const node of nextNodes) {\n\t\tconst previousItem = previousById.get(node.id)\n\t\tnext.push({\n\t\t\tnode,\n\t\t\tphase:\n\t\t\t\tpreviousItem && previousItem.phase !== 'exiting'\n\t\t\t\t\t? previousItem.phase\n\t\t\t\t\t: previousItem\n\t\t\t\t\t\t? 'present'\n\t\t\t\t\t\t: 'entering',\n\t\t})\n\t}\n\n\tfor (const item of previous) {\n\t\tif (nextIds.has(item.node.id)) continue\n\t\tnext.push(item.phase === 'exiting' ? item : { ...item, phase: 'exiting' })\n\t}\n\n\treturn next\n}\n\nexport function clusterFadeClassName(phase: ClusterFadePhase): string {\n\treturn `tlui-cmt-cluster-fade tlui-cmt-cluster-fade--${phase}`\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAA4C;AAG5C,MAAM,kBAAkB;AAejB,SAAS,oBACf,OACA,UACoB;AACpB,QAAM,kBAAc,qBAAO,QAAQ;AACnC,QAAM,WAAW,YAAY,YAAY;AACzC,MAAI,UAAU;AACb,gBAAY,UAAU;AAAA,EACvB;AAEA,QAAM,CAAC,WAAW,YAAY,QAAI,uBAA4B,MAAM,mBAAmB,KAAK,CAAC;AAC7F,QAAM,gBAAgB,WAAW,mBAAmB,KAAK,IAAI;AAE7D,8BAAU,MAAM;AACf,iBAAa,mBAAmB,KAAK,CAAC;AAAA,EAIvC,GAAG,CAAC,QAAQ,CAAC;AAEb,8BAAU,MAAM;AACf,QAAI,SAAU;AACd,iBAAa,CAAC,aAAa,mBAAmB,UAAU,KAAK,CAAC;AAAA,EAC/D,GAAG,CAAC,UAAU,KAAK,CAAC;AAEpB,QAAM,cAAc,cAAc,KAAK,CAAC,SAAS,KAAK,UAAU,UAAU;AAC1E,8BAAU,MAAM;AACf,QAAI,CAAC,YAAa;AAClB,UAAM,QAAQ,sBAAsB,MAAM;AACzC;AAAA,QAAa,CAAC,aACb,SAAS,IAAI,CAAC,SAAU,KAAK,UAAU,aAAa,EAAE,GAAG,MAAM,OAAO,UAAU,IAAI,IAAK;AAAA,MAC1F;AAAA,IACD,CAAC;AACD,WAAO,MAAM,qBAAqB,KAAK;AAAA,EACxC,GAAG,CAAC,aAAa,aAAa,CAAC;AAE/B,QAAM,aAAa,cAAc,KAAK,CAAC,SAAS,KAAK,UAAU,SAAS;AACxE,8BAAU,MAAM;AACf,QAAI,CAAC,WAAY;AACjB,UAAM,UAAU,OAAO,WAAW,MAAM;AACvC,mBAAa,CAAC,aAAa,SAAS,OAAO,CAAC,SAAS,KAAK,UAAU,SAAS,CAAC;AAAA,IAC/E,GAAG,eAAe;AAClB,WAAO,MAAM,OAAO,aAAa,OAAO;AAAA,EACzC,GAAG,CAAC,YAAY,aAAa,CAAC;AAE9B,SAAO;AACR;AAEA,SAAS,mBAAmB,OAAkD;AAC7E,SAAO,MAAM,IAAI,CAAC,UAAU,EAAE,MAAM,OAAO,UAAU,EAAE;AACxD;AAEA,SAAS,mBACR,UACA,WACoB;AACpB,QAAM,eAAe,IAAI,IAAI,SAAS,IAAI,CAAC,SAAS,CAAC,KAAK,KAAK,IAAI,IAAI,CAAC,CAAC;AACzE,QAAM,UAAU,IAAI,IAAI,UAAU,IAAI,CAAC,SAAS,KAAK,EAAE,CAAC;AACxD,QAAM,OAA0B,CAAC;AAEjC,aAAW,QAAQ,WAAW;AAC7B,UAAM,eAAe,aAAa,IAAI,KAAK,EAAE;AAC7C,SAAK,KAAK;AAAA,MACT;AAAA,MACA,OACC,gBAAgB,aAAa,UAAU,YACpC,aAAa,QACb,eACC,YACA;AAAA,IACN,CAAC;AAAA,EACF;AAEA,aAAW,QAAQ,UAAU;AAC5B,QAAI,QAAQ,IAAI,KAAK,KAAK,EAAE,EAAG;AAC/B,SAAK,KAAK,KAAK,UAAU,YAAY,OAAO,EAAE,GAAG,MAAM,OAAO,UAAU,CAAC;AAAA,EAC1E;AAEA,SAAO;AACR;AAEO,SAAS,qBAAqB,OAAiC;AACrE,SAAO,gDAAgD,KAAK;AAC7D;",
4
+ "sourcesContent": ["import { useEffect, useRef, useState } from 'react'\nimport type { ClusterNode } from '../clustering/types'\n\nconst CLUSTER_FADE_MS = 150\n\nexport type ClusterFadePhase = 'entering' | 'present' | 'exiting'\n\nexport interface ClusterFadeNode {\n\tnode: ClusterNode\n\tphase: ClusterFadePhase\n}\n\n/**\n * Cross-fades the visible cluster nodes as the partition changes: a node that appears starts\n * `entering` and flips to `present` on the next frame, and one that leaves is kept as `exiting`\n * until the CSS transition has run. `resetKey` is compared by identity \u2014 a new cluster model\n * replaces the whole world, so its nodes snap to `present` instead of animating.\n */\nexport function useFadeVisibleNodes(\n\tnodes: readonly ClusterNode[],\n\tresetKey: object\n): ClusterFadeNode[] {\n\tconst resetKeyRef = useRef(resetKey)\n\tconst didReset = resetKeyRef.current !== resetKey\n\tif (didReset) {\n\t\tresetKeyRef.current = resetKey\n\t}\n\n\tconst [fadeNodes, setFadeNodes] = useState<ClusterFadeNode[]>(() => toPresentFadeNodes(nodes))\n\tconst renderedNodes = didReset ? toPresentFadeNodes(nodes) : fadeNodes\n\n\tuseEffect(() => {\n\t\tsetFadeNodes(toPresentFadeNodes(nodes))\n\t\t// Resets only on a new model (resetKey); node-list changes within the same model are\n\t\t// handled by the reconcile effect below, which fades entries in/out instead of snapping.\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [resetKey])\n\n\tuseEffect(() => {\n\t\tif (didReset) return\n\t\tsetFadeNodes((previous) => reconcileFadeNodes(previous, nodes))\n\t}, [didReset, nodes])\n\n\tconst hasEntering = renderedNodes.some((item) => item.phase === 'entering')\n\tuseEffect(() => {\n\t\tif (!hasEntering) return\n\t\tconst frame = requestAnimationFrame(() => {\n\t\t\tsetFadeNodes((previous) =>\n\t\t\t\tprevious.map((item) => (item.phase === 'entering' ? { ...item, phase: 'present' } : item))\n\t\t\t)\n\t\t})\n\t\treturn () => cancelAnimationFrame(frame)\n\t}, [hasEntering, renderedNodes])\n\n\tconst hasExiting = renderedNodes.some((item) => item.phase === 'exiting')\n\tuseEffect(() => {\n\t\tif (!hasExiting) return\n\t\tconst timeout = window.setTimeout(() => {\n\t\t\tsetFadeNodes((previous) => previous.filter((item) => item.phase !== 'exiting'))\n\t\t}, CLUSTER_FADE_MS)\n\t\treturn () => window.clearTimeout(timeout)\n\t}, [hasExiting, renderedNodes])\n\n\treturn renderedNodes\n}\n\nfunction toPresentFadeNodes(nodes: readonly ClusterNode[]): ClusterFadeNode[] {\n\treturn nodes.map((node) => ({ node, phase: 'present' }))\n}\n\nfunction reconcileFadeNodes(\n\tprevious: readonly ClusterFadeNode[],\n\tnextNodes: readonly ClusterNode[]\n): ClusterFadeNode[] {\n\tconst previousById = new Map(previous.map((item) => [item.node.id, item]))\n\tconst nextIds = new Set(nextNodes.map((node) => node.id))\n\tconst next: ClusterFadeNode[] = []\n\n\tfor (const node of nextNodes) {\n\t\tconst previousItem = previousById.get(node.id)\n\t\t// A node that was fading out and came back snaps to present rather than re-entering.\n\t\tnext.push({\n\t\t\tnode,\n\t\t\tphase: !previousItem\n\t\t\t\t? 'entering'\n\t\t\t\t: previousItem.phase === 'exiting'\n\t\t\t\t\t? 'present'\n\t\t\t\t\t: previousItem.phase,\n\t\t})\n\t}\n\n\tfor (const item of previous) {\n\t\tif (nextIds.has(item.node.id)) continue\n\t\tnext.push(item.phase === 'exiting' ? item : { ...item, phase: 'exiting' })\n\t}\n\n\treturn next\n}\n\nexport function clusterFadeClassName(phase: ClusterFadePhase): string {\n\treturn `tlui-cmt-cluster-fade tlui-cmt-cluster-fade--${phase}`\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAA4C;AAG5C,MAAM,kBAAkB;AAejB,SAAS,oBACf,OACA,UACoB;AACpB,QAAM,kBAAc,qBAAO,QAAQ;AACnC,QAAM,WAAW,YAAY,YAAY;AACzC,MAAI,UAAU;AACb,gBAAY,UAAU;AAAA,EACvB;AAEA,QAAM,CAAC,WAAW,YAAY,QAAI,uBAA4B,MAAM,mBAAmB,KAAK,CAAC;AAC7F,QAAM,gBAAgB,WAAW,mBAAmB,KAAK,IAAI;AAE7D,8BAAU,MAAM;AACf,iBAAa,mBAAmB,KAAK,CAAC;AAAA,EAIvC,GAAG,CAAC,QAAQ,CAAC;AAEb,8BAAU,MAAM;AACf,QAAI,SAAU;AACd,iBAAa,CAAC,aAAa,mBAAmB,UAAU,KAAK,CAAC;AAAA,EAC/D,GAAG,CAAC,UAAU,KAAK,CAAC;AAEpB,QAAM,cAAc,cAAc,KAAK,CAAC,SAAS,KAAK,UAAU,UAAU;AAC1E,8BAAU,MAAM;AACf,QAAI,CAAC,YAAa;AAClB,UAAM,QAAQ,sBAAsB,MAAM;AACzC;AAAA,QAAa,CAAC,aACb,SAAS,IAAI,CAAC,SAAU,KAAK,UAAU,aAAa,EAAE,GAAG,MAAM,OAAO,UAAU,IAAI,IAAK;AAAA,MAC1F;AAAA,IACD,CAAC;AACD,WAAO,MAAM,qBAAqB,KAAK;AAAA,EACxC,GAAG,CAAC,aAAa,aAAa,CAAC;AAE/B,QAAM,aAAa,cAAc,KAAK,CAAC,SAAS,KAAK,UAAU,SAAS;AACxE,8BAAU,MAAM;AACf,QAAI,CAAC,WAAY;AACjB,UAAM,UAAU,OAAO,WAAW,MAAM;AACvC,mBAAa,CAAC,aAAa,SAAS,OAAO,CAAC,SAAS,KAAK,UAAU,SAAS,CAAC;AAAA,IAC/E,GAAG,eAAe;AAClB,WAAO,MAAM,OAAO,aAAa,OAAO;AAAA,EACzC,GAAG,CAAC,YAAY,aAAa,CAAC;AAE9B,SAAO;AACR;AAEA,SAAS,mBAAmB,OAAkD;AAC7E,SAAO,MAAM,IAAI,CAAC,UAAU,EAAE,MAAM,OAAO,UAAU,EAAE;AACxD;AAEA,SAAS,mBACR,UACA,WACoB;AACpB,QAAM,eAAe,IAAI,IAAI,SAAS,IAAI,CAAC,SAAS,CAAC,KAAK,KAAK,IAAI,IAAI,CAAC,CAAC;AACzE,QAAM,UAAU,IAAI,IAAI,UAAU,IAAI,CAAC,SAAS,KAAK,EAAE,CAAC;AACxD,QAAM,OAA0B,CAAC;AAEjC,aAAW,QAAQ,WAAW;AAC7B,UAAM,eAAe,aAAa,IAAI,KAAK,EAAE;AAE7C,SAAK,KAAK;AAAA,MACT;AAAA,MACA,OAAO,CAAC,eACL,aACA,aAAa,UAAU,YACtB,YACA,aAAa;AAAA,IAClB,CAAC;AAAA,EACF;AAEA,aAAW,QAAQ,UAAU;AAC5B,QAAI,QAAQ,IAAI,KAAK,KAAK,EAAE,EAAG;AAC/B,SAAK,KAAK,KAAK,UAAU,YAAY,OAAO,EAAE,GAAG,MAAM,OAAO,UAAU,CAAC;AAAA,EAC1E;AAEA,SAAO;AACR;AAEO,SAAS,qBAAqB,OAAiC;AACrE,SAAO,gDAAgD,KAAK;AAC7D;",
6
6
  "names": []
7
7
  }
@@ -113,8 +113,11 @@ function useClusterModel(editor, threads, openId) {
113
113
  for (const id of newlyMovedIds) next.add(id);
114
114
  setHeldThreadIds(next);
115
115
  }
116
+ const latestLeafIds = (0, import_react.useMemo)(
117
+ () => new Set(latestModel.table.leaves.map((leaf) => leaf.id)),
118
+ [latestModel]
119
+ );
116
120
  if (clusterModel !== latestModel) {
117
- const latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id));
118
121
  const removedLeafIds = [];
119
122
  for (const leaf of clusterModel.table.leaves) {
120
123
  if (!latestLeafIds.has(leaf.id)) {
@@ -125,24 +128,14 @@ function useClusterModel(editor, threads, openId) {
125
128
  }
126
129
  (0, import_react.useEffect)(() => {
127
130
  if (heldThreadIds.size === 0) return;
128
- let lastZoom = editor.getZoomLevel();
129
- return (0, import_tldraw.react)("rejoin moved comment pins on zoom out", () => {
130
- const zoom = editor.getZoomLevel();
131
- const prevZoom = lastZoom;
132
- lastZoom = zoom;
133
- if (zoom >= prevZoom) return;
131
+ return reactOnZoomOut(editor, "rejoin moved comment pins on zoom out", () => {
134
132
  adoptOnRebuild.current = true;
135
133
  setHeldThreadIds(EMPTY_SET);
136
134
  });
137
135
  }, [heldThreadIds, editor]);
138
136
  (0, import_react.useEffect)(() => {
139
137
  if (clusterModel === latestModel) return;
140
- let lastZoom = editor.getZoomLevel();
141
- return (0, import_tldraw.react)("adopt pending cluster model on zoom out", () => {
142
- const zoom = editor.getZoomLevel();
143
- const prevZoom = lastZoom;
144
- lastZoom = zoom;
145
- if (zoom >= prevZoom) return;
138
+ return reactOnZoomOut(editor, "adopt pending cluster model on zoom out", (zoom) => {
146
139
  latestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible());
147
140
  setRenderedModel(latestModel);
148
141
  });
@@ -154,9 +147,8 @@ function useClusterModel(editor, threads, openId) {
154
147
  for (const node of clusterModel.runtime.getVisible().values()) {
155
148
  for (const member of node.members) displayed.add(member);
156
149
  }
157
- const latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id));
158
- return threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id));
159
- }, [clusterModel, latestModel, threads, partitionVersion]);
150
+ return threads.filter((thread) => latestLeafIds.has(thread.id) && !displayed.has(thread.id));
151
+ }, [clusterModel, latestModel, latestLeafIds, threads, partitionVersion]);
160
152
  const heldThreads = (0, import_react.useMemo)(
161
153
  () => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),
162
154
  [threads, heldThreadIds, openId]
@@ -181,6 +173,15 @@ function useClusterModel(editor, threads, openId) {
181
173
  heldThreads
182
174
  };
183
175
  }
176
+ function reactOnZoomOut(editor, name, onZoomOut) {
177
+ let lastZoom = editor.getZoomLevel();
178
+ return (0, import_tldraw.react)(name, () => {
179
+ const zoom = editor.getZoomLevel();
180
+ const prevZoom = lastZoom;
181
+ lastZoom = zoom;
182
+ if (zoom < prevZoom) onZoomOut(zoom);
183
+ });
184
+ }
184
185
  function anyFoldedLeafMoved(prev, next, rendered) {
185
186
  if (!rendered) return false;
186
187
  if (prev.leaves.length !== next.leaves.length) return false;
@@ -234,12 +235,7 @@ function revealThreadPin(editor, thread, table, zoomBounds, options, duration =
234
235
  if (options.enableClustering) {
235
236
  const parentEvent = findDirectParentEvent(table, thread.id);
236
237
  if (parentEvent && Number.isFinite(parentEvent.zSplit) && parentEvent.zSplit <= zoomBounds.maxZoom) {
237
- const zoom = clamp(
238
- parentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
239
- zoomBounds.minZoom,
240
- zoomBounds.maxZoom
241
- );
242
- centerOnPointAtZoom(editor, point, zoom, duration);
238
+ centerOnPointAtZoom(editor, point, splitZoom(parentEvent, zoomBounds), duration);
243
239
  return;
244
240
  }
245
241
  }
@@ -249,12 +245,10 @@ function revealThreadPin(editor, thread, table, zoomBounds, options, duration =
249
245
  function zoomToClusterSplit(editor, table, zoomBounds, node) {
250
246
  const event = table.events.find((e) => e.result.id === node.id);
251
247
  if (!event || !Number.isFinite(event.zSplit)) return;
252
- const zoom = clamp(
253
- event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
254
- zoomBounds.minZoom,
255
- zoomBounds.maxZoom
256
- );
257
- centerOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS);
248
+ centerOnPointAtZoom(editor, node.centroid, splitZoom(event, zoomBounds), CLUSTER_EXPAND_ZOOM_MS);
249
+ }
250
+ function splitZoom(event, zoomBounds) {
251
+ return (0, import_tldraw.clamp)(event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR, zoomBounds.minZoom, zoomBounds.maxZoom);
258
252
  }
259
253
  function findDirectParentEvent(table, threadId) {
260
254
  return table.events.find((event) => event.children.some((child) => child.id === threadId));
@@ -271,7 +265,4 @@ function centerOnPointAtZoom(editor, point, zoom, duration = 200) {
271
265
  { animation: { duration } }
272
266
  );
273
267
  }
274
- function clamp(value, min, max) {
275
- return Math.max(min, Math.min(max, value));
276
- }
277
268
  //# sourceMappingURL=cluster-model.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/canvas/cluster-model.ts"],
4
- "sourcesContent": ["import { useEffect, useMemo, useRef, useState } from 'react'\nimport { Editor, react, TLCommentThread, useValue } from 'tldraw'\nimport { computeClusterTable } from '../clustering/computeClusterTable'\nimport { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'\nimport type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'\nimport { type ClusterFadeNode, useFadeVisibleNodes } from './cluster-fade'\nimport {\n\ttype ClusterInput,\n\tclusterInputEqual,\n\tclusterInputIdsEqual,\n\tcollectClusterLeaves,\n} from './cluster-input'\nimport { type CommentingOptions } from './options'\nimport { openThreadId } from './state'\nimport { anchorPagePoint, commentCenterScreenOffset } from './thread-state'\n\n/** Duration of the click-a-badge zoom-to-split animation. */\nexport const CLUSTER_EXPAND_ZOOM_MS = 450\n/** How far past a cluster's split zoom to land when expanding it \u2014 a 5% overshoot, so the badge\n * lands clear of the threshold it just crossed rather than flickering on it. */\nconst CLUSTER_SPLIT_ZOOM_FACTOR = 1.05\n\nconst EMPTY_SET: ReadonlySet<string> = new Set()\nconst MOVED_LEAF_EPSILON = 1e-6\n\n/** Default select-tool states that move shapes continuously, one store write per pointermove. A\n * custom tool in their place just never matches, falling back to a rebuild per frame. */\nconst SHAPE_DRAG_STATE_PATHS = [\n\t'select.translating',\n\t'select.resizing',\n\t'select.rotating',\n\t'select.dragging_handle',\n\t'select.crop.cropping',\n] as const\n\n/** Reactive: `isInAny` reads the tool state path, so a computed reading this re-evaluates when the\n * gesture starts or settles. */\nfunction isShapeDragInProgress(editor: Editor): boolean {\n\treturn editor.isInAny(...SHAPE_DRAG_STATE_PATHS)\n}\n\n/** The clustering table for the current scene, plus the runtime walking its merge events. */\nexport interface ClusterModel {\n\truntime: ClusterRuntime\n\ttable: ClusterTable\n}\n\nexport interface ClusterZoomBounds {\n\tminZoom: number\n\tmaxZoom: number\n}\n\nexport interface ClusterModelState {\n\t/** The partition actually on screen. See {@link useClusterModel} for why it can lag the input. */\n\tmodel: ClusterModel\n\tzoomBounds: ClusterZoomBounds\n\t/** The displayed nodes, each tagged with its cross-fade phase. */\n\tfadeNodes: ClusterFadeNode[]\n\t/** Threads in the input that the displayed partition doesn't show \u2014 render them as plain pins. */\n\torphanThreads: TLCommentThread[]\n\t/** Threads held out of clustering because their anchor moved while folded into a badge. */\n\theldThreads: TLCommentThread[]\n}\n\n/**\n * The clustering state machine behind the comments layer.\n *\n * The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild is\n * computed immediately as `latestModel`, but the on-screen partition is `renderedModel`, which only\n * changes via (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or\n * (c) LOCAL detach patches, where a leaf that left the input is detached from its own badge in\n * place and nothing else on the canvas moves.\n *\n * Threads the displayed partition can't represent are returned separately (`orphanThreads`,\n * `heldThreads`) for the layer to draw as ordinary pins.\n */\nexport function useClusterModel(\n\teditor: Editor,\n\tthreads: readonly TLCommentThread[],\n\topenId: string | null\n): ClusterModelState {\n\t// Threads held out of clustering because their anchor moved while folded inside a badge\n\t// (drag, nudge, align, undo, a collaborator \u2014 detected by position, not gesture). They render\n\t// as live pins riding their anchor and rejoin clustering on the next zoom-out.\n\tconst [heldThreadIds, setHeldThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)\n\tconst adoptOnRebuild = useRef(false)\n\t// This input's identity keys the O(N\u00B2) table rebuild below, so it's gated on value equality: a\n\t// reply, a reaction, a resolve \u2014 anything that touches comment records without moving a pin \u2014\n\t// returns the previous input and rebuilds nothing.\n\t//\n\t// Mid-drag the gate ignores positions too \u2014 except a folded leaf's, which a badge can't\n\t// follow. Its first move passes through, the pop-out below holds the pin out as a live pin,\n\t// and the input is static again for the rest of the drag.\n\tconst clusterInputRef = useRef<ClusterInput>({ leaves: [], screenOffsets: undefined })\n\tconst renderedModelRef = useRef<ClusterModel | null>(null)\n\tconst clusterInput = useValue(\n\t\t'comment cluster leaves',\n\t\t() => {\n\t\t\tconst next = collectClusterLeaves(\n\t\t\t\teditor,\n\t\t\t\tthreads.filter((thread) => !heldThreadIds.has(thread.id)),\n\t\t\t\topenThreadId.get(editor)\n\t\t\t)\n\t\t\tconst prev = clusterInputRef.current\n\t\t\tif (\n\t\t\t\tisShapeDragInProgress(editor) &&\n\t\t\t\tclusterInputIdsEqual(prev, next) &&\n\t\t\t\t!anyFoldedLeafMoved(prev, next, renderedModelRef.current)\n\t\t\t) {\n\t\t\t\treturn prev\n\t\t\t}\n\t\t\tif (clusterInputEqual(prev, next)) return prev\n\t\t\tclusterInputRef.current = next\n\t\t\treturn next\n\t\t},\n\t\t[editor, threads, heldThreadIds]\n\t)\n\tconst clusterZoomBounds = useValue(\n\t\t'comment cluster zoom bounds',\n\t\t() => getClusterZoomBounds(editor),\n\t\t[editor]\n\t)\n\tconst latestModel = useMemo(() => {\n\t\tconst table = computeClusterTable(\n\t\t\tclusterInput.leaves,\n\t\t\tclusterZoomBounds,\n\t\t\tclusterInput.screenOffsets\n\t\t)\n\t\tconst runtime = createClusterRuntime(table)\n\t\truntime.seed(editor.getZoomLevel())\n\t\treturn { runtime, table }\n\t}, [clusterInput, clusterZoomBounds, editor])\n\tconst [renderedModel, setRenderedModel] = useState(latestModel)\n\tlet clusterModel = renderedModel\n\t// A page switch replaces the whole scene: hard-reset rather than detach the world.\n\tconst pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])\n\tconst pageRef = useRef(pageId)\n\tif (pageRef.current !== pageId) {\n\t\tpageRef.current = pageId\n\t\tadoptOnRebuild.current = false\n\t\tlatestModel.runtime.seed(editor.getZoomLevel())\n\t\tif (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)\n\t\tsetRenderedModel(latestModel)\n\t\tclusterModel = latestModel\n\t}\n\t// adoptOnRebuild is set outside React's render cycle, paired with clearing heldThreadIds. Only trust\n\t// it once that pairing is visible here, or an unrelated re-render can land in the gap.\n\tconst rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current\n\tif (renderedModel !== latestModel && rejoinPending) {\n\t\tadoptOnRebuild.current = false\n\t\t// Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so\n\t\t// nothing changes state because of the swap alone. Idempotent, so safe during render.\n\t\tlatestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())\n\t\tsetRenderedModel(latestModel)\n\t\tclusterModel = latestModel\n\t} else if (heldThreadIds.size === 0 && renderedModel === latestModel) {\n\t\t// Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't\n\t\t// survive to force-adopt a later, unrelated rebuild.\n\t\tadoptOnRebuild.current = false\n\t}\n\t// For the input gate above: folded-vs-visible is judged against what's on screen.\n\trenderedModelRef.current = clusterModel\n\t// Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position\n\t// is baked into the model), so when its live position drifts from the baked one, hold it out.\n\t// It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.\n\tconst newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)\n\tif (newlyMovedIds.length > 0) {\n\t\tconst next = new Set(heldThreadIds)\n\t\tfor (const id of newlyMovedIds) next.add(id)\n\t\tsetHeldThreadIds(next)\n\t}\n\t// Local partition maintenance \u2014 the only non-zoom visual change. Any displayed leaf that has left the\n\t// cluster input is detached from its badge in place; the corrected rebuild already sits in latestModel\n\t// awaiting the next zoom-out. When the two models match, the leaf sets are identical, so skip the scan.\n\tif (clusterModel !== latestModel) {\n\t\tconst latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))\n\t\tconst removedLeafIds: string[] = []\n\t\tfor (const leaf of clusterModel.table.leaves) {\n\t\t\tif (!latestLeafIds.has(leaf.id)) {\n\t\t\t\tremovedLeafIds.push(leaf.id)\n\t\t\t}\n\t\t}\n\t\t// Batched: one patch rebuild and one version bump for the whole set.\n\t\tif (removedLeafIds.length > 0) clusterModel.runtime.detachLeaves(removedLeafIds)\n\t}\n\t// Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild\n\t// includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in\n\t// never folds pins into clusters \u2014 merging is a zoom-out-only move, matching the runtime.\n\tuseEffect(() => {\n\t\tif (heldThreadIds.size === 0) return\n\t\tlet lastZoom = editor.getZoomLevel()\n\t\treturn react('rejoin moved comment pins on zoom out', () => {\n\t\t\tconst zoom = editor.getZoomLevel()\n\t\t\tconst prevZoom = lastZoom\n\t\t\tlastZoom = zoom\n\t\t\tif (zoom >= prevZoom) return\n\t\t\tadoptOnRebuild.current = true\n\t\t\tsetHeldThreadIds(EMPTY_SET)\n\t\t})\n\t}, [heldThreadIds, editor])\n\t// Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is\n\t// a merge, and merging only happens while zooming out. While zooming in, the stale table still\n\t// splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).\n\tuseEffect(() => {\n\t\tif (clusterModel === latestModel) return\n\t\tlet lastZoom = editor.getZoomLevel()\n\t\treturn react('adopt pending cluster model on zoom out', () => {\n\t\t\tconst zoom = editor.getZoomLevel()\n\t\t\tconst prevZoom = lastZoom\n\t\t\tlastZoom = zoom\n\t\t\tif (zoom >= prevZoom) return\n\t\t\tlatestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())\n\t\t\tsetRenderedModel(latestModel)\n\t\t})\n\t}, [clusterModel, latestModel, editor])\n\t// Threads the displayed partition doesn't show anywhere (new comments, reopened threads, undone\n\t// deletions) render as plain pins until the next zoom-out folds them in. Judged against the\n\t// displayed partition, so a detached-then-restored leaf reappears.\n\tconst partitionVersion = clusterModel.runtime.version\n\tconst orphanThreads = useMemo(() => {\n\t\tif (clusterModel === latestModel) return []\n\t\tconst displayed = new Set<string>()\n\t\tfor (const node of clusterModel.runtime.getVisible().values()) {\n\t\t\tfor (const member of node.members) displayed.add(member)\n\t\t}\n\t\tconst latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))\n\t\treturn threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))\n\t\t// The runtime mutates its partition in place; partitionVersion is its change stamp.\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [clusterModel, latestModel, threads, partitionVersion])\n\tconst heldThreads = useMemo(\n\t\t() => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),\n\t\t[threads, heldThreadIds, openId]\n\t)\n\t// Subscribe to the runtime's partition version, not the raw zoom, so this only re-renders on cluster\n\t// changes rather than every camera frame. The memo below re-reads the version inline because\n\t// render-time detaches bump it after the subscription's computed already evaluated.\n\tuseValue(\n\t\t'comment cluster version',\n\t\t() => {\n\t\t\tclusterModel.runtime.onCamera(editor.getZoomLevel())\n\t\t\treturn clusterModel.runtime.version\n\t\t},\n\t\t[clusterModel, editor]\n\t)\n\tconst visibleNodes = useMemo(() => {\n\t\treturn Array.from(clusterModel.runtime.getVisible().values())\n\t\t// The runtime mutates its partition in place; partitionVersion is its change stamp.\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [clusterModel, partitionVersion])\n\tconst fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)\n\n\treturn {\n\t\tmodel: clusterModel,\n\t\tzoomBounds: clusterZoomBounds,\n\t\tfadeNodes,\n\t\torphanThreads,\n\t\theldThreads,\n\t}\n}\n\n/**\n * Whether a position-only input change (ids equal, same order) moved a leaf folded inside a badge\n * of the rendered partition \u2014 the one move the mid-drag freeze must let through.\n *\n * Folded means a member of a displayed badge, not merely \"absent from the displayed partition\":\n * the input also carries orphans (threads the rendered partition has never seen) and detached\n * leaves, which already ride their anchors as plain pins. Neither is in the rendered table, so the\n * pop-out below can never hold one \u2014 counting them here would break the freeze on every\n * pointermove for the rest of the drag.\n * @internal\n */\nexport function anyFoldedLeafMoved(\n\tprev: ClusterInput,\n\tnext: ClusterInput,\n\trendered: ClusterModel | null\n): boolean {\n\tif (!rendered) return false\n\tif (prev.leaves.length !== next.leaves.length) return false\n\tconst folded = new Set<string>()\n\tfor (const node of rendered.runtime.getVisible().values()) {\n\t\tif (node.count < 2) continue\n\t\tfor (const member of node.members) folded.add(member)\n\t}\n\t// No badges on screen, so nothing can be folded \u2014 the common case, and the cheap way out of it.\n\tif (folded.size === 0) return false\n\tfor (let i = 0; i < next.leaves.length; i++) {\n\t\tif (!folded.has(next.leaves[i].id)) continue\n\t\tconst a = prev.leaves[i].point\n\t\tconst b = next.leaves[i].point\n\t\tif (Math.abs(a.x - b.x) > MOVED_LEAF_EPSILON || Math.abs(a.y - b.y) > MOVED_LEAF_EPSILON) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n/**\n * Leaves folded inside a badge whose live anchor no longer matches the position the rendered\n * model was built with. Visible (unclustered) leaf pins track their anchor live, so they can\n * stay deferred; a badge can't follow a member, so these must pop out of clustering.\n */\nfunction findMovedClusteredLeafIds(rendered: ClusterModel, latest: { table: ClusterTable }) {\n\tif (rendered.table === latest.table) return []\n\tconst visible = rendered.runtime.getVisible()\n\tconst latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]))\n\tconst moved: string[] = []\n\tfor (const leaf of rendered.table.leaves) {\n\t\tif (visible.has(leaf.id)) continue\n\t\tconst current = latestById.get(leaf.id)\n\t\tif (!current) continue\n\t\tif (\n\t\t\tMath.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON ||\n\t\t\tMath.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON\n\t\t) {\n\t\t\tmoved.push(leaf.id)\n\t\t}\n\t}\n\treturn moved\n}\n\nfunction getClusterZoomBounds(editor: Editor): ClusterZoomBounds {\n\tconst cameraOptions = editor.getCameraOptions()\n\tconst baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1\n\tconst zoomSteps = cameraOptions.zoomSteps\n\treturn {\n\t\tminZoom: zoomSteps[0] * baseZoom,\n\t\tmaxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom,\n\t}\n}\n\n/**\n * Bring a thread's pin into view: switch pages if needed, then zoom to the first cluster split\n * that unfolds it from its badge (or just centre on it when it isn't clustered).\n */\nexport function revealThreadPin(\n\teditor: Editor,\n\tthread: TLCommentThread,\n\ttable: ClusterTable,\n\tzoomBounds: ClusterZoomBounds,\n\toptions: CommentingOptions,\n\tduration = 200\n) {\n\tif (thread.pageId !== editor.getCurrentPageId()) {\n\t\teditor.markHistoryStoppingPoint('change-page')\n\t\teditor.setCurrentPage(thread.pageId as any)\n\t}\n\n\tconst point = anchorPagePoint(editor, thread.anchor)\n\tif (!point) return\n\n\t// With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster\n\t// badge never exists) and just center on the pin.\n\tif (options.enableClustering) {\n\t\tconst parentEvent = findDirectParentEvent(table, thread.id)\n\t\tif (\n\t\t\tparentEvent &&\n\t\t\tNumber.isFinite(parentEvent.zSplit) &&\n\t\t\tparentEvent.zSplit <= zoomBounds.maxZoom\n\t\t) {\n\t\t\tconst zoom = clamp(\n\t\t\t\tparentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,\n\t\t\t\tzoomBounds.minZoom,\n\t\t\t\tzoomBounds.maxZoom\n\t\t\t)\n\t\t\tcenterOnPointAtZoom(editor, point, zoom, duration)\n\t\t\treturn\n\t\t}\n\t}\n\n\tconst offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()\n\teditor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })\n}\n\n/**\n * Zoom to just past the zoom at which a cluster first unclusters, centered on its centroid. The\n * event that created a visible cluster is the event that splits it, and has the smallest zSplit of\n * everything applied inside it \u2014 so its zSplit is exactly the first split within those comments.\n * The animated zoom drives the runtime cursor like any manual zoom. A no-op with no split event.\n */\nexport function zoomToClusterSplit(\n\teditor: Editor,\n\ttable: ClusterTable,\n\tzoomBounds: ClusterZoomBounds,\n\tnode: ClusterNode\n) {\n\tconst event = table.events.find((e) => e.result.id === node.id)\n\tif (!event || !Number.isFinite(event.zSplit)) return\n\tconst zoom = clamp(\n\t\tevent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,\n\t\tzoomBounds.minZoom,\n\t\tzoomBounds.maxZoom\n\t)\n\tcenterOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS)\n}\n\nfunction findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {\n\treturn table.events.find((event) => event.children.some((child) => child.id === threadId))\n}\n\nfunction centerOnPointAtZoom(\n\teditor: Editor,\n\tpoint: { x: number; y: number },\n\tzoom: number,\n\tduration = 200\n) {\n\tconst viewport = editor.getViewportScreenBounds()\n\t// The open sidebar shifts the target left so the pin lands mid-uncovered-area, not under it.\n\tconst offset = commentCenterScreenOffset(editor)\n\teditor.setCamera(\n\t\t{\n\t\t\tx: (viewport.w / 2 - offset) / zoom - point.x,\n\t\t\ty: viewport.h / (2 * zoom) - point.y,\n\t\t\tz: zoom,\n\t\t},\n\t\t{ animation: { duration } }\n\t)\n}\n\nfunction clamp(value: number, min: number, max: number): number {\n\treturn Math.max(min, Math.min(max, value))\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAAqD;AACrD,oBAAyD;AACzD,iCAAoC;AACpC,qBAA0D;AAE1D,0BAA0D;AAC1D,2BAKO;AAEP,mBAA6B;AAC7B,0BAA2D;AAGpD,MAAM,yBAAyB;AAGtC,MAAM,4BAA4B;AAElC,MAAM,YAAiC,oBAAI,IAAI;AAC/C,MAAM,qBAAqB;AAI3B,MAAM,yBAAyB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACD;AAIA,SAAS,sBAAsB,QAAyB;AACvD,SAAO,OAAO,QAAQ,GAAG,sBAAsB;AAChD;AAqCO,SAAS,gBACf,QACA,SACA,QACoB;AAIpB,QAAM,CAAC,eAAe,gBAAgB,QAAI,uBAA8B,SAAS;AACjF,QAAM,qBAAiB,qBAAO,KAAK;AAQnC,QAAM,sBAAkB,qBAAqB,EAAE,QAAQ,CAAC,GAAG,eAAe,OAAU,CAAC;AACrF,QAAM,uBAAmB,qBAA4B,IAAI;AACzD,QAAM,mBAAe;AAAA,IACpB;AAAA,IACA,MAAM;AACL,YAAM,WAAO;AAAA,QACZ;AAAA,QACA,QAAQ,OAAO,CAAC,WAAW,CAAC,cAAc,IAAI,OAAO,EAAE,CAAC;AAAA,QACxD,0BAAa,IAAI,MAAM;AAAA,MACxB;AACA,YAAM,OAAO,gBAAgB;AAC7B,UACC,sBAAsB,MAAM,SAC5B,2CAAqB,MAAM,IAAI,KAC/B,CAAC,mBAAmB,MAAM,MAAM,iBAAiB,OAAO,GACvD;AACD,eAAO;AAAA,MACR;AACA,cAAI,wCAAkB,MAAM,IAAI,EAAG,QAAO;AAC1C,sBAAgB,UAAU;AAC1B,aAAO;AAAA,IACR;AAAA,IACA,CAAC,QAAQ,SAAS,aAAa;AAAA,EAChC;AACA,QAAM,wBAAoB;AAAA,IACzB;AAAA,IACA,MAAM,qBAAqB,MAAM;AAAA,IACjC,CAAC,MAAM;AAAA,EACR;AACA,QAAM,kBAAc,sBAAQ,MAAM;AACjC,UAAM,YAAQ;AAAA,MACb,aAAa;AAAA,MACb;AAAA,MACA,aAAa;AAAA,IACd;AACA,UAAM,cAAU,qCAAqB,KAAK;AAC1C,YAAQ,KAAK,OAAO,aAAa,CAAC;AAClC,WAAO,EAAE,SAAS,MAAM;AAAA,EACzB,GAAG,CAAC,cAAc,mBAAmB,MAAM,CAAC;AAC5C,QAAM,CAAC,eAAe,gBAAgB,QAAI,uBAAS,WAAW;AAC9D,MAAI,eAAe;AAEnB,QAAM,aAAS,wBAAS,wBAAwB,MAAM,OAAO,iBAAiB,GAAG,CAAC,MAAM,CAAC;AACzF,QAAM,cAAU,qBAAO,MAAM;AAC7B,MAAI,QAAQ,YAAY,QAAQ;AAC/B,YAAQ,UAAU;AAClB,mBAAe,UAAU;AACzB,gBAAY,QAAQ,KAAK,OAAO,aAAa,CAAC;AAC9C,QAAI,cAAc,OAAO,EAAG,kBAAiB,SAAS;AACtD,qBAAiB,WAAW;AAC5B,mBAAe;AAAA,EAChB;AAGA,QAAM,gBAAgB,cAAc,SAAS,KAAK,eAAe;AACjE,MAAI,kBAAkB,eAAe,eAAe;AACnD,mBAAe,UAAU;AAGzB,gBAAY,QAAQ,SAAS,OAAO,aAAa,GAAG,cAAc,QAAQ,WAAW,CAAC;AACtF,qBAAiB,WAAW;AAC5B,mBAAe;AAAA,EAChB,WAAW,cAAc,SAAS,KAAK,kBAAkB,aAAa;AAGrE,mBAAe,UAAU;AAAA,EAC1B;AAEA,mBAAiB,UAAU;AAI3B,QAAM,gBAAgB,0BAA0B,cAAc,WAAW;AACzE,MAAI,cAAc,SAAS,GAAG;AAC7B,UAAM,OAAO,IAAI,IAAI,aAAa;AAClC,eAAW,MAAM,cAAe,MAAK,IAAI,EAAE;AAC3C,qBAAiB,IAAI;AAAA,EACtB;AAIA,MAAI,iBAAiB,aAAa;AACjC,UAAM,gBAAgB,IAAI,IAAI,YAAY,MAAM,OAAO,IAAI,CAAC,SAAS,KAAK,EAAE,CAAC;AAC7E,UAAM,iBAA2B,CAAC;AAClC,eAAW,QAAQ,aAAa,MAAM,QAAQ;AAC7C,UAAI,CAAC,cAAc,IAAI,KAAK,EAAE,GAAG;AAChC,uBAAe,KAAK,KAAK,EAAE;AAAA,MAC5B;AAAA,IACD;AAEA,QAAI,eAAe,SAAS,EAAG,cAAa,QAAQ,aAAa,cAAc;AAAA,EAChF;AAIA,8BAAU,MAAM;AACf,QAAI,cAAc,SAAS,EAAG;AAC9B,QAAI,WAAW,OAAO,aAAa;AACnC,eAAO,qBAAM,yCAAyC,MAAM;AAC3D,YAAM,OAAO,OAAO,aAAa;AACjC,YAAM,WAAW;AACjB,iBAAW;AACX,UAAI,QAAQ,SAAU;AACtB,qBAAe,UAAU;AACzB,uBAAiB,SAAS;AAAA,IAC3B,CAAC;AAAA,EACF,GAAG,CAAC,eAAe,MAAM,CAAC;AAI1B,8BAAU,MAAM;AACf,QAAI,iBAAiB,YAAa;AAClC,QAAI,WAAW,OAAO,aAAa;AACnC,eAAO,qBAAM,2CAA2C,MAAM;AAC7D,YAAM,OAAO,OAAO,aAAa;AACjC,YAAM,WAAW;AACjB,iBAAW;AACX,UAAI,QAAQ,SAAU;AACtB,kBAAY,QAAQ,SAAS,MAAM,aAAa,QAAQ,WAAW,CAAC;AACpE,uBAAiB,WAAW;AAAA,IAC7B,CAAC;AAAA,EACF,GAAG,CAAC,cAAc,aAAa,MAAM,CAAC;AAItC,QAAM,mBAAmB,aAAa,QAAQ;AAC9C,QAAM,oBAAgB,sBAAQ,MAAM;AACnC,QAAI,iBAAiB,YAAa,QAAO,CAAC;AAC1C,UAAM,YAAY,oBAAI,IAAY;AAClC,eAAW,QAAQ,aAAa,QAAQ,WAAW,EAAE,OAAO,GAAG;AAC9D,iBAAW,UAAU,KAAK,QAAS,WAAU,IAAI,MAAM;AAAA,IACxD;AACA,UAAM,YAAY,IAAI,IAAI,YAAY,MAAM,OAAO,IAAI,CAAC,SAAS,KAAK,EAAE,CAAC;AACzE,WAAO,QAAQ,OAAO,CAAC,WAAW,UAAU,IAAI,OAAO,EAAE,KAAK,CAAC,UAAU,IAAI,OAAO,EAAE,CAAC;AAAA,EAGxF,GAAG,CAAC,cAAc,aAAa,SAAS,gBAAgB,CAAC;AACzD,QAAM,kBAAc;AAAA,IACnB,MAAM,QAAQ,OAAO,CAAC,WAAW,cAAc,IAAI,OAAO,EAAE,KAAK,OAAO,OAAO,MAAM;AAAA,IACrF,CAAC,SAAS,eAAe,MAAM;AAAA,EAChC;AAIA;AAAA,IACC;AAAA,IACA,MAAM;AACL,mBAAa,QAAQ,SAAS,OAAO,aAAa,CAAC;AACnD,aAAO,aAAa,QAAQ;AAAA,IAC7B;AAAA,IACA,CAAC,cAAc,MAAM;AAAA,EACtB;AACA,QAAM,mBAAe,sBAAQ,MAAM;AAClC,WAAO,MAAM,KAAK,aAAa,QAAQ,WAAW,EAAE,OAAO,CAAC;AAAA,EAG7D,GAAG,CAAC,cAAc,gBAAgB,CAAC;AACnC,QAAM,gBAAY,yCAAoB,cAAc,YAAY;AAEhE,SAAO;AAAA,IACN,OAAO;AAAA,IACP,YAAY;AAAA,IACZ;AAAA,IACA;AAAA,IACA;AAAA,EACD;AACD;AAaO,SAAS,mBACf,MACA,MACA,UACU;AACV,MAAI,CAAC,SAAU,QAAO;AACtB,MAAI,KAAK,OAAO,WAAW,KAAK,OAAO,OAAQ,QAAO;AACtD,QAAM,SAAS,oBAAI,IAAY;AAC/B,aAAW,QAAQ,SAAS,QAAQ,WAAW,EAAE,OAAO,GAAG;AAC1D,QAAI,KAAK,QAAQ,EAAG;AACpB,eAAW,UAAU,KAAK,QAAS,QAAO,IAAI,MAAM;AAAA,EACrD;AAEA,MAAI,OAAO,SAAS,EAAG,QAAO;AAC9B,WAAS,IAAI,GAAG,IAAI,KAAK,OAAO,QAAQ,KAAK;AAC5C,QAAI,CAAC,OAAO,IAAI,KAAK,OAAO,CAAC,EAAE,EAAE,EAAG;AACpC,UAAM,IAAI,KAAK,OAAO,CAAC,EAAE;AACzB,UAAM,IAAI,KAAK,OAAO,CAAC,EAAE;AACzB,QAAI,KAAK,IAAI,EAAE,IAAI,EAAE,CAAC,IAAI,sBAAsB,KAAK,IAAI,EAAE,IAAI,EAAE,CAAC,IAAI,oBAAoB;AACzF,aAAO;AAAA,IACR;AAAA,EACD;AACA,SAAO;AACR;AAOA,SAAS,0BAA0B,UAAwB,QAAiC;AAC3F,MAAI,SAAS,UAAU,OAAO,MAAO,QAAO,CAAC;AAC7C,QAAM,UAAU,SAAS,QAAQ,WAAW;AAC5C,QAAM,aAAa,IAAI,IAAI,OAAO,MAAM,OAAO,IAAI,CAAC,SAAS,CAAC,KAAK,IAAI,IAAI,CAAC,CAAC;AAC7E,QAAM,QAAkB,CAAC;AACzB,aAAW,QAAQ,SAAS,MAAM,QAAQ;AACzC,QAAI,QAAQ,IAAI,KAAK,EAAE,EAAG;AAC1B,UAAM,UAAU,WAAW,IAAI,KAAK,EAAE;AACtC,QAAI,CAAC,QAAS;AACd,QACC,KAAK,IAAI,QAAQ,SAAS,IAAI,KAAK,SAAS,CAAC,IAAI,sBACjD,KAAK,IAAI,QAAQ,SAAS,IAAI,KAAK,SAAS,CAAC,IAAI,oBAChD;AACD,YAAM,KAAK,KAAK,EAAE;AAAA,IACnB;AAAA,EACD;AACA,SAAO;AACR;AAEA,SAAS,qBAAqB,QAAmC;AAChE,QAAM,gBAAgB,OAAO,iBAAiB;AAC9C,QAAM,WAAW,cAAc,cAAc,OAAO,YAAY,IAAI;AACpE,QAAM,YAAY,cAAc;AAChC,SAAO;AAAA,IACN,SAAS,UAAU,CAAC,IAAI;AAAA,IACxB,SAAS,UAAU,UAAU,SAAS,CAAC,IAAI;AAAA,EAC5C;AACD;AAMO,SAAS,gBACf,QACA,QACA,OACA,YACA,SACA,WAAW,KACV;AACD,MAAI,OAAO,WAAW,OAAO,iBAAiB,GAAG;AAChD,WAAO,yBAAyB,aAAa;AAC7C,WAAO,eAAe,OAAO,MAAa;AAAA,EAC3C;AAEA,QAAM,YAAQ,qCAAgB,QAAQ,OAAO,MAAM;AACnD,MAAI,CAAC,MAAO;AAIZ,MAAI,QAAQ,kBAAkB;AAC7B,UAAM,cAAc,sBAAsB,OAAO,OAAO,EAAE;AAC1D,QACC,eACA,OAAO,SAAS,YAAY,MAAM,KAClC,YAAY,UAAU,WAAW,SAChC;AACD,YAAM,OAAO;AAAA,QACZ,YAAY,SAAS;AAAA,QACrB,WAAW;AAAA,QACX,WAAW;AAAA,MACZ;AACA,0BAAoB,QAAQ,OAAO,MAAM,QAAQ;AACjD;AAAA,IACD;AAAA,EACD;AAEA,QAAM,aAAS,+CAA0B,MAAM,IAAI,OAAO,aAAa;AACvE,SAAO,cAAc,EAAE,GAAG,MAAM,IAAI,QAAQ,GAAG,MAAM,EAAE,GAAG,EAAE,WAAW,EAAE,SAAS,EAAE,CAAC;AACtF;AAQO,SAAS,mBACf,QACA,OACA,YACA,MACC;AACD,QAAM,QAAQ,MAAM,OAAO,KAAK,CAAC,MAAM,EAAE,OAAO,OAAO,KAAK,EAAE;AAC9D,MAAI,CAAC,SAAS,CAAC,OAAO,SAAS,MAAM,MAAM,EAAG;AAC9C,QAAM,OAAO;AAAA,IACZ,MAAM,SAAS;AAAA,IACf,WAAW;AAAA,IACX,WAAW;AAAA,EACZ;AACA,sBAAoB,QAAQ,KAAK,UAAU,MAAM,sBAAsB;AACxE;AAEA,SAAS,sBAAsB,OAAqB,UAA0C;AAC7F,SAAO,MAAM,OAAO,KAAK,CAAC,UAAU,MAAM,SAAS,KAAK,CAAC,UAAU,MAAM,OAAO,QAAQ,CAAC;AAC1F;AAEA,SAAS,oBACR,QACA,OACA,MACA,WAAW,KACV;AACD,QAAM,WAAW,OAAO,wBAAwB;AAEhD,QAAM,aAAS,+CAA0B,MAAM;AAC/C,SAAO;AAAA,IACN;AAAA,MACC,IAAI,SAAS,IAAI,IAAI,UAAU,OAAO,MAAM;AAAA,MAC5C,GAAG,SAAS,KAAK,IAAI,QAAQ,MAAM;AAAA,MACnC,GAAG;AAAA,IACJ;AAAA,IACA,EAAE,WAAW,EAAE,SAAS,EAAE;AAAA,EAC3B;AACD;AAEA,SAAS,MAAM,OAAe,KAAa,KAAqB;AAC/D,SAAO,KAAK,IAAI,KAAK,KAAK,IAAI,KAAK,KAAK,CAAC;AAC1C;",
4
+ "sourcesContent": ["import { useEffect, useMemo, useRef, useState } from 'react'\nimport { clamp, Editor, react, TLCommentThread, useValue } from 'tldraw'\nimport { computeClusterTable } from '../clustering/computeClusterTable'\nimport { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'\nimport type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'\nimport { type ClusterFadeNode, useFadeVisibleNodes } from './cluster-fade'\nimport {\n\ttype ClusterInput,\n\tclusterInputEqual,\n\tclusterInputIdsEqual,\n\tcollectClusterLeaves,\n} from './cluster-input'\nimport { type CommentingOptions } from './options'\nimport { openThreadId } from './state'\nimport { anchorPagePoint, commentCenterScreenOffset } from './thread-state'\n\n/** Duration of the click-a-badge zoom-to-split animation. */\nexport const CLUSTER_EXPAND_ZOOM_MS = 450\n/** How far past a cluster's split zoom to land when expanding it \u2014 a 5% overshoot, so the badge\n * lands clear of the threshold it just crossed rather than flickering on it. */\nconst CLUSTER_SPLIT_ZOOM_FACTOR = 1.05\n\nconst EMPTY_SET: ReadonlySet<string> = new Set()\nconst MOVED_LEAF_EPSILON = 1e-6\n\n/** Default select-tool states that move shapes continuously, one store write per pointermove. A\n * custom tool in their place just never matches, falling back to a rebuild per frame. */\nconst SHAPE_DRAG_STATE_PATHS = [\n\t'select.translating',\n\t'select.resizing',\n\t'select.rotating',\n\t'select.dragging_handle',\n\t'select.crop.cropping',\n] as const\n\n/** Reactive: `isInAny` reads the tool state path, so a computed reading this re-evaluates when the\n * gesture starts or settles. */\nfunction isShapeDragInProgress(editor: Editor): boolean {\n\treturn editor.isInAny(...SHAPE_DRAG_STATE_PATHS)\n}\n\n/** The clustering table for the current scene, plus the runtime walking its merge events. */\nexport interface ClusterModel {\n\truntime: ClusterRuntime\n\ttable: ClusterTable\n}\n\nexport interface ClusterZoomBounds {\n\tminZoom: number\n\tmaxZoom: number\n}\n\nexport interface ClusterModelState {\n\t/** The partition actually on screen. See {@link useClusterModel} for why it can lag the input. */\n\tmodel: ClusterModel\n\tzoomBounds: ClusterZoomBounds\n\t/** The displayed nodes, each tagged with its cross-fade phase. */\n\tfadeNodes: ClusterFadeNode[]\n\t/** Threads in the input that the displayed partition doesn't show \u2014 render them as plain pins. */\n\torphanThreads: TLCommentThread[]\n\t/** Threads held out of clustering because their anchor moved while folded into a badge. */\n\theldThreads: TLCommentThread[]\n}\n\n/**\n * The clustering state machine behind the comments layer.\n *\n * The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild is\n * computed immediately as `latestModel`, but the on-screen partition is `renderedModel`, which only\n * changes via (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or\n * (c) LOCAL detach patches, where a leaf that left the input is detached from its own badge in\n * place and nothing else on the canvas moves.\n *\n * Threads the displayed partition can't represent are returned separately (`orphanThreads`,\n * `heldThreads`) for the layer to draw as ordinary pins.\n */\nexport function useClusterModel(\n\teditor: Editor,\n\tthreads: readonly TLCommentThread[],\n\topenId: string | null\n): ClusterModelState {\n\t// Threads held out of clustering because their anchor moved while folded inside a badge\n\t// (drag, nudge, align, undo, a collaborator \u2014 detected by position, not gesture). They render\n\t// as live pins riding their anchor and rejoin clustering on the next zoom-out.\n\tconst [heldThreadIds, setHeldThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)\n\tconst adoptOnRebuild = useRef(false)\n\t// This input's identity keys the O(N\u00B2) table rebuild below, so it's gated on value equality: a\n\t// reply, a reaction, a resolve \u2014 anything that touches comment records without moving a pin \u2014\n\t// returns the previous input and rebuilds nothing.\n\t//\n\t// Mid-drag the gate ignores positions too \u2014 except a folded leaf's, which a badge can't\n\t// follow. Its first move passes through, the pop-out below holds the pin out as a live pin,\n\t// and the input is static again for the rest of the drag.\n\tconst clusterInputRef = useRef<ClusterInput>({ leaves: [], screenOffsets: undefined })\n\tconst renderedModelRef = useRef<ClusterModel | null>(null)\n\tconst clusterInput = useValue(\n\t\t'comment cluster leaves',\n\t\t() => {\n\t\t\tconst next = collectClusterLeaves(\n\t\t\t\teditor,\n\t\t\t\tthreads.filter((thread) => !heldThreadIds.has(thread.id)),\n\t\t\t\topenThreadId.get(editor)\n\t\t\t)\n\t\t\tconst prev = clusterInputRef.current\n\t\t\tif (\n\t\t\t\tisShapeDragInProgress(editor) &&\n\t\t\t\tclusterInputIdsEqual(prev, next) &&\n\t\t\t\t!anyFoldedLeafMoved(prev, next, renderedModelRef.current)\n\t\t\t) {\n\t\t\t\treturn prev\n\t\t\t}\n\t\t\tif (clusterInputEqual(prev, next)) return prev\n\t\t\tclusterInputRef.current = next\n\t\t\treturn next\n\t\t},\n\t\t[editor, threads, heldThreadIds]\n\t)\n\tconst clusterZoomBounds = useValue(\n\t\t'comment cluster zoom bounds',\n\t\t() => getClusterZoomBounds(editor),\n\t\t[editor]\n\t)\n\tconst latestModel = useMemo(() => {\n\t\tconst table = computeClusterTable(\n\t\t\tclusterInput.leaves,\n\t\t\tclusterZoomBounds,\n\t\t\tclusterInput.screenOffsets\n\t\t)\n\t\tconst runtime = createClusterRuntime(table)\n\t\truntime.seed(editor.getZoomLevel())\n\t\treturn { runtime, table }\n\t}, [clusterInput, clusterZoomBounds, editor])\n\tconst [renderedModel, setRenderedModel] = useState(latestModel)\n\tlet clusterModel = renderedModel\n\t// A page switch replaces the whole scene: hard-reset rather than detach the world.\n\tconst pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])\n\tconst pageRef = useRef(pageId)\n\tif (pageRef.current !== pageId) {\n\t\tpageRef.current = pageId\n\t\tadoptOnRebuild.current = false\n\t\tlatestModel.runtime.seed(editor.getZoomLevel())\n\t\tif (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)\n\t\tsetRenderedModel(latestModel)\n\t\tclusterModel = latestModel\n\t}\n\t// adoptOnRebuild is set outside React's render cycle, paired with clearing heldThreadIds. Only trust\n\t// it once that pairing is visible here, or an unrelated re-render can land in the gap.\n\tconst rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current\n\tif (renderedModel !== latestModel && rejoinPending) {\n\t\tadoptOnRebuild.current = false\n\t\t// Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so\n\t\t// nothing changes state because of the swap alone. Idempotent, so safe during render.\n\t\tlatestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())\n\t\tsetRenderedModel(latestModel)\n\t\tclusterModel = latestModel\n\t} else if (heldThreadIds.size === 0 && renderedModel === latestModel) {\n\t\t// Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't\n\t\t// survive to force-adopt a later, unrelated rebuild.\n\t\tadoptOnRebuild.current = false\n\t}\n\t// For the input gate above: folded-vs-visible is judged against what's on screen.\n\trenderedModelRef.current = clusterModel\n\t// Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position\n\t// is baked into the model), so when its live position drifts from the baked one, hold it out.\n\t// It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.\n\tconst newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)\n\tif (newlyMovedIds.length > 0) {\n\t\tconst next = new Set(heldThreadIds)\n\t\tfor (const id of newlyMovedIds) next.add(id)\n\t\tsetHeldThreadIds(next)\n\t}\n\tconst latestLeafIds = useMemo(\n\t\t() => new Set(latestModel.table.leaves.map((leaf) => leaf.id)),\n\t\t[latestModel]\n\t)\n\t// Local partition maintenance \u2014 the only non-zoom visual change. Any displayed leaf that has left the\n\t// cluster input is detached from its badge in place; the corrected rebuild already sits in latestModel\n\t// awaiting the next zoom-out. When the two models match, the leaf sets are identical, so skip the scan.\n\tif (clusterModel !== latestModel) {\n\t\tconst removedLeafIds: string[] = []\n\t\tfor (const leaf of clusterModel.table.leaves) {\n\t\t\tif (!latestLeafIds.has(leaf.id)) {\n\t\t\t\tremovedLeafIds.push(leaf.id)\n\t\t\t}\n\t\t}\n\t\t// Batched: one patch rebuild and one version bump for the whole set.\n\t\tif (removedLeafIds.length > 0) clusterModel.runtime.detachLeaves(removedLeafIds)\n\t}\n\t// Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild\n\t// includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in\n\t// never folds pins into clusters \u2014 merging is a zoom-out-only move, matching the runtime.\n\tuseEffect(() => {\n\t\tif (heldThreadIds.size === 0) return\n\t\treturn reactOnZoomOut(editor, 'rejoin moved comment pins on zoom out', () => {\n\t\t\tadoptOnRebuild.current = true\n\t\t\tsetHeldThreadIds(EMPTY_SET)\n\t\t})\n\t}, [heldThreadIds, editor])\n\t// Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is\n\t// a merge, and merging only happens while zooming out. While zooming in, the stale table still\n\t// splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).\n\tuseEffect(() => {\n\t\tif (clusterModel === latestModel) return\n\t\treturn reactOnZoomOut(editor, 'adopt pending cluster model on zoom out', (zoom) => {\n\t\t\tlatestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())\n\t\t\tsetRenderedModel(latestModel)\n\t\t})\n\t}, [clusterModel, latestModel, editor])\n\t// Threads the displayed partition doesn't show anywhere (new comments, reopened threads, undone\n\t// deletions) render as plain pins until the next zoom-out folds them in. Judged against the\n\t// displayed partition, so a detached-then-restored leaf reappears.\n\tconst partitionVersion = clusterModel.runtime.version\n\tconst orphanThreads = useMemo(() => {\n\t\tif (clusterModel === latestModel) return []\n\t\tconst displayed = new Set<string>()\n\t\tfor (const node of clusterModel.runtime.getVisible().values()) {\n\t\t\tfor (const member of node.members) displayed.add(member)\n\t\t}\n\t\treturn threads.filter((thread) => latestLeafIds.has(thread.id) && !displayed.has(thread.id))\n\t\t// The runtime mutates its partition in place; partitionVersion is its change stamp.\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [clusterModel, latestModel, latestLeafIds, threads, partitionVersion])\n\tconst heldThreads = useMemo(\n\t\t() => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),\n\t\t[threads, heldThreadIds, openId]\n\t)\n\t// Subscribe to the runtime's partition version, not the raw zoom, so this only re-renders on cluster\n\t// changes rather than every camera frame. The memo below re-reads the version inline because\n\t// render-time detaches bump it after the subscription's computed already evaluated.\n\tuseValue(\n\t\t'comment cluster version',\n\t\t() => {\n\t\t\tclusterModel.runtime.onCamera(editor.getZoomLevel())\n\t\t\treturn clusterModel.runtime.version\n\t\t},\n\t\t[clusterModel, editor]\n\t)\n\tconst visibleNodes = useMemo(() => {\n\t\treturn Array.from(clusterModel.runtime.getVisible().values())\n\t\t// The runtime mutates its partition in place; partitionVersion is its change stamp.\n\t\t// eslint-disable-next-line react-hooks/exhaustive-deps\n\t}, [clusterModel, partitionVersion])\n\tconst fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)\n\n\treturn {\n\t\tmodel: clusterModel,\n\t\tzoomBounds: clusterZoomBounds,\n\t\tfadeNodes,\n\t\torphanThreads,\n\t\theldThreads,\n\t}\n}\n\n/** Run `onZoomOut` on each zoom change that decreased the zoom, starting from the current level. */\nfunction reactOnZoomOut(editor: Editor, name: string, onZoomOut: (zoom: number) => void) {\n\tlet lastZoom = editor.getZoomLevel()\n\treturn react(name, () => {\n\t\tconst zoom = editor.getZoomLevel()\n\t\tconst prevZoom = lastZoom\n\t\tlastZoom = zoom\n\t\tif (zoom < prevZoom) onZoomOut(zoom)\n\t})\n}\n\n/**\n * Whether a position-only input change (ids equal, same order) moved a leaf folded inside a badge\n * of the rendered partition \u2014 the one move the mid-drag freeze must let through.\n *\n * Folded means a member of a displayed badge, not merely \"absent from the displayed partition\":\n * the input also carries orphans (threads the rendered partition has never seen) and detached\n * leaves, which already ride their anchors as plain pins. Neither is in the rendered table, so the\n * pop-out below can never hold one \u2014 counting them here would break the freeze on every\n * pointermove for the rest of the drag.\n * @internal\n */\nexport function anyFoldedLeafMoved(\n\tprev: ClusterInput,\n\tnext: ClusterInput,\n\trendered: ClusterModel | null\n): boolean {\n\tif (!rendered) return false\n\tif (prev.leaves.length !== next.leaves.length) return false\n\tconst folded = new Set<string>()\n\tfor (const node of rendered.runtime.getVisible().values()) {\n\t\tif (node.count < 2) continue\n\t\tfor (const member of node.members) folded.add(member)\n\t}\n\t// No badges on screen, so nothing can be folded \u2014 the common case, and the cheap way out of it.\n\tif (folded.size === 0) return false\n\tfor (let i = 0; i < next.leaves.length; i++) {\n\t\tif (!folded.has(next.leaves[i].id)) continue\n\t\tconst a = prev.leaves[i].point\n\t\tconst b = next.leaves[i].point\n\t\tif (Math.abs(a.x - b.x) > MOVED_LEAF_EPSILON || Math.abs(a.y - b.y) > MOVED_LEAF_EPSILON) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n/**\n * Leaves folded inside a badge whose live anchor no longer matches the position the rendered\n * model was built with. Visible (unclustered) leaf pins track their anchor live, so they can\n * stay deferred; a badge can't follow a member, so these must pop out of clustering.\n */\nfunction findMovedClusteredLeafIds(rendered: ClusterModel, latest: { table: ClusterTable }) {\n\tif (rendered.table === latest.table) return []\n\tconst visible = rendered.runtime.getVisible()\n\tconst latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]))\n\tconst moved: string[] = []\n\tfor (const leaf of rendered.table.leaves) {\n\t\tif (visible.has(leaf.id)) continue\n\t\tconst current = latestById.get(leaf.id)\n\t\tif (!current) continue\n\t\tif (\n\t\t\tMath.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON ||\n\t\t\tMath.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON\n\t\t) {\n\t\t\tmoved.push(leaf.id)\n\t\t}\n\t}\n\treturn moved\n}\n\nfunction getClusterZoomBounds(editor: Editor): ClusterZoomBounds {\n\tconst cameraOptions = editor.getCameraOptions()\n\tconst baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1\n\tconst zoomSteps = cameraOptions.zoomSteps\n\treturn {\n\t\tminZoom: zoomSteps[0] * baseZoom,\n\t\tmaxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom,\n\t}\n}\n\n/**\n * Bring a thread's pin into view: switch pages if needed, then zoom to the first cluster split\n * that unfolds it from its badge (or just centre on it when it isn't clustered).\n */\nexport function revealThreadPin(\n\teditor: Editor,\n\tthread: TLCommentThread,\n\ttable: ClusterTable,\n\tzoomBounds: ClusterZoomBounds,\n\toptions: CommentingOptions,\n\tduration = 200\n) {\n\tif (thread.pageId !== editor.getCurrentPageId()) {\n\t\teditor.markHistoryStoppingPoint('change-page')\n\t\teditor.setCurrentPage(thread.pageId as any)\n\t}\n\n\tconst point = anchorPagePoint(editor, thread.anchor)\n\tif (!point) return\n\n\t// With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster\n\t// badge never exists) and just center on the pin.\n\tif (options.enableClustering) {\n\t\tconst parentEvent = findDirectParentEvent(table, thread.id)\n\t\tif (\n\t\t\tparentEvent &&\n\t\t\tNumber.isFinite(parentEvent.zSplit) &&\n\t\t\tparentEvent.zSplit <= zoomBounds.maxZoom\n\t\t) {\n\t\t\tcenterOnPointAtZoom(editor, point, splitZoom(parentEvent, zoomBounds), duration)\n\t\t\treturn\n\t\t}\n\t}\n\n\tconst offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()\n\teditor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })\n}\n\n/**\n * Zoom to just past the zoom at which a cluster first unclusters, centered on its centroid. The\n * event that created a visible cluster is the event that splits it, and has the smallest zSplit of\n * everything applied inside it \u2014 so its zSplit is exactly the first split within those comments.\n * The animated zoom drives the runtime cursor like any manual zoom. A no-op with no split event.\n */\nexport function zoomToClusterSplit(\n\teditor: Editor,\n\ttable: ClusterTable,\n\tzoomBounds: ClusterZoomBounds,\n\tnode: ClusterNode\n) {\n\tconst event = table.events.find((e) => e.result.id === node.id)\n\tif (!event || !Number.isFinite(event.zSplit)) return\n\tcenterOnPointAtZoom(editor, node.centroid, splitZoom(event, zoomBounds), CLUSTER_EXPAND_ZOOM_MS)\n}\n\n/** The zoom to land on to see a merge event's cluster just after it splits. */\nfunction splitZoom(event: MergeEvent, zoomBounds: ClusterZoomBounds): number {\n\treturn clamp(event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR, zoomBounds.minZoom, zoomBounds.maxZoom)\n}\n\nfunction findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {\n\treturn table.events.find((event) => event.children.some((child) => child.id === threadId))\n}\n\nfunction centerOnPointAtZoom(\n\teditor: Editor,\n\tpoint: { x: number; y: number },\n\tzoom: number,\n\tduration = 200\n) {\n\tconst viewport = editor.getViewportScreenBounds()\n\t// The open sidebar shifts the target left so the pin lands mid-uncovered-area, not under it.\n\tconst offset = commentCenterScreenOffset(editor)\n\teditor.setCamera(\n\t\t{\n\t\t\tx: (viewport.w / 2 - offset) / zoom - point.x,\n\t\t\ty: viewport.h / (2 * zoom) - point.y,\n\t\t\tz: zoom,\n\t\t},\n\t\t{ animation: { duration } }\n\t)\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -43,33 +43,33 @@ function hasMention(node) {
43
43
  return Array.isArray(node.content) ? node.content.some(hasMention) : false;
44
44
  }
45
45
  const htmlCache = /* @__PURE__ */ new WeakMap();
46
- function renderCommentHtml(richText, resolveName) {
46
+ const textCache = /* @__PURE__ */ new WeakMap();
47
+ function renderWithCommentExtensions(cache, richText, resolveName, render) {
47
48
  const mentions = hasMention(richText);
48
49
  if (!mentions) {
49
- const cached = htmlCache.get(richText);
50
+ const cached = cache.get(richText);
50
51
  if (cached !== void 0) return cached;
51
52
  }
52
53
  const extensions = mentions ? [...import_comment_extensions.commentTipTapExtensions, (0, import_mentions.createMentionExtension)({ resolveName })] : import_comment_extensions.commentTipTapExtensions;
53
- const html = (0, import_tldraw.renderHtmlFromRichTextWithExtensions)(
54
- demoteHeadings(richText),
55
- extensions
54
+ const result = render(demoteHeadings(richText), extensions);
55
+ if (!mentions) cache.set(richText, result);
56
+ return result;
57
+ }
58
+ function renderCommentHtml(richText, resolveName) {
59
+ return renderWithCommentExtensions(
60
+ htmlCache,
61
+ richText,
62
+ resolveName,
63
+ (doc, extensions) => (0, import_tldraw.renderHtmlFromRichTextWithExtensions)(doc, extensions)
56
64
  );
57
- if (!mentions) htmlCache.set(richText, html);
58
- return html;
59
65
  }
60
- const textCache = /* @__PURE__ */ new WeakMap();
61
66
  function renderCommentPlaintext(richText, resolveName) {
62
67
  if ((0, import_comment_extensions.isCommentEmpty)(richText)) return "";
63
- const mentions = hasMention(richText);
64
- if (!mentions) {
65
- const cached = textCache.get(richText);
66
- if (cached !== void 0) return cached;
67
- }
68
- const extensions = mentions ? [...import_comment_extensions.commentTipTapExtensions, (0, import_mentions.createMentionExtension)({ resolveName })] : import_comment_extensions.commentTipTapExtensions;
69
- const text = (0, import_core.generateText)(demoteHeadings(richText), extensions, {
70
- blockSeparator: "\n"
71
- });
72
- if (!mentions) textCache.set(richText, text);
73
- return text;
68
+ return renderWithCommentExtensions(
69
+ textCache,
70
+ richText,
71
+ resolveName,
72
+ (doc, extensions) => (0, import_core.generateText)(doc, extensions, { blockSeparator: "\n" })
73
+ );
74
74
  }
75
75
  //# sourceMappingURL=comment-render.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/canvas/comment-render.ts"],
4
- "sourcesContent": ["import { generateText, JSONContent } from '@tiptap/core'\nimport { type CommentAuthor, createMentionExtension } from '@tldraw/mentions'\nimport { renderHtmlFromRichTextWithExtensions, TLRichText } from 'tldraw'\nimport { commentTipTapExtensions, isCommentEmpty } from '../ui/comment-extensions'\n\n/**\n * The author name shown in a byline when no source can name an id (e.g. a deleted account, or a\n * member with no comment and no live presence). The toolkit's one generic default, applied where a\n * byline needs a name; hosts pre-empt it by resolving the id from `resolveAuthor`. Not a\n * translation key \u2014 a single English literal.\n */\nexport const UNKNOWN_AUTHOR = 'Someone'\n\n/** The `CommentAuthor` fallback for an unresolvable id \u2014 just the name, no color or image.\n * Frozen: the one shared instance is handed to host render slots. */\nexport const UNKNOWN_COMMENT_AUTHOR: CommentAuthor = Object.freeze({ name: UNKNOWN_AUTHOR })\n\n/**\n * The comment extension set has no heading node, so a body that nonetheless contains one (e.g. a\n * record created programmatically or synced from a client with a fuller set) would make TipTap\n * throw on an unknown node type. Demote any heading to a paragraph first, so headings can never\n * render as headings and rendering never crashes.\n */\nfunction demoteHeadings(node: JSONContent): JSONContent {\n\tconst content = Array.isArray(node.content) ? node.content.map(demoteHeadings) : node.content\n\tif (node.type === 'heading') {\n\t\tconst { attrs: _attrs, ...rest } = node\n\t\treturn { ...rest, type: 'paragraph', content }\n\t}\n\treturn content === node.content ? node : { ...node, content }\n}\n\n/**\n * Whether a body contains a mention node. Mention text is resolved from a member id at render time,\n * so a body with one can't be cached by identity alone (the same body renders differently as names\n * change) \u2014 those bodies skip the cache and re-render each call.\n */\nfunction hasMention(node: JSONContent): boolean {\n\tif (node.type === 'mention') return true\n\treturn Array.isArray(node.content) ? node.content.some(hasMention) : false\n}\n\nconst htmlCache = new WeakMap<TLRichText, string>()\n\n/**\n * Render a comment body to HTML through the limited comment extension set (no headings), so a body\n * always renders with comment formatting regardless of the host editor's rich-text config. Renders\n * through tldraw's shared helper, so it gets the same empty-paragraph fix that keeps blank lines\n * from collapsing. `resolveName` maps a member id to its current name for any @mentions.\n */\nexport function renderCommentHtml(\n\trichText: TLRichText,\n\tresolveName?: (id: string) => string | undefined\n): string {\n\tconst mentions = hasMention(richText as JSONContent)\n\tif (!mentions) {\n\t\tconst cached = htmlCache.get(richText)\n\t\tif (cached !== undefined) return cached\n\t}\n\tconst extensions = mentions\n\t\t? [...commentTipTapExtensions, createMentionExtension({ resolveName })]\n\t\t: commentTipTapExtensions\n\tconst html = renderHtmlFromRichTextWithExtensions(\n\t\tdemoteHeadings(richText as JSONContent) as TLRichText,\n\t\textensions\n\t)\n\tif (!mentions) htmlCache.set(richText, html)\n\treturn html\n}\n\nconst textCache = new WeakMap<TLRichText, string>()\n\n/**\n * Flatten a comment body to plaintext through the limited comment extension set \u2014 paragraphs and\n * list items separated by newlines. Used for previews (e.g. the sidebar) where formatting is dropped.\n * `resolveName` maps a member id to its current name for any @mentions.\n */\nexport function renderCommentPlaintext(\n\trichText: TLRichText,\n\tresolveName?: (id: string) => string | undefined\n): string {\n\tif (isCommentEmpty(richText)) return ''\n\tconst mentions = hasMention(richText as JSONContent)\n\tif (!mentions) {\n\t\tconst cached = textCache.get(richText)\n\t\tif (cached !== undefined) return cached\n\t}\n\tconst extensions = mentions\n\t\t? [...commentTipTapExtensions, createMentionExtension({ resolveName })]\n\t\t: commentTipTapExtensions\n\tconst text = generateText(demoteHeadings(richText as JSONContent), extensions, {\n\t\tblockSeparator: '\\n',\n\t})\n\tif (!mentions) textCache.set(richText, text)\n\treturn text\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAA0C;AAC1C,sBAA2D;AAC3D,oBAAiE;AACjE,gCAAwD;AAQjD,MAAM,iBAAiB;AAIvB,MAAM,yBAAwC,OAAO,OAAO,EAAE,MAAM,eAAe,CAAC;AAQ3F,SAAS,eAAe,MAAgC;AACvD,QAAM,UAAU,MAAM,QAAQ,KAAK,OAAO,IAAI,KAAK,QAAQ,IAAI,cAAc,IAAI,KAAK;AACtF,MAAI,KAAK,SAAS,WAAW;AAC5B,UAAM,EAAE,OAAO,QAAQ,GAAG,KAAK,IAAI;AACnC,WAAO,EAAE,GAAG,MAAM,MAAM,aAAa,QAAQ;AAAA,EAC9C;AACA,SAAO,YAAY,KAAK,UAAU,OAAO,EAAE,GAAG,MAAM,QAAQ;AAC7D;AAOA,SAAS,WAAW,MAA4B;AAC/C,MAAI,KAAK,SAAS,UAAW,QAAO;AACpC,SAAO,MAAM,QAAQ,KAAK,OAAO,IAAI,KAAK,QAAQ,KAAK,UAAU,IAAI;AACtE;AAEA,MAAM,YAAY,oBAAI,QAA4B;AAQ3C,SAAS,kBACf,UACA,aACS;AACT,QAAM,WAAW,WAAW,QAAuB;AACnD,MAAI,CAAC,UAAU;AACd,UAAM,SAAS,UAAU,IAAI,QAAQ;AACrC,QAAI,WAAW,OAAW,QAAO;AAAA,EAClC;AACA,QAAM,aAAa,WAChB,CAAC,GAAG,uDAAyB,wCAAuB,EAAE,YAAY,CAAC,CAAC,IACpE;AACH,QAAM,WAAO;AAAA,IACZ,eAAe,QAAuB;AAAA,IACtC;AAAA,EACD;AACA,MAAI,CAAC,SAAU,WAAU,IAAI,UAAU,IAAI;AAC3C,SAAO;AACR;AAEA,MAAM,YAAY,oBAAI,QAA4B;AAO3C,SAAS,uBACf,UACA,aACS;AACT,UAAI,0CAAe,QAAQ,EAAG,QAAO;AACrC,QAAM,WAAW,WAAW,QAAuB;AACnD,MAAI,CAAC,UAAU;AACd,UAAM,SAAS,UAAU,IAAI,QAAQ;AACrC,QAAI,WAAW,OAAW,QAAO;AAAA,EAClC;AACA,QAAM,aAAa,WAChB,CAAC,GAAG,uDAAyB,wCAAuB,EAAE,YAAY,CAAC,CAAC,IACpE;AACH,QAAM,WAAO,0BAAa,eAAe,QAAuB,GAAG,YAAY;AAAA,IAC9E,gBAAgB;AAAA,EACjB,CAAC;AACD,MAAI,CAAC,SAAU,WAAU,IAAI,UAAU,IAAI;AAC3C,SAAO;AACR;",
4
+ "sourcesContent": ["import { type Extensions, generateText, JSONContent } from '@tiptap/core'\nimport { type CommentAuthor, createMentionExtension } from '@tldraw/mentions'\nimport { renderHtmlFromRichTextWithExtensions, TLRichText } from 'tldraw'\nimport { commentTipTapExtensions, isCommentEmpty } from '../ui/comment-extensions'\n\n/**\n * The author name shown in a byline when no source can name an id (e.g. a deleted account, or a\n * member with no comment and no live presence). The toolkit's one generic default, applied where a\n * byline needs a name; hosts pre-empt it by resolving the id from `resolveAuthor`. Not a\n * translation key \u2014 a single English literal.\n */\nexport const UNKNOWN_AUTHOR = 'Someone'\n\n/** The `CommentAuthor` fallback for an unresolvable id \u2014 just the name, no color or image.\n * Frozen: the one shared instance is handed to host render slots. */\nexport const UNKNOWN_COMMENT_AUTHOR: CommentAuthor = Object.freeze({ name: UNKNOWN_AUTHOR })\n\n/**\n * The comment extension set has no heading node, so a body that nonetheless contains one (e.g. a\n * record created programmatically or synced from a client with a fuller set) would make TipTap\n * throw on an unknown node type. Demote any heading to a paragraph first, so headings can never\n * render as headings and rendering never crashes.\n */\nfunction demoteHeadings(node: JSONContent): JSONContent {\n\tconst content = Array.isArray(node.content) ? node.content.map(demoteHeadings) : node.content\n\tif (node.type === 'heading') {\n\t\tconst { attrs: _attrs, ...rest } = node\n\t\treturn { ...rest, type: 'paragraph', content }\n\t}\n\treturn content === node.content ? node : { ...node, content }\n}\n\n/**\n * Whether a body contains a mention node. Mention text is resolved from a member id at render time,\n * so a body with one can't be cached by identity alone (the same body renders differently as names\n * change) \u2014 those bodies skip the cache and re-render each call.\n */\nfunction hasMention(node: JSONContent): boolean {\n\tif (node.type === 'mention') return true\n\treturn Array.isArray(node.content) ? node.content.some(hasMention) : false\n}\n\nconst htmlCache = new WeakMap<TLRichText, string>()\nconst textCache = new WeakMap<TLRichText, string>()\n\n/**\n * Render a body through the comment extension set, memoized by body identity in `cache` unless the\n * body carries a mention (see {@link hasMention}), in which case the mention extension resolving\n * `resolveName` is added and nothing is cached.\n */\nfunction renderWithCommentExtensions(\n\tcache: WeakMap<TLRichText, string>,\n\trichText: TLRichText,\n\tresolveName: ((id: string) => string | undefined) | undefined,\n\trender: (doc: JSONContent, extensions: Extensions) => string\n): string {\n\tconst mentions = hasMention(richText as JSONContent)\n\tif (!mentions) {\n\t\tconst cached = cache.get(richText)\n\t\tif (cached !== undefined) return cached\n\t}\n\tconst extensions = mentions\n\t\t? [...commentTipTapExtensions, createMentionExtension({ resolveName })]\n\t\t: commentTipTapExtensions\n\tconst result = render(demoteHeadings(richText as JSONContent), extensions)\n\tif (!mentions) cache.set(richText, result)\n\treturn result\n}\n\n/**\n * Render a comment body to HTML through the limited comment extension set (no headings), so a body\n * always renders with comment formatting regardless of the host editor's rich-text config. Renders\n * through tldraw's shared helper, so it gets the same empty-paragraph fix that keeps blank lines\n * from collapsing. `resolveName` maps a member id to its current name for any @mentions.\n */\nexport function renderCommentHtml(\n\trichText: TLRichText,\n\tresolveName?: (id: string) => string | undefined\n): string {\n\treturn renderWithCommentExtensions(htmlCache, richText, resolveName, (doc, extensions) =>\n\t\trenderHtmlFromRichTextWithExtensions(doc as TLRichText, extensions)\n\t)\n}\n\n/**\n * Flatten a comment body to plaintext through the limited comment extension set \u2014 paragraphs and\n * list items separated by newlines. Used for previews (e.g. the sidebar) where formatting is dropped.\n * `resolveName` maps a member id to its current name for any @mentions.\n */\nexport function renderCommentPlaintext(\n\trichText: TLRichText,\n\tresolveName?: (id: string) => string | undefined\n): string {\n\tif (isCommentEmpty(richText)) return ''\n\treturn renderWithCommentExtensions(textCache, richText, resolveName, (doc, extensions) =>\n\t\tgenerateText(doc, extensions, { blockSeparator: '\\n' })\n\t)\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,kBAA2D;AAC3D,sBAA2D;AAC3D,oBAAiE;AACjE,gCAAwD;AAQjD,MAAM,iBAAiB;AAIvB,MAAM,yBAAwC,OAAO,OAAO,EAAE,MAAM,eAAe,CAAC;AAQ3F,SAAS,eAAe,MAAgC;AACvD,QAAM,UAAU,MAAM,QAAQ,KAAK,OAAO,IAAI,KAAK,QAAQ,IAAI,cAAc,IAAI,KAAK;AACtF,MAAI,KAAK,SAAS,WAAW;AAC5B,UAAM,EAAE,OAAO,QAAQ,GAAG,KAAK,IAAI;AACnC,WAAO,EAAE,GAAG,MAAM,MAAM,aAAa,QAAQ;AAAA,EAC9C;AACA,SAAO,YAAY,KAAK,UAAU,OAAO,EAAE,GAAG,MAAM,QAAQ;AAC7D;AAOA,SAAS,WAAW,MAA4B;AAC/C,MAAI,KAAK,SAAS,UAAW,QAAO;AACpC,SAAO,MAAM,QAAQ,KAAK,OAAO,IAAI,KAAK,QAAQ,KAAK,UAAU,IAAI;AACtE;AAEA,MAAM,YAAY,oBAAI,QAA4B;AAClD,MAAM,YAAY,oBAAI,QAA4B;AAOlD,SAAS,4BACR,OACA,UACA,aACA,QACS;AACT,QAAM,WAAW,WAAW,QAAuB;AACnD,MAAI,CAAC,UAAU;AACd,UAAM,SAAS,MAAM,IAAI,QAAQ;AACjC,QAAI,WAAW,OAAW,QAAO;AAAA,EAClC;AACA,QAAM,aAAa,WAChB,CAAC,GAAG,uDAAyB,wCAAuB,EAAE,YAAY,CAAC,CAAC,IACpE;AACH,QAAM,SAAS,OAAO,eAAe,QAAuB,GAAG,UAAU;AACzE,MAAI,CAAC,SAAU,OAAM,IAAI,UAAU,MAAM;AACzC,SAAO;AACR;AAQO,SAAS,kBACf,UACA,aACS;AACT,SAAO;AAAA,IAA4B;AAAA,IAAW;AAAA,IAAU;AAAA,IAAa,CAAC,KAAK,mBAC1E,oDAAqC,KAAmB,UAAU;AAAA,EACnE;AACD;AAOO,SAAS,uBACf,UACA,aACS;AACT,UAAI,0CAAe,QAAQ,EAAG,QAAO;AACrC,SAAO;AAAA,IAA4B;AAAA,IAAW;AAAA,IAAU;AAAA,IAAa,CAAC,KAAK,mBAC1E,0BAAa,KAAK,YAAY,EAAE,gBAAgB,KAAK,CAAC;AAAA,EACvD;AACD;",
6
6
  "names": []
7
7
  }
@@ -20,6 +20,7 @@ var comment_store_exports = {};
20
20
  __export(comment_store_exports, {
21
21
  getCommentReactions: () => getCommentReactions,
22
22
  getCommentRecord: () => getCommentRecord,
23
+ getCommentThread: () => getCommentThread,
23
24
  getCommentThreads: () => getCommentThreads,
24
25
  getComments: () => getComments,
25
26
  getLiveCommentThreads: () => getLiveCommentThreads,
@@ -34,13 +35,15 @@ function getCommentRecord(editor, id) {
34
35
  }
35
36
  return void 0;
36
37
  }
38
+ function getCommentThread(editor, id) {
39
+ const record = getCommentRecord(editor, id);
40
+ return record?.typeName === "comment-thread" ? record : void 0;
41
+ }
37
42
  function getCommentThreads(editor) {
38
- const typeName = "comment-thread";
39
- return editor.store.query.records(typeName).get();
43
+ return queryCommentRecords(editor, "comment-thread");
40
44
  }
41
45
  function getComments(editor) {
42
- const typeName = "comment";
43
- return editor.store.query.records(typeName).get();
46
+ return queryCommentRecords(editor, "comment");
44
47
  }
45
48
  function getLiveComments(editor) {
46
49
  return getComments(editor).filter((comment) => !comment.isDeleted);
@@ -52,7 +55,9 @@ function getLiveCommentThreads(editor) {
52
55
  );
53
56
  }
54
57
  function getCommentReactions(editor) {
55
- const typeName = "comment-reaction";
58
+ return queryCommentRecords(editor, "comment-reaction");
59
+ }
60
+ function queryCommentRecords(editor, typeName) {
56
61
  return editor.store.query.records(typeName).get();
57
62
  }
58
63
  //# sourceMappingURL=comment-store.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/canvas/comment-store.ts"],
4
- "sourcesContent": ["import { Editor, TLComment, TLCommentReaction, TLCommentThread, TLRecord } from 'tldraw'\n\n/**\n * Typed reads of comment records on the editor store.\n *\n * Comment records live on the editor's local store so the canvas can render them reactively, but\n * they're opt-in and aren't part of the `TLRecord` union \u2014 so `editor.store` is statically typed\n * `Store<TLRecord>` and every access has to reinterpret the type. These helpers own that\n * reinterpretation behind one boundary and keep call sites typed.\n *\n * Writes do the same, but also answer to the undo/redo policy, so they live in\n * `comment-mutations.ts`.\n */\n\n/**\n * A record that lives in a comment thread: the thread itself, one of its messages, or a reaction\n * to one of those messages.\n * @public\n */\nexport type TLCommentRecord = TLComment | TLCommentThread | TLCommentReaction\n\n/** Read one comment record by id, or `undefined` if the id isn't a present comment record. @public */\nexport function getCommentRecord(editor: Editor, id: string): TLCommentRecord | undefined {\n\tconst record = editor.store.get(id as TLRecord['id']) as unknown as TLCommentRecord | undefined\n\tif (!record) return undefined\n\tif (\n\t\trecord.typeName === 'comment' ||\n\t\trecord.typeName === 'comment-thread' ||\n\t\trecord.typeName === 'comment-reaction'\n\t) {\n\t\treturn record\n\t}\n\treturn undefined\n}\n\n/**\n * Every comment thread in the store, **including soft-deleted and emptied ones** awaiting the\n * server's prune, which nothing renders. For the set the UI shows, use {@link getLiveCommentThreads}.\n *\n * Non-reactive; wrap in `useValue`, or use `useCommentThreads`, to react.\n * @public\n */\nexport function getCommentThreads(editor: Editor): TLCommentThread[] {\n\tconst typeName = 'comment-thread' as TLRecord['typeName']\n\treturn editor.store.query.records(typeName).get() as unknown as TLCommentThread[]\n}\n\n/**\n * Every comment in the store, **including soft-deleted ones** awaiting the server's prune. For the\n * set the UI shows, use {@link getLiveComments}.\n *\n * Non-reactive; wrap in `useValue`, or use `useComments`, to react.\n * @public\n */\nexport function getComments(editor: Editor): TLComment[] {\n\tconst typeName = 'comment' as TLRecord['typeName']\n\treturn editor.store.query.records(typeName).get() as unknown as TLComment[]\n}\n\n/**\n * The comments that should render: not soft-deleted. A deleted record lingers in the store until\n * the server prunes it, so build counts and lists from this rather than {@link getComments}.\n *\n * Non-reactive; the reactive equivalent is `useComments` (which also sorts oldest first).\n * @public\n */\nexport function getLiveComments(editor: Editor): TLComment[] {\n\treturn getComments(editor).filter((comment) => !comment.isDeleted)\n}\n\n/**\n * The comment threads that should render (pins, sidebar): not soft-deleted, and still holding at\n * least one live comment. A thread emptied by its last comment's delete lingers with no surface\n * until the server's prune lands.\n *\n * Non-reactive; the reactive equivalent is `useCommentThreads`.\n * @public\n */\nexport function getLiveCommentThreads(editor: Editor): TLCommentThread[] {\n\tconst threadIdsWithComments = new Set(getLiveComments(editor).map((comment) => comment.threadId))\n\treturn getCommentThreads(editor).filter(\n\t\t(thread) => !thread.isDeleted && threadIdsWithComments.has(thread.id)\n\t)\n}\n\n/** All comment reactions currently in the store (non-reactive; wrap in `useValue` to react). @public */\nexport function getCommentReactions(editor: Editor): TLCommentReaction[] {\n\tconst typeName = 'comment-reaction' as TLRecord['typeName']\n\treturn editor.store.query.records(typeName).get() as unknown as TLCommentReaction[]\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsBO,SAAS,iBAAiB,QAAgB,IAAyC;AACzF,QAAM,SAAS,OAAO,MAAM,IAAI,EAAoB;AACpD,MAAI,CAAC,OAAQ,QAAO;AACpB,MACC,OAAO,aAAa,aACpB,OAAO,aAAa,oBACpB,OAAO,aAAa,oBACnB;AACD,WAAO;AAAA,EACR;AACA,SAAO;AACR;AASO,SAAS,kBAAkB,QAAmC;AACpE,QAAM,WAAW;AACjB,SAAO,OAAO,MAAM,MAAM,QAAQ,QAAQ,EAAE,IAAI;AACjD;AASO,SAAS,YAAY,QAA6B;AACxD,QAAM,WAAW;AACjB,SAAO,OAAO,MAAM,MAAM,QAAQ,QAAQ,EAAE,IAAI;AACjD;AASO,SAAS,gBAAgB,QAA6B;AAC5D,SAAO,YAAY,MAAM,EAAE,OAAO,CAAC,YAAY,CAAC,QAAQ,SAAS;AAClE;AAUO,SAAS,sBAAsB,QAAmC;AACxE,QAAM,wBAAwB,IAAI,IAAI,gBAAgB,MAAM,EAAE,IAAI,CAAC,YAAY,QAAQ,QAAQ,CAAC;AAChG,SAAO,kBAAkB,MAAM,EAAE;AAAA,IAChC,CAAC,WAAW,CAAC,OAAO,aAAa,sBAAsB,IAAI,OAAO,EAAE;AAAA,EACrE;AACD;AAGO,SAAS,oBAAoB,QAAqC;AACxE,QAAM,WAAW;AACjB,SAAO,OAAO,MAAM,MAAM,QAAQ,QAAQ,EAAE,IAAI;AACjD;",
4
+ "sourcesContent": ["import { Editor, TLComment, TLCommentReaction, TLCommentThread, TLRecord } from 'tldraw'\n\n/**\n * Typed reads of comment records on the editor store.\n *\n * Comment records live on the editor's local store so the canvas can render them reactively, but\n * they're opt-in and aren't part of the `TLRecord` union \u2014 so `editor.store` is statically typed\n * `Store<TLRecord>` and every access has to reinterpret the type. These helpers own that\n * reinterpretation behind one boundary and keep call sites typed.\n *\n * Writes do the same, but also answer to the undo/redo policy, so they live in\n * `comment-mutations.ts`.\n */\n\n/**\n * A record that lives in a comment thread: the thread itself, one of its messages, or a reaction\n * to one of those messages.\n * @public\n */\nexport type TLCommentRecord = TLComment | TLCommentThread | TLCommentReaction\n\n/** Read one comment record by id, or `undefined` if the id isn't a present comment record. @public */\nexport function getCommentRecord(editor: Editor, id: string): TLCommentRecord | undefined {\n\tconst record = editor.store.get(id as TLRecord['id']) as unknown as TLCommentRecord | undefined\n\tif (!record) return undefined\n\tif (\n\t\trecord.typeName === 'comment' ||\n\t\trecord.typeName === 'comment-thread' ||\n\t\trecord.typeName === 'comment-reaction'\n\t) {\n\t\treturn record\n\t}\n\treturn undefined\n}\n\n/** Read one comment thread by id, or `undefined` if the id isn't a present thread. @internal */\nexport function getCommentThread(editor: Editor, id: string): TLCommentThread | undefined {\n\tconst record = getCommentRecord(editor, id)\n\treturn record?.typeName === 'comment-thread' ? record : undefined\n}\n\n/**\n * Every comment thread in the store, **including soft-deleted and emptied ones** awaiting the\n * server's prune, which nothing renders. For the set the UI shows, use {@link getLiveCommentThreads}.\n *\n * Non-reactive; wrap in `useValue`, or use `useCommentThreads`, to react.\n * @public\n */\nexport function getCommentThreads(editor: Editor): TLCommentThread[] {\n\treturn queryCommentRecords<TLCommentThread>(editor, 'comment-thread')\n}\n\n/**\n * Every comment in the store, **including soft-deleted ones** awaiting the server's prune. For the\n * set the UI shows, use {@link getLiveComments}.\n *\n * Non-reactive; wrap in `useValue`, or use `useComments`, to react.\n * @public\n */\nexport function getComments(editor: Editor): TLComment[] {\n\treturn queryCommentRecords<TLComment>(editor, 'comment')\n}\n\n/**\n * The comments that should render: not soft-deleted. A deleted record lingers in the store until\n * the server prunes it, so build counts and lists from this rather than {@link getComments}.\n *\n * Non-reactive; the reactive equivalent is `useComments` (which also sorts oldest first).\n * @public\n */\nexport function getLiveComments(editor: Editor): TLComment[] {\n\treturn getComments(editor).filter((comment) => !comment.isDeleted)\n}\n\n/**\n * The comment threads that should render (pins, sidebar): not soft-deleted, and still holding at\n * least one live comment. A thread emptied by its last comment's delete lingers with no surface\n * until the server's prune lands.\n *\n * Non-reactive; the reactive equivalent is `useCommentThreads`.\n * @public\n */\nexport function getLiveCommentThreads(editor: Editor): TLCommentThread[] {\n\tconst threadIdsWithComments = new Set(getLiveComments(editor).map((comment) => comment.threadId))\n\treturn getCommentThreads(editor).filter(\n\t\t(thread) => !thread.isDeleted && threadIdsWithComments.has(thread.id)\n\t)\n}\n\n/** All comment reactions currently in the store (non-reactive; wrap in `useValue` to react). @public */\nexport function getCommentReactions(editor: Editor): TLCommentReaction[] {\n\treturn queryCommentRecords<TLCommentReaction>(editor, 'comment-reaction')\n}\n\nfunction queryCommentRecords<T extends TLCommentRecord>(\n\teditor: Editor,\n\ttypeName: T['typeName']\n): T[] {\n\treturn editor.store.query.records(typeName as TLRecord['typeName']).get() as unknown as T[]\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsBO,SAAS,iBAAiB,QAAgB,IAAyC;AACzF,QAAM,SAAS,OAAO,MAAM,IAAI,EAAoB;AACpD,MAAI,CAAC,OAAQ,QAAO;AACpB,MACC,OAAO,aAAa,aACpB,OAAO,aAAa,oBACpB,OAAO,aAAa,oBACnB;AACD,WAAO;AAAA,EACR;AACA,SAAO;AACR;AAGO,SAAS,iBAAiB,QAAgB,IAAyC;AACzF,QAAM,SAAS,iBAAiB,QAAQ,EAAE;AAC1C,SAAO,QAAQ,aAAa,mBAAmB,SAAS;AACzD;AASO,SAAS,kBAAkB,QAAmC;AACpE,SAAO,oBAAqC,QAAQ,gBAAgB;AACrE;AASO,SAAS,YAAY,QAA6B;AACxD,SAAO,oBAA+B,QAAQ,SAAS;AACxD;AASO,SAAS,gBAAgB,QAA6B;AAC5D,SAAO,YAAY,MAAM,EAAE,OAAO,CAAC,YAAY,CAAC,QAAQ,SAAS;AAClE;AAUO,SAAS,sBAAsB,QAAmC;AACxE,QAAM,wBAAwB,IAAI,IAAI,gBAAgB,MAAM,EAAE,IAAI,CAAC,YAAY,QAAQ,QAAQ,CAAC;AAChG,SAAO,kBAAkB,MAAM,EAAE;AAAA,IAChC,CAAC,WAAW,CAAC,OAAO,aAAa,sBAAsB,IAAI,OAAO,EAAE;AAAA,EACrE;AACD;AAGO,SAAS,oBAAoB,QAAqC;AACxE,SAAO,oBAAuC,QAAQ,kBAAkB;AACzE;AAEA,SAAS,oBACR,QACA,UACM;AACN,SAAO,OAAO,MAAM,MAAM,QAAQ,QAAgC,EAAE,IAAI;AACzE;",
6
6
  "names": []
7
7
  }