@tldraw/commenting 5.3.0-next.2fa9c61a8de6 → 5.3.0-next.7654e7ac2a02

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 (238) hide show
  1. package/commenting.css +63 -71
  2. package/dist-cjs/canvas/anchor-lifecycle.js +2 -2
  3. package/dist-cjs/canvas/anchor-lifecycle.js.map +2 -2
  4. package/dist-cjs/{ui/tooltip-button.js → canvas/canvas-events.js} +16 -13
  5. package/dist-cjs/canvas/canvas-events.js.map +7 -0
  6. package/dist-cjs/canvas/cluster-badge.js +121 -0
  7. package/dist-cjs/canvas/cluster-badge.js.map +7 -0
  8. package/dist-cjs/canvas/cluster-fade.js +85 -0
  9. package/dist-cjs/canvas/cluster-fade.js.map +7 -0
  10. package/dist-cjs/canvas/cluster-input.js +51 -1
  11. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  12. package/dist-cjs/canvas/cluster-model.js +252 -0
  13. package/dist-cjs/canvas/cluster-model.js.map +7 -0
  14. package/dist-cjs/canvas/comment-mutations.js +57 -28
  15. package/dist-cjs/canvas/comment-mutations.js.map +2 -2
  16. package/dist-cjs/canvas/comment-reactions.js +4 -7
  17. package/dist-cjs/canvas/comment-reactions.js.map +2 -2
  18. package/dist-cjs/canvas/comment-render.js +4 -3
  19. package/dist-cjs/canvas/comment-render.js.map +2 -2
  20. package/dist-cjs/canvas/comment-store.js.map +2 -2
  21. package/dist-cjs/canvas/comment-tool.js +0 -1
  22. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  23. package/dist-cjs/canvas/comments-filter-menu.js +11 -13
  24. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  25. package/dist-cjs/canvas/comments-overflow-menu.js +10 -2
  26. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  27. package/dist-cjs/canvas/comments-overlay.js +77 -870
  28. package/dist-cjs/canvas/comments-overlay.js.map +3 -3
  29. package/dist-cjs/canvas/comments-sidebar.js +16 -11
  30. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  31. package/dist-cjs/canvas/comments-visibility-toggle.js +5 -25
  32. package/dist-cjs/canvas/comments-visibility-toggle.js.map +2 -2
  33. package/dist-cjs/canvas/context.js.map +1 -1
  34. package/dist-cjs/canvas/hooks.js +8 -2
  35. package/dist-cjs/canvas/hooks.js.map +2 -2
  36. package/dist-cjs/canvas/license.js.map +1 -1
  37. package/dist-cjs/canvas/mobile-placement.js +91 -0
  38. package/dist-cjs/canvas/mobile-placement.js.map +7 -0
  39. package/dist-cjs/canvas/options.js +39 -1
  40. package/dist-cjs/canvas/options.js.map +2 -2
  41. package/dist-cjs/canvas/pending-composer.js +134 -0
  42. package/dist-cjs/canvas/pending-composer.js.map +7 -0
  43. package/dist-cjs/canvas/pin-stacking.js +26 -1
  44. package/dist-cjs/canvas/pin-stacking.js.map +2 -2
  45. package/dist-cjs/canvas/region-box.js +113 -0
  46. package/dist-cjs/canvas/region-box.js.map +7 -0
  47. package/dist-cjs/canvas/state.js.map +2 -2
  48. package/dist-cjs/canvas/thread-pin.js +304 -0
  49. package/dist-cjs/canvas/thread-pin.js.map +7 -0
  50. package/dist-cjs/canvas/thread-preview.js +41 -49
  51. package/dist-cjs/canvas/thread-preview.js.map +2 -2
  52. package/dist-cjs/canvas/thread-stack.js +7 -7
  53. package/dist-cjs/canvas/thread-stack.js.map +2 -2
  54. package/dist-cjs/canvas/thread-state.js +15 -0
  55. package/dist-cjs/canvas/thread-state.js.map +2 -2
  56. package/dist-cjs/canvas/thread-view.js +120 -67
  57. package/dist-cjs/canvas/thread-view.js.map +3 -3
  58. package/dist-cjs/clustering/computeClusterTable.js +2 -2
  59. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  60. package/dist-cjs/clustering/replay.js +89 -22
  61. package/dist-cjs/clustering/replay.js.map +3 -3
  62. package/dist-cjs/clustering/runtime.js +19 -12
  63. package/dist-cjs/clustering/runtime.js.map +2 -2
  64. package/dist-cjs/clustering/types.js.map +1 -1
  65. package/dist-cjs/index.d.ts +323 -202
  66. package/dist-cjs/index.js +7 -1
  67. package/dist-cjs/index.js.map +2 -2
  68. package/dist-cjs/ui/byline.js +5 -3
  69. package/dist-cjs/ui/byline.js.map +2 -2
  70. package/dist-cjs/ui/comment-composer.js +2 -18
  71. package/dist-cjs/ui/comment-composer.js.map +2 -2
  72. package/dist-cjs/ui/comment-pin.js +2 -15
  73. package/dist-cjs/ui/comment-pin.js.map +2 -2
  74. package/dist-cjs/ui/comments-list.js +11 -24
  75. package/dist-cjs/ui/comments-list.js.map +2 -2
  76. package/dist-cjs/ui/format-time.js +12 -0
  77. package/dist-cjs/ui/format-time.js.map +2 -2
  78. package/dist-cjs/ui/icons.js +115 -0
  79. package/dist-cjs/ui/icons.js.map +7 -0
  80. package/dist-cjs/ui/reaction-picker.js +11 -13
  81. package/dist-cjs/ui/reaction-picker.js.map +2 -2
  82. package/dist-cjs/ui/reaction.js +3 -4
  83. package/dist-cjs/ui/reaction.js.map +2 -2
  84. package/dist-cjs/ui/send-button.js +2 -9
  85. package/dist-cjs/ui/send-button.js.map +2 -2
  86. package/dist-cjs/ui/visual-viewport.js +32 -0
  87. package/dist-cjs/ui/visual-viewport.js.map +7 -0
  88. package/dist-esm/canvas/anchor-lifecycle.mjs +3 -3
  89. package/dist-esm/canvas/anchor-lifecycle.mjs.map +2 -2
  90. package/dist-esm/canvas/canvas-events.mjs +15 -0
  91. package/dist-esm/canvas/canvas-events.mjs.map +7 -0
  92. package/dist-esm/canvas/cluster-badge.mjs +106 -0
  93. package/dist-esm/canvas/cluster-badge.mjs.map +7 -0
  94. package/dist-esm/canvas/cluster-fade.mjs +65 -0
  95. package/dist-esm/canvas/cluster-fade.mjs.map +7 -0
  96. package/dist-esm/canvas/cluster-input.mjs +52 -2
  97. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  98. package/dist-esm/canvas/cluster-model.mjs +236 -0
  99. package/dist-esm/canvas/cluster-model.mjs.map +7 -0
  100. package/dist-esm/canvas/comment-mutations.mjs +57 -28
  101. package/dist-esm/canvas/comment-mutations.mjs.map +2 -2
  102. package/dist-esm/canvas/comment-reactions.mjs +5 -12
  103. package/dist-esm/canvas/comment-reactions.mjs.map +2 -2
  104. package/dist-esm/canvas/comment-render.mjs +5 -4
  105. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  106. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  107. package/dist-esm/canvas/comment-tool.mjs +0 -1
  108. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  109. package/dist-esm/canvas/comments-filter-menu.mjs +12 -13
  110. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  111. package/dist-esm/canvas/comments-overflow-menu.mjs +11 -2
  112. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  113. package/dist-esm/canvas/comments-overlay.mjs +54 -878
  114. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  115. package/dist-esm/canvas/comments-sidebar.mjs +18 -14
  116. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  117. package/dist-esm/canvas/comments-visibility-toggle.mjs +5 -25
  118. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +2 -2
  119. package/dist-esm/canvas/hooks.mjs +9 -2
  120. package/dist-esm/canvas/hooks.mjs.map +2 -2
  121. package/dist-esm/canvas/license.mjs.map +1 -1
  122. package/dist-esm/canvas/mobile-placement.mjs +71 -0
  123. package/dist-esm/canvas/mobile-placement.mjs.map +7 -0
  124. package/dist-esm/canvas/options.mjs +39 -1
  125. package/dist-esm/canvas/options.mjs.map +2 -2
  126. package/dist-esm/canvas/pending-composer.mjs +126 -0
  127. package/dist-esm/canvas/pending-composer.mjs.map +7 -0
  128. package/dist-esm/canvas/pin-stacking.mjs +26 -1
  129. package/dist-esm/canvas/pin-stacking.mjs.map +2 -2
  130. package/dist-esm/canvas/region-box.mjs +93 -0
  131. package/dist-esm/canvas/region-box.mjs.map +7 -0
  132. package/dist-esm/canvas/state.mjs.map +2 -2
  133. package/dist-esm/canvas/thread-pin.mjs +310 -0
  134. package/dist-esm/canvas/thread-pin.mjs.map +7 -0
  135. package/dist-esm/canvas/thread-preview.mjs +42 -49
  136. package/dist-esm/canvas/thread-preview.mjs.map +2 -2
  137. package/dist-esm/canvas/thread-stack.mjs +8 -9
  138. package/dist-esm/canvas/thread-stack.mjs.map +2 -2
  139. package/dist-esm/canvas/thread-state.mjs +15 -0
  140. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  141. package/dist-esm/canvas/thread-view.mjs +131 -72
  142. package/dist-esm/canvas/thread-view.mjs.map +3 -3
  143. package/dist-esm/clustering/computeClusterTable.mjs +2 -2
  144. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  145. package/dist-esm/clustering/replay.mjs +89 -22
  146. package/dist-esm/clustering/replay.mjs.map +3 -3
  147. package/dist-esm/clustering/runtime.mjs +19 -12
  148. package/dist-esm/clustering/runtime.mjs.map +2 -2
  149. package/dist-esm/index.d.mts +323 -202
  150. package/dist-esm/index.mjs +16 -3
  151. package/dist-esm/index.mjs.map +2 -2
  152. package/dist-esm/ui/byline.mjs +6 -4
  153. package/dist-esm/ui/byline.mjs.map +2 -2
  154. package/dist-esm/ui/comment-composer.mjs +2 -18
  155. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  156. package/dist-esm/ui/comment-pin.mjs +2 -15
  157. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  158. package/dist-esm/ui/comments-list.mjs +10 -23
  159. package/dist-esm/ui/comments-list.mjs.map +2 -2
  160. package/dist-esm/ui/format-time.mjs +12 -0
  161. package/dist-esm/ui/format-time.mjs.map +2 -2
  162. package/dist-esm/ui/icons.mjs +95 -0
  163. package/dist-esm/ui/icons.mjs.map +7 -0
  164. package/dist-esm/ui/reaction-picker.mjs +12 -13
  165. package/dist-esm/ui/reaction-picker.mjs.map +2 -2
  166. package/dist-esm/ui/reaction.mjs +3 -4
  167. package/dist-esm/ui/reaction.mjs.map +2 -2
  168. package/dist-esm/ui/send-button.mjs +2 -9
  169. package/dist-esm/ui/send-button.mjs.map +2 -2
  170. package/dist-esm/ui/visual-viewport.mjs +12 -0
  171. package/dist-esm/ui/visual-viewport.mjs.map +7 -0
  172. package/package.json +5 -5
  173. package/src/canvas/anchor-lifecycle.test.ts +2 -2
  174. package/src/canvas/anchor-lifecycle.ts +25 -39
  175. package/src/canvas/canvas-events.ts +18 -0
  176. package/src/canvas/canvas.css +5 -11
  177. package/src/canvas/cluster-badge.tsx +133 -0
  178. package/src/canvas/cluster-fade.ts +102 -0
  179. package/src/canvas/cluster-input.test.ts +148 -14
  180. package/src/canvas/cluster-input.ts +92 -5
  181. package/src/canvas/cluster-model.ts +378 -0
  182. package/src/canvas/comment-mutations.test.ts +46 -4
  183. package/src/canvas/comment-mutations.ts +115 -104
  184. package/src/canvas/comment-reactions.test.ts +22 -0
  185. package/src/canvas/comment-reactions.tsx +33 -32
  186. package/src/canvas/comment-render.ts +8 -8
  187. package/src/canvas/comment-store.ts +12 -17
  188. package/src/canvas/comment-tool.tsx +8 -12
  189. package/src/canvas/comments-filter-menu.tsx +9 -16
  190. package/src/canvas/comments-overflow-menu.tsx +8 -3
  191. package/src/canvas/comments-overlay.tsx +154 -1344
  192. package/src/canvas/comments-sidebar.tsx +40 -19
  193. package/src/canvas/comments-visibility-toggle.tsx +6 -30
  194. package/src/canvas/context.ts +4 -3
  195. package/src/canvas/hooks.test.ts +94 -0
  196. package/src/canvas/hooks.ts +17 -3
  197. package/src/canvas/license.ts +1 -1
  198. package/src/canvas/mobile-placement.ts +115 -0
  199. package/src/canvas/options.test.ts +124 -2
  200. package/src/canvas/options.ts +192 -58
  201. package/src/canvas/pending-composer.tsx +157 -0
  202. package/src/canvas/pin-stacking.test.ts +111 -1
  203. package/src/canvas/pin-stacking.ts +46 -0
  204. package/src/canvas/region-box.tsx +124 -0
  205. package/src/canvas/state.ts +20 -28
  206. package/src/canvas/thread-pin.tsx +418 -0
  207. package/src/canvas/thread-preview.tsx +77 -87
  208. package/src/canvas/thread-stack.tsx +19 -29
  209. package/src/canvas/thread-state.test.ts +51 -0
  210. package/src/canvas/thread-state.ts +56 -23
  211. package/src/canvas/thread-view.test.ts +72 -0
  212. package/src/canvas/thread-view.tsx +231 -133
  213. package/src/clustering/computeClusterTable.ts +12 -3
  214. package/src/clustering/replay.test.ts +0 -7
  215. package/src/clustering/replay.ts +131 -32
  216. package/src/clustering/runtime.test.ts +50 -6
  217. package/src/clustering/runtime.ts +42 -39
  218. package/src/clustering/schedule.test.ts +0 -6
  219. package/src/clustering/screen-offsets.test.ts +171 -0
  220. package/src/clustering/types.ts +10 -0
  221. package/src/index.ts +15 -2
  222. package/src/ui/byline.tsx +16 -6
  223. package/src/ui/comment-composer.tsx +25 -69
  224. package/src/ui/comment-pin.tsx +3 -16
  225. package/src/ui/comments-list.tsx +40 -29
  226. package/src/ui/comments.css +58 -60
  227. package/src/ui/format-time.test.ts +69 -0
  228. package/src/ui/format-time.ts +20 -0
  229. package/src/ui/icons.tsx +116 -0
  230. package/src/ui/reaction-picker.tsx +9 -16
  231. package/src/ui/reaction.tsx +10 -6
  232. package/src/ui/send-button.tsx +3 -8
  233. package/src/ui/visual-viewport.test.ts +36 -0
  234. package/src/ui/visual-viewport.ts +27 -0
  235. package/dist-cjs/ui/tooltip-button.js.map +0 -7
  236. package/dist-esm/ui/tooltip-button.mjs +0 -12
  237. package/dist-esm/ui/tooltip-button.mjs.map +0 -7
  238. package/src/ui/tooltip-button.tsx +0 -20
@@ -18,6 +18,8 @@ var __copyProps = (to, from, except, desc) => {
18
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
19
  var cluster_input_exports = {};
20
20
  __export(cluster_input_exports, {
21
+ clusterInputEqual: () => clusterInputEqual,
22
+ clusterInputIdsEqual: () => clusterInputIdsEqual,
21
23
  collectClusterLeaves: () => collectClusterLeaves
22
24
  });
23
25
  module.exports = __toCommonJS(cluster_input_exports);
@@ -25,6 +27,7 @@ var import_thread_state = require("./thread-state");
25
27
  function collectClusterLeaves(editor, threads, openThreadId) {
26
28
  const pageId = editor.getCurrentPageId();
27
29
  const leaves = [];
30
+ let screenOffsets;
28
31
  for (const thread of threads) {
29
32
  if (thread.id === openThreadId) continue;
30
33
  if (thread.pageId !== pageId) continue;
@@ -34,7 +37,54 @@ function collectClusterLeaves(editor, threads, openThreadId) {
34
37
  id: thread.id,
35
38
  point: { x: point.x, y: point.y }
36
39
  });
40
+ const inset = (0, import_thread_state.impreciseShapePinInset)(editor, thread.anchor);
41
+ if (inset) (screenOffsets ??= /* @__PURE__ */ new Map()).set(thread.id, inset);
37
42
  }
38
- return leaves;
43
+ return { leaves, screenOffsets };
44
+ }
45
+ function clusterInputEqual(a, b) {
46
+ if (a === b) return true;
47
+ return clusterLeavesEqual(a.leaves, b.leaves) && screenOffsetsEqual(a.screenOffsets, b.screenOffsets);
48
+ }
49
+ function clusterInputIdsEqual(a, b) {
50
+ if (a === b) return true;
51
+ return clusterLeafIdsEqual(a.leaves, b.leaves) && screenOffsetIdsEqual(a.screenOffsets, b.screenOffsets);
52
+ }
53
+ function screenOffsetsEqual(a, b) {
54
+ if (a === b) return true;
55
+ if (!a || !b) return false;
56
+ if (a.size !== b.size) return false;
57
+ for (const [id, offset] of b) {
58
+ const prev = a.get(id);
59
+ if (!prev || prev.x !== offset.x || prev.y !== offset.y) return false;
60
+ }
61
+ return true;
62
+ }
63
+ function screenOffsetIdsEqual(a, b) {
64
+ if (a === b) return true;
65
+ if (!a || !b) return false;
66
+ if (a.size !== b.size) return false;
67
+ for (const id of b.keys()) {
68
+ if (!a.has(id)) return false;
69
+ }
70
+ return true;
71
+ }
72
+ function clusterLeavesEqual(a, b) {
73
+ if (a === b) return true;
74
+ if (a.length !== b.length) return false;
75
+ for (let i = 0; i < a.length; i++) {
76
+ const la = a[i];
77
+ const lb = b[i];
78
+ if (la.id !== lb.id || la.point.x !== lb.point.x || la.point.y !== lb.point.y) return false;
79
+ }
80
+ return true;
81
+ }
82
+ function clusterLeafIdsEqual(a, b) {
83
+ if (a === b) return true;
84
+ if (a.length !== b.length) return false;
85
+ for (let i = 0; i < a.length; i++) {
86
+ if (a[i].id !== b[i].id) return false;
87
+ }
88
+ return true;
39
89
  }
40
90
  //# sourceMappingURL=cluster-input.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/canvas/cluster-input.ts"],
4
- "sourcesContent": ["import type { Editor, TLCommentThread } from 'tldraw'\nimport type { LeafInput } from '../clustering/types'\nimport { anchorPagePoint } from './thread-state'\n\n/** The cluster leaves for the threads pinned on the current page. @internal */\nexport function collectClusterLeaves(\n\teditor: Editor,\n\tthreads: readonly TLCommentThread[],\n\topenThreadId: string | null\n): LeafInput[] {\n\tconst pageId = editor.getCurrentPageId()\n\tconst leaves: LeafInput[] = []\n\n\tfor (const thread of threads) {\n\t\tif (thread.id === openThreadId) continue\n\t\tif (thread.pageId !== pageId) continue\n\n\t\tconst point = anchorPagePoint(editor, thread.anchor)\n\t\tif (!point) continue\n\n\t\tleaves.push({\n\t\t\tid: thread.id,\n\t\t\tpoint: { x: point.x, y: point.y },\n\t\t})\n\t}\n\n\treturn leaves\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAEA,0BAAgC;AAGzB,SAAS,qBACf,QACA,SACA,cACc;AACd,QAAM,SAAS,OAAO,iBAAiB;AACvC,QAAM,SAAsB,CAAC;AAE7B,aAAW,UAAU,SAAS;AAC7B,QAAI,OAAO,OAAO,aAAc;AAChC,QAAI,OAAO,WAAW,OAAQ;AAE9B,UAAM,YAAQ,qCAAgB,QAAQ,OAAO,MAAM;AACnD,QAAI,CAAC,MAAO;AAEZ,WAAO,KAAK;AAAA,MACX,IAAI,OAAO;AAAA,MACX,OAAO,EAAE,GAAG,MAAM,GAAG,GAAG,MAAM,EAAE;AAAA,IACjC,CAAC;AAAA,EACF;AAEA,SAAO;AACR;",
4
+ "sourcesContent": ["import type { Editor, TLCommentThread, VecLike } from 'tldraw'\nimport type { LeafInput, LeafScreenOffsets } from '../clustering/types'\nimport { anchorPagePoint, impreciseShapePinInset } from './thread-state'\n\n/** The clustering input for the current page's pinned threads. @internal */\nexport interface ClusterInput {\n\t/** One leaf per pinned thread, at its anchor page point. */\n\tleaves: LeafInput[]\n\t/**\n\t * The render offsets of the imprecise pins among the leaves \u2014 those draw tucked into their\n\t * shape by a constant screen-px inset rather than on their anchor, and the clustering prices\n\t * their merges at the visual positions. Undefined when every pin renders on its anchor,\n\t * which guarantees a byte-identical offset-unaware clustering run.\n\t */\n\tscreenOffsets: LeafScreenOffsets | undefined\n}\n\n/** The cluster leaves for the threads pinned on the current page. @internal */\nexport function collectClusterLeaves(\n\teditor: Editor,\n\tthreads: readonly TLCommentThread[],\n\topenThreadId: string | null\n): ClusterInput {\n\tconst pageId = editor.getCurrentPageId()\n\tconst leaves: LeafInput[] = []\n\tlet screenOffsets: Map<string, VecLike> | undefined\n\n\tfor (const thread of threads) {\n\t\tif (thread.id === openThreadId) continue\n\t\tif (thread.pageId !== pageId) continue\n\n\t\tconst point = anchorPagePoint(editor, thread.anchor)\n\t\tif (!point) continue\n\n\t\tleaves.push({\n\t\t\tid: thread.id,\n\t\t\tpoint: { x: point.x, y: point.y },\n\t\t})\n\n\t\tconst inset = impreciseShapePinInset(editor, thread.anchor)\n\t\tif (inset) (screenOffsets ??= new Map()).set(thread.id, inset)\n\t}\n\n\treturn { leaves, screenOffsets }\n}\n\n/**\n * Value equality over everything `computeClusterTable` reads. Keeps the input's identity stable\n * across recomputes that changed nothing, so the O(N\u00B2) rebuild only runs when it has to.\n * @internal\n */\nexport function clusterInputEqual(a: ClusterInput, b: ClusterInput): boolean {\n\tif (a === b) return true\n\treturn (\n\t\tclusterLeavesEqual(a.leaves, b.leaves) && screenOffsetsEqual(a.screenOffsets, b.screenOffsets)\n\t)\n}\n\n/**\n * The same, ignoring everything positional \u2014 anchor points and offset vectors alike. The mid-drag\n * gate: a move defers, but a thread added, removed, or changed precision still rebuilds.\n * @internal\n */\nexport function clusterInputIdsEqual(a: ClusterInput, b: ClusterInput): boolean {\n\tif (a === b) return true\n\treturn (\n\t\tclusterLeafIdsEqual(a.leaves, b.leaves) &&\n\t\tscreenOffsetIdsEqual(a.screenOffsets, b.screenOffsets)\n\t)\n}\n\nfunction screenOffsetsEqual(a: LeafScreenOffsets | undefined, b: LeafScreenOffsets | undefined) {\n\tif (a === b) return true\n\tif (!a || !b) return false\n\tif (a.size !== b.size) return false\n\tfor (const [id, offset] of b) {\n\t\tconst prev = a.get(id)\n\t\tif (!prev || prev.x !== offset.x || prev.y !== offset.y) return false\n\t}\n\treturn true\n}\n\n/** Which leaves carry an offset, ignoring the vectors: a leaf gains or loses one by changing\n * precision, which is a record change rather than a gesture. */\nfunction screenOffsetIdsEqual(a: LeafScreenOffsets | undefined, b: LeafScreenOffsets | undefined) {\n\tif (a === b) return true\n\tif (!a || !b) return false\n\tif (a.size !== b.size) return false\n\tfor (const id of b.keys()) {\n\t\tif (!a.has(id)) return false\n\t}\n\treturn true\n}\n\n/** Whether two leaf lists have the same threads at the same positions, in the same order. */\nfunction clusterLeavesEqual(a: readonly LeafInput[], b: readonly LeafInput[]): boolean {\n\tif (a === b) return true\n\tif (a.length !== b.length) return false\n\tfor (let i = 0; i < a.length; i++) {\n\t\tconst la = a[i]\n\t\tconst lb = b[i]\n\t\tif (la.id !== lb.id || la.point.x !== lb.point.x || la.point.y !== lb.point.y) return false\n\t}\n\treturn true\n}\n\n/** Whether two leaf lists contain the same thread ids in the same order, ignoring positions. */\nfunction clusterLeafIdsEqual(a: readonly LeafInput[], b: readonly LeafInput[]): boolean {\n\tif (a === b) return true\n\tif (a.length !== b.length) return false\n\tfor (let i = 0; i < a.length; i++) {\n\t\tif (a[i].id !== b[i].id) return false\n\t}\n\treturn true\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAEA,0BAAwD;AAgBjD,SAAS,qBACf,QACA,SACA,cACe;AACf,QAAM,SAAS,OAAO,iBAAiB;AACvC,QAAM,SAAsB,CAAC;AAC7B,MAAI;AAEJ,aAAW,UAAU,SAAS;AAC7B,QAAI,OAAO,OAAO,aAAc;AAChC,QAAI,OAAO,WAAW,OAAQ;AAE9B,UAAM,YAAQ,qCAAgB,QAAQ,OAAO,MAAM;AACnD,QAAI,CAAC,MAAO;AAEZ,WAAO,KAAK;AAAA,MACX,IAAI,OAAO;AAAA,MACX,OAAO,EAAE,GAAG,MAAM,GAAG,GAAG,MAAM,EAAE;AAAA,IACjC,CAAC;AAED,UAAM,YAAQ,4CAAuB,QAAQ,OAAO,MAAM;AAC1D,QAAI,MAAO,EAAC,kBAAkB,oBAAI,IAAI,GAAG,IAAI,OAAO,IAAI,KAAK;AAAA,EAC9D;AAEA,SAAO,EAAE,QAAQ,cAAc;AAChC;AAOO,SAAS,kBAAkB,GAAiB,GAA0B;AAC5E,MAAI,MAAM,EAAG,QAAO;AACpB,SACC,mBAAmB,EAAE,QAAQ,EAAE,MAAM,KAAK,mBAAmB,EAAE,eAAe,EAAE,aAAa;AAE/F;AAOO,SAAS,qBAAqB,GAAiB,GAA0B;AAC/E,MAAI,MAAM,EAAG,QAAO;AACpB,SACC,oBAAoB,EAAE,QAAQ,EAAE,MAAM,KACtC,qBAAqB,EAAE,eAAe,EAAE,aAAa;AAEvD;AAEA,SAAS,mBAAmB,GAAkC,GAAkC;AAC/F,MAAI,MAAM,EAAG,QAAO;AACpB,MAAI,CAAC,KAAK,CAAC,EAAG,QAAO;AACrB,MAAI,EAAE,SAAS,EAAE,KAAM,QAAO;AAC9B,aAAW,CAAC,IAAI,MAAM,KAAK,GAAG;AAC7B,UAAM,OAAO,EAAE,IAAI,EAAE;AACrB,QAAI,CAAC,QAAQ,KAAK,MAAM,OAAO,KAAK,KAAK,MAAM,OAAO,EAAG,QAAO;AAAA,EACjE;AACA,SAAO;AACR;AAIA,SAAS,qBAAqB,GAAkC,GAAkC;AACjG,MAAI,MAAM,EAAG,QAAO;AACpB,MAAI,CAAC,KAAK,CAAC,EAAG,QAAO;AACrB,MAAI,EAAE,SAAS,EAAE,KAAM,QAAO;AAC9B,aAAW,MAAM,EAAE,KAAK,GAAG;AAC1B,QAAI,CAAC,EAAE,IAAI,EAAE,EAAG,QAAO;AAAA,EACxB;AACA,SAAO;AACR;AAGA,SAAS,mBAAmB,GAAyB,GAAkC;AACtF,MAAI,MAAM,EAAG,QAAO;AACpB,MAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;AAClC,WAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK;AAClC,UAAM,KAAK,EAAE,CAAC;AACd,UAAM,KAAK,EAAE,CAAC;AACd,QAAI,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,MAAM,GAAG,MAAM,KAAK,GAAG,MAAM,MAAM,GAAG,MAAM,EAAG,QAAO;AAAA,EACvF;AACA,SAAO;AACR;AAGA,SAAS,oBAAoB,GAAyB,GAAkC;AACvF,MAAI,MAAM,EAAG,QAAO;AACpB,MAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;AAClC,WAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK;AAClC,QAAI,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,GAAI,QAAO;AAAA,EACjC;AACA,SAAO;AACR;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,252 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var cluster_model_exports = {};
20
+ __export(cluster_model_exports, {
21
+ CLUSTER_EXPAND_ZOOM_MS: () => CLUSTER_EXPAND_ZOOM_MS,
22
+ revealThreadPin: () => revealThreadPin,
23
+ useClusterModel: () => useClusterModel,
24
+ zoomToClusterSplit: () => zoomToClusterSplit
25
+ });
26
+ module.exports = __toCommonJS(cluster_model_exports);
27
+ var import_react = require("react");
28
+ var import_tldraw = require("tldraw");
29
+ var import_computeClusterTable = require("../clustering/computeClusterTable");
30
+ var import_runtime = require("../clustering/runtime");
31
+ var import_cluster_fade = require("./cluster-fade");
32
+ var import_cluster_input = require("./cluster-input");
33
+ var import_state = require("./state");
34
+ var import_thread_state = require("./thread-state");
35
+ const CLUSTER_EXPAND_ZOOM_MS = 450;
36
+ const CLUSTER_SPLIT_ZOOM_FACTOR = 1.05;
37
+ const EMPTY_SET = /* @__PURE__ */ new Set();
38
+ const MOVED_LEAF_EPSILON = 1e-6;
39
+ const SHAPE_DRAG_STATE_PATHS = [
40
+ "select.translating",
41
+ "select.resizing",
42
+ "select.rotating",
43
+ "select.dragging_handle",
44
+ "select.crop.cropping"
45
+ ];
46
+ function isShapeDragInProgress(editor) {
47
+ return editor.isInAny(...SHAPE_DRAG_STATE_PATHS);
48
+ }
49
+ function useClusterModel(editor, threads, openId) {
50
+ const [heldThreadIds, setHeldThreadIds] = (0, import_react.useState)(EMPTY_SET);
51
+ const adoptOnRebuild = (0, import_react.useRef)(false);
52
+ const clusterInputRef = (0, import_react.useRef)({ leaves: [], screenOffsets: void 0 });
53
+ const clusterInput = (0, import_tldraw.useValue)(
54
+ "comment cluster leaves",
55
+ () => {
56
+ const next = (0, import_cluster_input.collectClusterLeaves)(
57
+ editor,
58
+ threads.filter((thread) => !heldThreadIds.has(thread.id)),
59
+ import_state.openThreadId.get(editor)
60
+ );
61
+ const prev = clusterInputRef.current;
62
+ if (isShapeDragInProgress(editor) && (0, import_cluster_input.clusterInputIdsEqual)(prev, next)) return prev;
63
+ if ((0, import_cluster_input.clusterInputEqual)(prev, next)) return prev;
64
+ clusterInputRef.current = next;
65
+ return next;
66
+ },
67
+ [editor, threads, heldThreadIds]
68
+ );
69
+ const clusterZoomBounds = (0, import_tldraw.useValue)(
70
+ "comment cluster zoom bounds",
71
+ () => getClusterZoomBounds(editor),
72
+ [editor]
73
+ );
74
+ const latestModel = (0, import_react.useMemo)(() => {
75
+ const table = (0, import_computeClusterTable.computeClusterTable)(
76
+ clusterInput.leaves,
77
+ clusterZoomBounds,
78
+ clusterInput.screenOffsets
79
+ );
80
+ const runtime = (0, import_runtime.createClusterRuntime)(table);
81
+ runtime.seed(editor.getZoomLevel());
82
+ return { runtime, table };
83
+ }, [clusterInput, clusterZoomBounds, editor]);
84
+ const [renderedModel, setRenderedModel] = (0, import_react.useState)(latestModel);
85
+ let clusterModel = renderedModel;
86
+ const pageId = (0, import_tldraw.useValue)("comment cluster page", () => editor.getCurrentPageId(), [editor]);
87
+ const pageRef = (0, import_react.useRef)(pageId);
88
+ if (pageRef.current !== pageId) {
89
+ pageRef.current = pageId;
90
+ adoptOnRebuild.current = false;
91
+ latestModel.runtime.seed(editor.getZoomLevel());
92
+ if (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET);
93
+ setRenderedModel(latestModel);
94
+ clusterModel = latestModel;
95
+ }
96
+ const rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current;
97
+ if (renderedModel !== latestModel && rejoinPending) {
98
+ adoptOnRebuild.current = false;
99
+ latestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible());
100
+ setRenderedModel(latestModel);
101
+ clusterModel = latestModel;
102
+ } else if (heldThreadIds.size === 0 && renderedModel === latestModel) {
103
+ adoptOnRebuild.current = false;
104
+ }
105
+ const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel);
106
+ if (newlyMovedIds.length > 0) {
107
+ const next = new Set(heldThreadIds);
108
+ for (const id of newlyMovedIds) next.add(id);
109
+ setHeldThreadIds(next);
110
+ }
111
+ if (clusterModel !== latestModel) {
112
+ const latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id));
113
+ const removedLeafIds = [];
114
+ for (const leaf of clusterModel.table.leaves) {
115
+ if (!latestLeafIds.has(leaf.id)) {
116
+ removedLeafIds.push(leaf.id);
117
+ }
118
+ }
119
+ if (removedLeafIds.length > 0) clusterModel.runtime.detachLeaves(removedLeafIds);
120
+ }
121
+ (0, import_react.useEffect)(() => {
122
+ if (heldThreadIds.size === 0) return;
123
+ let lastZoom = editor.getZoomLevel();
124
+ return (0, import_tldraw.react)("rejoin moved comment pins on zoom out", () => {
125
+ const zoom = editor.getZoomLevel();
126
+ const prevZoom = lastZoom;
127
+ lastZoom = zoom;
128
+ if (zoom >= prevZoom) return;
129
+ adoptOnRebuild.current = true;
130
+ setHeldThreadIds(EMPTY_SET);
131
+ });
132
+ }, [heldThreadIds, editor]);
133
+ (0, import_react.useEffect)(() => {
134
+ if (clusterModel === latestModel) return;
135
+ let lastZoom = editor.getZoomLevel();
136
+ return (0, import_tldraw.react)("adopt pending cluster model on zoom out", () => {
137
+ const zoom = editor.getZoomLevel();
138
+ const prevZoom = lastZoom;
139
+ lastZoom = zoom;
140
+ if (zoom >= prevZoom) return;
141
+ latestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible());
142
+ setRenderedModel(latestModel);
143
+ });
144
+ }, [clusterModel, latestModel, editor]);
145
+ const partitionVersion = clusterModel.runtime.version;
146
+ const orphanThreads = (0, import_react.useMemo)(() => {
147
+ if (clusterModel === latestModel) return [];
148
+ const displayed = /* @__PURE__ */ new Set();
149
+ for (const node of clusterModel.runtime.getVisible().values()) {
150
+ for (const member of node.members) displayed.add(member);
151
+ }
152
+ const latestIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id));
153
+ return threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id));
154
+ }, [clusterModel, latestModel, threads, partitionVersion]);
155
+ const heldThreads = (0, import_react.useMemo)(
156
+ () => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),
157
+ [threads, heldThreadIds, openId]
158
+ );
159
+ (0, import_tldraw.useValue)(
160
+ "comment cluster version",
161
+ () => {
162
+ clusterModel.runtime.onCamera(editor.getZoomLevel());
163
+ return clusterModel.runtime.version;
164
+ },
165
+ [clusterModel, editor]
166
+ );
167
+ const visibleNodes = (0, import_react.useMemo)(() => {
168
+ return Array.from(clusterModel.runtime.getVisible().values());
169
+ }, [clusterModel, partitionVersion]);
170
+ const fadeNodes = (0, import_cluster_fade.useFadeVisibleNodes)(visibleNodes, clusterModel);
171
+ return {
172
+ model: clusterModel,
173
+ zoomBounds: clusterZoomBounds,
174
+ fadeNodes,
175
+ orphanThreads,
176
+ heldThreads
177
+ };
178
+ }
179
+ function findMovedClusteredLeafIds(rendered, latest) {
180
+ if (rendered.table === latest.table) return [];
181
+ const visible = rendered.runtime.getVisible();
182
+ const latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]));
183
+ const moved = [];
184
+ for (const leaf of rendered.table.leaves) {
185
+ if (visible.has(leaf.id)) continue;
186
+ const current = latestById.get(leaf.id);
187
+ if (!current) continue;
188
+ if (Math.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON || Math.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON) {
189
+ moved.push(leaf.id);
190
+ }
191
+ }
192
+ return moved;
193
+ }
194
+ function getClusterZoomBounds(editor) {
195
+ const cameraOptions = editor.getCameraOptions();
196
+ const baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1;
197
+ const zoomSteps = cameraOptions.zoomSteps;
198
+ return {
199
+ minZoom: zoomSteps[0] * baseZoom,
200
+ maxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom
201
+ };
202
+ }
203
+ function revealThreadPin(editor, thread, table, zoomBounds, options, duration = 200) {
204
+ if (thread.pageId !== editor.getCurrentPageId()) {
205
+ editor.setCurrentPage(thread.pageId);
206
+ }
207
+ const point = (0, import_thread_state.anchorPagePoint)(editor, thread.anchor);
208
+ if (!point) return;
209
+ if (options.enableClustering) {
210
+ const parentEvent = findDirectParentEvent(table, thread.id);
211
+ if (parentEvent && Number.isFinite(parentEvent.zSplit) && parentEvent.zSplit <= zoomBounds.maxZoom) {
212
+ const zoom = clamp(
213
+ parentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
214
+ zoomBounds.minZoom,
215
+ zoomBounds.maxZoom
216
+ );
217
+ centerOnPointAtZoom(editor, point, zoom, duration);
218
+ return;
219
+ }
220
+ }
221
+ const offset = (0, import_thread_state.commentCenterScreenOffset)(editor) / editor.getZoomLevel();
222
+ editor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } });
223
+ }
224
+ function zoomToClusterSplit(editor, table, zoomBounds, node) {
225
+ const event = table.events.find((e) => e.result.id === node.id);
226
+ if (!event || !Number.isFinite(event.zSplit)) return;
227
+ const zoom = clamp(
228
+ event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
229
+ zoomBounds.minZoom,
230
+ zoomBounds.maxZoom
231
+ );
232
+ centerOnPointAtZoom(editor, node.centroid, zoom, CLUSTER_EXPAND_ZOOM_MS);
233
+ }
234
+ function findDirectParentEvent(table, threadId) {
235
+ return table.events.find((event) => event.children.some((child) => child.id === threadId));
236
+ }
237
+ function centerOnPointAtZoom(editor, point, zoom, duration = 200) {
238
+ const viewport = editor.getViewportScreenBounds();
239
+ const offset = (0, import_thread_state.commentCenterScreenOffset)(editor);
240
+ editor.setCamera(
241
+ {
242
+ x: (viewport.w / 2 - offset) / zoom - point.x,
243
+ y: viewport.h / (2 * zoom) - point.y,
244
+ z: zoom
245
+ },
246
+ { animation: { duration } }
247
+ );
248
+ }
249
+ function clamp(value, min, max) {
250
+ return Math.max(min, Math.min(max, value));
251
+ }
252
+ //# sourceMappingURL=cluster-model.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 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 widens to ignore positions too, and that costs something real: it freezes\n\t// `latestModel`, which is where `findMovedClusteredLeafIds` reads live positions from, so a pin\n\t// folded into a badge stops popping out to ride its anchor and corrects on release instead. The\n\t// per-frame rebuild it buys back was paid by every drag on the board, comment-anchored or not.\n\t// An added or deleted thread changes the id set, so it still rebuilds promptly.\n\tconst clusterInputRef = useRef<ClusterInput>({ leaves: [], screenOffsets: undefined })\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 (isShapeDragInProgress(editor) && clusterInputIdsEqual(prev, next)) return prev\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// 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 * 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.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": "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",
6
+ "names": []
7
+ }
@@ -23,9 +23,7 @@ __export(comment_mutations_exports, {
23
23
  deleteThread: () => deleteThread,
24
24
  editComment: () => editComment,
25
25
  putCommentRecords: () => putCommentRecords,
26
- putRecordsInCommit: () => putRecordsInCommit,
27
26
  removeCommentRecords: () => removeCommentRecords,
28
- removeRecordsInCommit: () => removeRecordsInCommit,
29
27
  reopenThread: () => reopenThread,
30
28
  resolveThread: () => resolveThread
31
29
  });
@@ -33,6 +31,7 @@ module.exports = __toCommonJS(comment_mutations_exports);
33
31
  var import_comment_store = require("./comment-store");
34
32
  var import_options = require("./options");
35
33
  var import_state = require("./state");
34
+ const activeCommentMutations = /* @__PURE__ */ new WeakMap();
36
35
  function historyModeFor(options, kind) {
37
36
  switch (kind) {
38
37
  case "delete":
@@ -45,63 +44,93 @@ function historyModeFor(options, kind) {
45
44
  }
46
45
  function commitCommentMutation(editor, fn, kind = "mutation") {
47
46
  const history = historyModeFor((0, import_options.getCommentingOptions)(editor), kind);
48
- let result;
49
- editor.run(
50
- () => {
51
- result = fn();
52
- },
53
- { history }
54
- );
55
- return result;
56
- }
57
- function putRecordsInCommit(editor, records) {
58
- editor.store.put(records);
59
- }
60
- function removeRecordsInCommit(editor, ids) {
61
- editor.store.remove(ids);
47
+ const enclosing = activeCommentMutations.get(editor);
48
+ if (enclosing && enclosing.history !== history) {
49
+ throw new Error(
50
+ `A comment mutation that records history as '${history}' can't run inside one recording it as '${enclosing.history}': one of the two modes would be silently discarded. Use the provided writer for constituent records, or run this operation after the enclosing one has committed.`
51
+ );
52
+ }
53
+ activeCommentMutations.set(editor, { history });
54
+ try {
55
+ let result;
56
+ editor.run(
57
+ () => {
58
+ let isWriterActive = true;
59
+ const assertWriterActive = () => {
60
+ if (!isWriterActive) {
61
+ throw new Error(
62
+ "A comment mutation writer cannot be used after its commit has finished."
63
+ );
64
+ }
65
+ };
66
+ try {
67
+ result = fn({
68
+ put: (records) => {
69
+ assertWriterActive();
70
+ editor.store.put(records);
71
+ },
72
+ remove: (ids) => {
73
+ assertWriterActive();
74
+ editor.store.remove(ids);
75
+ }
76
+ });
77
+ } finally {
78
+ isWriterActive = false;
79
+ }
80
+ },
81
+ { history }
82
+ );
83
+ return result;
84
+ } finally {
85
+ if (enclosing) {
86
+ activeCommentMutations.set(editor, enclosing);
87
+ } else {
88
+ activeCommentMutations.delete(editor);
89
+ }
90
+ }
62
91
  }
63
92
  function putCommentRecords(editor, records) {
64
- commitCommentMutation(editor, () => putRecordsInCommit(editor, records));
93
+ commitCommentMutation(editor, ({ put }) => put(records));
65
94
  }
66
95
  function removeCommentRecords(editor, ids) {
67
- commitCommentMutation(editor, () => removeRecordsInCommit(editor, ids));
96
+ commitCommentMutation(editor, ({ remove }) => remove(ids));
68
97
  }
69
98
  function readLatest(editor, record) {
70
99
  const current = (0, import_comment_store.getCommentRecord)(editor, record.id);
71
100
  return current?.typeName === record.typeName ? current : void 0;
72
101
  }
73
102
  function editComment(editor, comment, body) {
74
- commitCommentMutation(editor, () => {
103
+ commitCommentMutation(editor, ({ put }) => {
75
104
  const current = readLatest(editor, comment);
76
105
  if (!current) return;
77
- putRecordsInCommit(editor, [{ ...current, body, editedAt: Date.now() }]);
106
+ put([{ ...current, body, editedAt: Date.now() }]);
78
107
  });
79
108
  }
80
109
  function resolveThread(editor, thread, userId) {
81
- commitCommentMutation(editor, () => {
110
+ commitCommentMutation(editor, ({ put }) => {
82
111
  const current = readLatest(editor, thread);
83
112
  if (!current) return;
84
- putRecordsInCommit(editor, [{ ...current, resolved: { at: Date.now(), by: userId } }]);
113
+ put([{ ...current, resolved: { at: Date.now(), by: userId } }]);
85
114
  });
86
115
  }
87
116
  function reopenThread(editor, thread) {
88
- commitCommentMutation(editor, () => {
117
+ commitCommentMutation(editor, ({ put }) => {
89
118
  const current = readLatest(editor, thread);
90
119
  if (!current) return;
91
- putRecordsInCommit(editor, [{ ...current, resolved: null }]);
120
+ put([{ ...current, resolved: null }]);
92
121
  });
93
122
  }
94
123
  function deleteComment(editor, comment) {
95
124
  commitCommentMutation(
96
125
  editor,
97
- () => {
126
+ ({ put }) => {
98
127
  const current = readLatest(editor, comment);
99
128
  if (!current || current.isDeleted) return;
100
129
  const isLastInThread = (0, import_comment_store.getLiveComments)(editor).filter((c) => c.threadId === current.threadId).length <= 1;
101
130
  if (isLastInThread && import_state.openThreadId.get(editor) === current.threadId) {
102
131
  import_state.openThreadId.set(editor, null);
103
132
  }
104
- putRecordsInCommit(editor, [{ ...current, isDeleted: true }]);
133
+ put([{ ...current, isDeleted: true }]);
105
134
  },
106
135
  "delete"
107
136
  );
@@ -109,13 +138,13 @@ function deleteComment(editor, comment) {
109
138
  function deleteThread(editor, thread) {
110
139
  commitCommentMutation(
111
140
  editor,
112
- () => {
141
+ ({ put }) => {
113
142
  const current = readLatest(editor, thread);
114
143
  if (!current) return;
115
144
  if (import_state.openThreadId.get(editor) === current.id) {
116
145
  import_state.openThreadId.set(editor, null);
117
146
  }
118
- putRecordsInCommit(editor, [{ ...current, isDeleted: true }]);
147
+ put([{ ...current, isDeleted: true }]);
119
148
  },
120
149
  "delete"
121
150
  );
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/canvas/comment-mutations.ts"],
4
- "sourcesContent": ["import {\n\tEditor,\n\tTLComment,\n\tTLCommentId,\n\tTLCommentReactionId,\n\tTLCommentThread,\n\tTLCommentThreadId,\n\tTLHistoryBatchOptions,\n\tTLRecord,\n\tTLRichText,\n} from 'tldraw'\nimport { getCommentRecord, getLiveComments, type TLCommentRecord } from './comment-store'\nimport { getCommentingOptions, type CommentingOptions } from './options'\nimport { openThreadId } from './state'\n\n/**\n * Every write to a comment record, and the undo/redo policy governing them.\n *\n * This file layers bottom-up: {@link commitCommentMutation} resolves the history mode,\n * {@link putCommentRecords} and {@link removeCommentRecords} are the typed writes that run under\n * it, and the verbs below are those writes plus the one rule each carries \u2014 a timestamp to stamp, a\n * shape to put the `resolved` field in, or the soft-delete protocol. The built-in thread view calls\n * exactly these verbs, so a UI of your own behaves the same as the one in the box.\n *\n * Every verb takes the record it acts on, but treats it as the identity of what to change rather\n * than as the value to write back \u2014 see {@link readLatest}.\n *\n * Posting carries no such rule, so it isn't a verb here: build the records with\n * `createCommentThread`/`createComment` and write them with {@link putCommentRecords}.\n */\n\n/**\n * Which history policy a comment write follows:\n *\n * - `mutation` \u2014 {@link CommentingOptions.history}: posts, replies, edits, resolves.\n * - `drag` \u2014 {@link CommentingOptions.dragHistory}, falling back to `history`: pin and region\n * re-anchors, which are spatial edits a host may reasonably want undoable alongside a shape move.\n * - `delete` \u2014 always `'ignore'`, whatever the options say. A soft-delete flag is write-once\n * server-side, so an undo clearing it would be vetoed and rebased rather than restore anything.\n *\n * @internal\n */\nexport type CommentMutationKind = 'delete' | 'drag' | 'mutation'\n\n/** The undo/redo mode a write of the given kind runs under. See {@link CommentMutationKind}. */\nfunction historyModeFor(\n\toptions: CommentingOptions,\n\tkind: CommentMutationKind\n): TLHistoryBatchOptions['history'] {\n\tswitch (kind) {\n\t\tcase 'delete':\n\t\t\treturn 'ignore'\n\t\tcase 'drag':\n\t\t\treturn options.dragHistory ?? options.history\n\t\tcase 'mutation':\n\t\t\treturn options.history\n\t}\n}\n\n/**\n * Commit a comment mutation with the configured undo/redo behavior, so the\n * {@link CommentingOptions.history} option governs whether it lands on the undo stack. Defaults to\n * `'ignore'`. See {@link CommentMutationKind} for what each kind resolves to.\n * @internal\n */\nexport function commitCommentMutation<T>(\n\teditor: Editor,\n\tfn: () => T,\n\tkind: CommentMutationKind = 'mutation'\n): T {\n\tconst history = historyModeFor(getCommentingOptions(editor), kind)\n\tlet result: T\n\teditor.run(\n\t\t() => {\n\t\t\tresult = fn()\n\t\t},\n\t\t{ history }\n\t)\n\treturn result!\n}\n\n/**\n * Write records without opening a commit, for call sites that already sit inside one.\n *\n * `editor.run`'s history option isn't additive \u2014 a nested run overwrites the enclosing mode for its\n * own scope \u2014 so a {@link putCommentRecords} call inside a commit would discard the mode its caller\n * chose. A pin drag committed as `drag` under `dragHistory: 'record'` would land back on\n * `history: 'ignore'` and quietly stop being undoable. Inside a commit, write with this.\n *\n * @internal\n */\nexport function putRecordsInCommit(editor: Editor, records: TLCommentRecord[]): void {\n\teditor.store.put(records as unknown as TLRecord[])\n}\n\n/** {@link putRecordsInCommit}'s counterpart for removals. @internal */\nexport function removeRecordsInCommit(\n\teditor: Editor,\n\tids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]\n): void {\n\teditor.store.remove(ids as unknown as TLRecord['id'][])\n}\n\n/**\n * Write comment records to the store, under the configured\n * {@link CommentingOptions.history} behavior \u2014 so a record you write lands on the undo stack (or\n * doesn't) exactly like one the built-in UI writes. Defaults to `'ignore'`.\n *\n * Use it to seed or import threads, and to save an edit. To delete, prefer\n * {@link deleteComment} and {@link deleteThread} over {@link removeCommentRecords}: comments are\n * soft-deleted, and a synced server rejects the hard delete.\n *\n * @public\n */\nexport function putCommentRecords(editor: Editor, records: TLCommentRecord[]): void {\n\tcommitCommentMutation(editor, () => putRecordsInCommit(editor, records))\n}\n\n/**\n * Remove comment records from the store by id, under the configured\n * {@link CommentingOptions.history} behavior.\n *\n * This is a hard delete, which is rarely what you want for a comment or a thread: the built-in UI\n * soft-deletes them ({@link deleteComment}, {@link deleteThread}) so the server can prune the\n * records \u2014 including reactions, which belong to whoever left them rather than to the deleter. A\n * server that enforces per-record permissions will veto a hard delete outright. Reach for this on\n * a local, unsynced comment store, or to drop a reaction (which is a hard delete \u2014 see\n * {@link toggleCommentReaction}).\n *\n * @public\n */\nexport function removeCommentRecords(\n\teditor: Editor,\n\tids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]\n): void {\n\tcommitCommentMutation(editor, () => removeRecordsInCommit(editor, ids))\n}\n\n/**\n * The record as the store currently holds it, or `undefined` if it isn't there any more.\n *\n * A verb is handed a record, but that record is a snapshot of whenever its caller got hold of one,\n * and a comment record moves underneath it. A thread's `anchor` and `pageId` are rewritten without\n * anyone touching the thread: deleting a pinned shape converts the anchor to a point, reparenting\n * one rehomes the thread to another page, a pin drag re-anchors it. Writing the caller's snapshot\n * back would put those fields as they were and sync the revert out to everyone.\n *\n * `put` is also an upsert, so a record a remote delete has already removed would come back \u2014 with\n * `isDeleted: false` on an edit or a resolve. That's the multiplayer surprise the schema warns\n * about (see `TLComment`), reached by a route the history option doesn't cover.\n *\n * So a verb reads what it's changing rather than trusting what it was given, and a record that's\n * gone is a no-op: whatever the change was, there's nothing left for it to apply to.\n */\nfunction readLatest<T extends TLComment | TLCommentThread>(\n\teditor: Editor,\n\trecord: T\n): T | undefined {\n\tconst current = getCommentRecord(editor, record.id)\n\t// Record ids carry their type, so a matching `typeName` means a record of exactly T.\n\treturn current?.typeName === record.typeName ? (current as T) : undefined\n}\n\n/**\n * Replace a comment's body and stamp it as edited, which is what renders the \"(edited)\" marker on\n * its byline.\n *\n * Editing is the author's to do. The built-in UI only offers it on your own comments, and a server\n * that enforces per-record permissions rejects anyone else's edit.\n *\n * The `comment` you pass says which comment to edit; the body lands on the version the store\n * currently holds, so a copy you've held on to can't revert a change made since it, and can't\n * re-create a comment that's already been removed \u2014 editing one of those does nothing.\n *\n * @example\n * ```ts\n * editComment(editor, comment, toRichText('Actually, make it dashed'))\n * ```\n *\n * @public\n */\nexport function editComment(editor: Editor, comment: TLComment, body: TLRichText): void {\n\tcommitCommentMutation(editor, () => {\n\t\tconst current = readLatest(editor, comment)\n\t\tif (!current) return\n\t\tputRecordsInCommit(editor, [{ ...current, body, editedAt: Date.now() }])\n\t})\n}\n\n/**\n * Mark a thread resolved, stamping who resolved it and when. Resolved threads keep their pin (a\n * checked one) and are hidden from the sidebar until its \"show resolved\" filter is on.\n *\n * Only the resolution is written: the rest of the thread is read fresh, so resolving with a stale\n * copy in hand won't drag a pin back to where it used to be. A no-op on a thread that's gone.\n *\n * @public\n */\nexport function resolveThread(editor: Editor, thread: TLCommentThread, userId: string): void {\n\tcommitCommentMutation(editor, () => {\n\t\tconst current = readLatest(editor, thread)\n\t\tif (!current) return\n\t\tputRecordsInCommit(editor, [{ ...current, resolved: { at: Date.now(), by: userId } }])\n\t})\n}\n\n/**\n * Reopen a resolved thread, clearing the resolution. A no-op on a thread that isn't resolved, and\n * on one that's gone. Like {@link resolveThread}, it touches only the resolution.\n *\n * @public\n */\nexport function reopenThread(editor: Editor, thread: TLCommentThread): void {\n\tcommitCommentMutation(editor, () => {\n\t\tconst current = readLatest(editor, thread)\n\t\tif (!current) return\n\t\tputRecordsInCommit(editor, [{ ...current, resolved: null }])\n\t})\n}\n\n/**\n * Delete a comment.\n *\n * This is a soft delete: it sets the record's `isDeleted` flag rather than removing it, and the\n * server prunes the comment and its reactions once the flag is persisted. That way no client ever\n * removes records it doesn't own \u2014 a reaction belongs to whoever left it \u2014 and a server enforcing\n * per-record permissions has a write it can check rather than a deletion it can only refuse.\n * Deleting is the author's to do; the built-in UI only offers it on your own comments.\n *\n * The write is never undoable, whatever {@link CommentingOptions.history} says: the flag is\n * write-once server-side, so an undo clearing it would be vetoed and rebased rather than bring the\n * comment back.\n *\n * Deleting a thread's last comment leaves the thread with no surface \u2014 an empty thread renders\n * nothing \u2014 and closes it if it's open. The thread record is left for the server to prune, since\n * the deleter may not be its creator.\n *\n * A comment that's already deleted, or already pruned, is a no-op.\n *\n * @public\n */\nexport function deleteComment(editor: Editor, comment: TLComment): void {\n\tcommitCommentMutation(\n\t\teditor,\n\t\t() => {\n\t\t\tconst current = readLatest(editor, comment)\n\t\t\t// Deleting twice \u2014 a double activation, a handler firing on a copy taken before the first\n\t\t\t// delete \u2014 is nothing to do rather than something to redo. The check below counts the\n\t\t\t// comment being deleted among the live ones, so it only reads as \"the last one\" while\n\t\t\t// this delete is the one taking it away.\n\t\t\tif (!current || current.isDeleted) return\n\t\t\tconst isLastInThread =\n\t\t\t\tgetLiveComments(editor).filter((c) => c.threadId === current.threadId).length <= 1\n\t\t\tif (isLastInThread && openThreadId.get(editor) === current.threadId) {\n\t\t\t\topenThreadId.set(editor, null)\n\t\t\t}\n\t\t\tputRecordsInCommit(editor, [{ ...current, isDeleted: true }])\n\t\t},\n\t\t'delete'\n\t)\n}\n\n/**\n * Delete a thread and, with it, the whole conversation.\n *\n * A soft delete on the same model as {@link deleteComment}: the flag goes on the thread record and\n * the server prunes the thread, its comments, and their reactions once it's persisted. Deleting a\n * thread is its creator's to do \u2014 a server enforcing per-record permissions vetoes anyone else \u2014\n * and the write is never undoable.\n *\n * Closes the thread if it's the open one. A thread that's already pruned is a no-op.\n *\n * @public\n */\nexport function deleteThread(editor: Editor, thread: TLCommentThread): void {\n\tcommitCommentMutation(\n\t\teditor,\n\t\t() => {\n\t\t\tconst current = readLatest(editor, thread)\n\t\t\tif (!current) return\n\t\t\tif (openThreadId.get(editor) === current.id) {\n\t\t\t\topenThreadId.set(editor, null)\n\t\t\t}\n\t\t\tputRecordsInCommit(editor, [{ ...current, isDeleted: true }])\n\t\t},\n\t\t'delete'\n\t)\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import {\n\tEditor,\n\tTLComment,\n\tTLCommentId,\n\tTLCommentReactionId,\n\tTLCommentThread,\n\tTLCommentThreadId,\n\tTLHistoryBatchOptions,\n\tTLRecord,\n\tTLRichText,\n} from 'tldraw'\nimport { getCommentRecord, getLiveComments, type TLCommentRecord } from './comment-store'\nimport { getCommentingOptions, type CommentingOptions } from './options'\nimport { openThreadId } from './state'\n\n/**\n * Every write to a comment record, and the undo/redo policy governing them.\n *\n * The file layers bottom-up: {@link commitCommentMutation} resolves the history mode and hands its\n * callback a writer, {@link putCommentRecords} and {@link removeCommentRecords} are the typed\n * writes that run under it, and the verbs below are those writes plus the one rule each carries.\n * Every verb takes the record it acts on as the identity of what to change, not the value to write\n * back \u2014 see {@link readLatest}.\n *\n * Posting carries no such rule, so it isn't a verb here: build the records with\n * `createCommentThread`/`createComment` and write them with {@link putCommentRecords}.\n */\n\n/**\n * Which history policy a comment write follows:\n *\n * - `mutation` \u2014 {@link CommentingOptions.history}: posts, replies, edits, resolves.\n * - `drag` \u2014 {@link CommentingOptions.dragHistory}, falling back to `history`: pin and region\n * re-anchors, which are spatial edits a host may reasonably want undoable alongside a shape move.\n * - `delete` \u2014 always `'ignore'`, whatever the options say. A soft-delete flag is write-once\n * server-side, so an undo clearing it would be vetoed and rebased rather than restore anything.\n *\n * @internal\n */\nexport type CommentMutationKind = 'delete' | 'drag' | 'mutation'\n\ninterface CommentMutationWriter {\n\tput(records: TLCommentRecord[]): void\n\tremove(ids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]): void\n}\n\nconst activeCommentMutations = new WeakMap<Editor, { history: TLHistoryBatchOptions['history'] }>()\n\n/** The undo/redo mode a write of the given kind runs under. See {@link CommentMutationKind}. */\nfunction historyModeFor(\n\toptions: CommentingOptions,\n\tkind: CommentMutationKind\n): TLHistoryBatchOptions['history'] {\n\tswitch (kind) {\n\t\tcase 'delete':\n\t\t\treturn 'ignore'\n\t\tcase 'drag':\n\t\t\treturn options.dragHistory ?? options.history\n\t\tcase 'mutation':\n\t\t\treturn options.history\n\t}\n}\n\n/**\n * Commit a comment mutation with the configured undo/redo behavior, so the\n * {@link CommentingOptions.history} option governs whether it lands on the undo stack. Defaults to\n * `'ignore'`. See {@link CommentMutationKind} for what each kind resolves to.\n *\n * `editor.run`'s history option isn't additive \u2014 a nested run overwrites the enclosing mode \u2014 so\n * constituent records go through the callback's writer instead of opening a commit of their own,\n * which would quietly make a `drag` write non-undoable.\n *\n * Commits that nest are only a problem when they resolve to different modes, and then neither is\n * the right one to keep, so it throws. Matching modes have to nest: a store side effect runs inside\n * the write that triggered it, with no \"after the commit\" to defer to. (A `store.listen` handler\n * normally flushes on a later frame, so its writes open a commit of their own, but a synchronous\n * flush \u2014 as under test \u2014 lands it inside too.)\n * @internal\n */\nexport function commitCommentMutation<T>(\n\teditor: Editor,\n\tfn: (writer: CommentMutationWriter) => T,\n\tkind: CommentMutationKind = 'mutation'\n): T {\n\tconst history = historyModeFor(getCommentingOptions(editor), kind)\n\tconst enclosing = activeCommentMutations.get(editor)\n\tif (enclosing && enclosing.history !== history) {\n\t\tthrow new Error(\n\t\t\t`A comment mutation that records history as '${history}' can't run inside one recording it as '${enclosing.history}': one of the two modes would be silently discarded. Use the provided writer for constituent records, or run this operation after the enclosing one has committed.`\n\t\t)\n\t}\n\n\tactiveCommentMutations.set(editor, { history })\n\ttry {\n\t\tlet result: T\n\t\teditor.run(\n\t\t\t() => {\n\t\t\t\tlet isWriterActive = true\n\t\t\t\tconst assertWriterActive = () => {\n\t\t\t\t\tif (!isWriterActive) {\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t'A comment mutation writer cannot be used after its commit has finished.'\n\t\t\t\t\t\t)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\ttry {\n\t\t\t\t\tresult = fn({\n\t\t\t\t\t\tput: (records) => {\n\t\t\t\t\t\t\tassertWriterActive()\n\t\t\t\t\t\t\teditor.store.put(records as unknown as TLRecord[])\n\t\t\t\t\t\t},\n\t\t\t\t\t\tremove: (ids) => {\n\t\t\t\t\t\t\tassertWriterActive()\n\t\t\t\t\t\t\teditor.store.remove(ids as unknown as TLRecord['id'][])\n\t\t\t\t\t\t},\n\t\t\t\t\t})\n\t\t\t\t} finally {\n\t\t\t\t\tisWriterActive = false\n\t\t\t\t}\n\t\t\t},\n\t\t\t{ history }\n\t\t)\n\t\treturn result!\n\t} finally {\n\t\tif (enclosing) {\n\t\t\tactiveCommentMutations.set(editor, enclosing)\n\t\t} else {\n\t\t\tactiveCommentMutations.delete(editor)\n\t\t}\n\t}\n}\n\n/**\n * Write comment records to the store, under the configured {@link CommentingOptions.history}\n * behavior. Defaults to `'ignore'`.\n *\n * Use it to seed or import threads, and to save an edit. To delete, prefer {@link deleteComment}\n * and {@link deleteThread}: comments are soft-deleted, and a synced server rejects the hard delete.\n *\n * @public\n */\nexport function putCommentRecords(editor: Editor, records: TLCommentRecord[]): void {\n\tcommitCommentMutation(editor, ({ put }) => put(records))\n}\n\n/**\n * Remove comment records from the store by id, under the configured\n * {@link CommentingOptions.history} behavior.\n *\n * This is a hard delete, which is rarely what you want: the built-in UI soft-deletes\n * ({@link deleteComment}, {@link deleteThread}) so the server can prune the records, and a server\n * enforcing per-record permissions vetoes a hard delete outright. Reach for this on a local,\n * unsynced store, or to drop a reaction (see {@link toggleCommentReaction}).\n *\n * @public\n */\nexport function removeCommentRecords(\n\teditor: Editor,\n\tids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]\n): void {\n\tcommitCommentMutation(editor, ({ remove }) => remove(ids))\n}\n\n/**\n * The record as the store currently holds it, or `undefined` if it isn't there any more.\n *\n * A verb is handed a record, but that record is a snapshot and comment records move underneath it:\n * deleting a pinned shape converts the anchor to a point, reparenting rehomes the thread, a drag\n * re-anchors it. Writing the caller's snapshot back would revert those fields for everyone. `put`\n * is also an upsert, so a record a remote delete already removed would come back.\n *\n * So a verb reads what it's changing rather than trusting what it was given, and a record that's\n * gone is a no-op.\n */\nfunction readLatest<T extends TLComment | TLCommentThread>(\n\teditor: Editor,\n\trecord: T\n): T | undefined {\n\tconst current = getCommentRecord(editor, record.id)\n\t// Record ids carry their type, so a matching `typeName` means a record of exactly T.\n\treturn current?.typeName === record.typeName ? (current as T) : undefined\n}\n\n/**\n * Replace a comment's body and stamp it as edited, which renders the \"(edited)\" marker on its\n * byline. Editing is the author's to do by default ({@link CommentingOptions.canModifyComment}),\n * and a server enforcing per-record permissions rejects anyone else's. Widening one end without the\n * other leaves an edit that's offered and then rejected, so widen both.\n *\n * The body lands on the version the store currently holds, so a stale copy can't revert a later\n * change or re-create a removed comment \u2014 editing one of those does nothing.\n *\n * @example\n * ```ts\n * editComment(editor, comment, toRichText('Actually, make it dashed'))\n * ```\n *\n * @public\n */\nexport function editComment(editor: Editor, comment: TLComment, body: TLRichText): void {\n\tcommitCommentMutation(editor, ({ put }) => {\n\t\tconst current = readLatest(editor, comment)\n\t\tif (!current) return\n\t\tput([{ ...current, body, editedAt: Date.now() }])\n\t})\n}\n\n/**\n * Mark a thread resolved, stamping who resolved it and when. Resolved threads keep their pin (a\n * checked one) and are hidden from the sidebar until its \"show resolved\" filter is on.\n *\n * Only the resolution is written \u2014 the rest of the thread is read fresh, so a stale copy can't drag\n * a pin back. A no-op on a thread that's gone.\n *\n * @public\n */\nexport function resolveThread(editor: Editor, thread: TLCommentThread, userId: string): void {\n\tcommitCommentMutation(editor, ({ put }) => {\n\t\tconst current = readLatest(editor, thread)\n\t\tif (!current) return\n\t\tput([{ ...current, resolved: { at: Date.now(), by: userId } }])\n\t})\n}\n\n/**\n * Reopen a resolved thread, clearing the resolution. A no-op on a thread that isn't resolved, and\n * on one that's gone. Like {@link resolveThread}, it touches only the resolution.\n *\n * @public\n */\nexport function reopenThread(editor: Editor, thread: TLCommentThread): void {\n\tcommitCommentMutation(editor, ({ put }) => {\n\t\tconst current = readLatest(editor, thread)\n\t\tif (!current) return\n\t\tput([{ ...current, resolved: null }])\n\t})\n}\n\n/**\n * Delete a comment.\n *\n * This is a soft delete: it sets `isDeleted` rather than removing the record, and the server prunes\n * the comment and its reactions once the flag is persisted \u2014 so no client removes records it\n * doesn't own, and a server enforcing per-record permissions has a write it can check.\n *\n * Deleting is the author's to do by default; {@link CommentingOptions.canModifyComment} widens\n * that, as does its counterpart on the server.\n *\n * Never undoable, whatever {@link CommentingOptions.history} says: the flag is write-once\n * server-side, so an undo clearing it would be vetoed rather than bring the comment back.\n *\n * Deleting a thread's last comment closes it and leaves the thread record for the server to prune,\n * since the deleter may not be its creator. An already-deleted comment is a no-op.\n *\n * @public\n */\nexport function deleteComment(editor: Editor, comment: TLComment): void {\n\tcommitCommentMutation(\n\t\teditor,\n\t\t({ put }) => {\n\t\t\tconst current = readLatest(editor, comment)\n\t\t\t// Deleting twice is nothing to do rather than something to redo. The check below counts this\n\t\t\t// comment among the live ones, so it only reads as \"the last one\" while this delete takes it away.\n\t\t\tif (!current || current.isDeleted) return\n\t\t\tconst isLastInThread =\n\t\t\t\tgetLiveComments(editor).filter((c) => c.threadId === current.threadId).length <= 1\n\t\t\tif (isLastInThread && openThreadId.get(editor) === current.threadId) {\n\t\t\t\topenThreadId.set(editor, null)\n\t\t\t}\n\t\t\tput([{ ...current, isDeleted: true }])\n\t\t},\n\t\t'delete'\n\t)\n}\n\n/**\n * Delete a thread and, with it, the whole conversation.\n *\n * A soft delete on the same model as {@link deleteComment}: the server prunes the thread, its\n * comments, and their reactions once the flag is persisted. Deleting a thread is its creator's to\n * do by default ({@link CommentingOptions.canModifyComment}), and the write is never undoable.\n * Closes the thread if it's the open one; a pruned thread is a no-op.\n *\n * @public\n */\nexport function deleteThread(editor: Editor, thread: TLCommentThread): void {\n\tcommitCommentMutation(\n\t\teditor,\n\t\t({ put }) => {\n\t\t\tconst current = readLatest(editor, thread)\n\t\t\tif (!current) return\n\t\t\tif (openThreadId.get(editor) === current.id) {\n\t\t\t\topenThreadId.set(editor, null)\n\t\t\t}\n\t\t\tput([{ ...current, isDeleted: true }])\n\t\t},\n\t\t'delete'\n\t)\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }