@tldraw/commenting 0.0.0-bootstrap → 5.3.0-canary.31036673d3f8

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 (279) hide show
  1. package/README.md +0 -2
  2. package/commenting.css +1139 -329
  3. package/dist-cjs/canvas/anchor-lifecycle.js +148 -0
  4. package/dist-cjs/canvas/anchor-lifecycle.js.map +7 -0
  5. package/dist-cjs/canvas/cluster-input.js +2 -2
  6. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  7. package/dist-cjs/canvas/comment-body.js +1 -1
  8. package/dist-cjs/canvas/comment-body.js.map +2 -2
  9. package/dist-cjs/canvas/comment-drafts.js +54 -0
  10. package/dist-cjs/canvas/comment-drafts.js.map +7 -0
  11. package/dist-cjs/canvas/comment-mutations.js +123 -0
  12. package/dist-cjs/canvas/comment-mutations.js.map +7 -0
  13. package/dist-cjs/canvas/comment-reactions.js +163 -0
  14. package/dist-cjs/canvas/comment-reactions.js.map +7 -0
  15. package/dist-cjs/canvas/comment-render.js +5 -3
  16. package/dist-cjs/canvas/comment-render.js.map +2 -2
  17. package/dist-cjs/canvas/comment-store.js +19 -9
  18. package/dist-cjs/canvas/comment-store.js.map +2 -2
  19. package/dist-cjs/canvas/comment-tool.js +64 -14
  20. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  21. package/dist-cjs/canvas/comments-filter-menu.js +20 -38
  22. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  23. package/dist-cjs/canvas/comments-overflow-menu.js +22 -27
  24. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  25. package/dist-cjs/canvas/comments-overlay.js +429 -474
  26. package/dist-cjs/canvas/comments-overlay.js.map +2 -2
  27. package/dist-cjs/canvas/comments-sidebar.js +35 -32
  28. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  29. package/dist-cjs/canvas/comments-visibility-toggle.js +65 -0
  30. package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
  31. package/dist-cjs/canvas/context.js +17 -0
  32. package/dist-cjs/canvas/context.js.map +7 -0
  33. package/dist-cjs/canvas/hooks.js +22 -3
  34. package/dist-cjs/canvas/hooks.js.map +2 -2
  35. package/dist-cjs/canvas/options.js +19 -2
  36. package/dist-cjs/canvas/options.js.map +2 -2
  37. package/dist-cjs/canvas/pin-stacking.js +56 -0
  38. package/dist-cjs/canvas/pin-stacking.js.map +7 -0
  39. package/dist-cjs/canvas/sidebar-filters.js +2 -2
  40. package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
  41. package/dist-cjs/canvas/state.js +28 -14
  42. package/dist-cjs/canvas/state.js.map +2 -2
  43. package/dist-cjs/canvas/thread-preview.js +185 -0
  44. package/dist-cjs/canvas/thread-preview.js.map +7 -0
  45. package/dist-cjs/canvas/thread-stack.js +199 -0
  46. package/dist-cjs/canvas/thread-stack.js.map +7 -0
  47. package/dist-cjs/canvas/thread-state.js +67 -21
  48. package/dist-cjs/canvas/thread-state.js.map +2 -2
  49. package/dist-cjs/canvas/thread-view.js +397 -0
  50. package/dist-cjs/canvas/thread-view.js.map +7 -0
  51. package/dist-cjs/clustering/computeClusterTable.js +4 -4
  52. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  53. package/dist-cjs/clustering/runtime.js +119 -7
  54. package/dist-cjs/clustering/runtime.js.map +2 -2
  55. package/dist-cjs/clustering/types.js.map +1 -1
  56. package/dist-cjs/index.d.ts +763 -333
  57. package/dist-cjs/index.js +46 -28
  58. package/dist-cjs/index.js.map +2 -2
  59. package/dist-cjs/ui/byline.js +6 -4
  60. package/dist-cjs/ui/byline.js.map +2 -2
  61. package/dist-cjs/ui/comment-card.js +15 -8
  62. package/dist-cjs/ui/comment-card.js.map +2 -2
  63. package/dist-cjs/ui/comment-composer.js +137 -36
  64. package/dist-cjs/ui/comment-composer.js.map +2 -2
  65. package/dist-cjs/ui/comment-pin.js +24 -2
  66. package/dist-cjs/ui/comment-pin.js.map +2 -2
  67. package/dist-cjs/ui/comment-thread.js +9 -7
  68. package/dist-cjs/ui/comment-thread.js.map +2 -2
  69. package/dist-cjs/ui/comments-list.js +30 -18
  70. package/dist-cjs/ui/comments-list.js.map +2 -2
  71. package/dist-cjs/ui/count-badge.js +3 -2
  72. package/dist-cjs/ui/count-badge.js.map +2 -2
  73. package/dist-cjs/ui/emoji-picker.js +54 -0
  74. package/dist-cjs/ui/emoji-picker.js.map +7 -0
  75. package/dist-cjs/ui/empty-state.js +3 -3
  76. package/dist-cjs/ui/empty-state.js.map +2 -2
  77. package/dist-cjs/ui/format-time.js +4 -1
  78. package/dist-cjs/ui/format-time.js.map +2 -2
  79. package/dist-cjs/ui/reaction-picker.js +73 -0
  80. package/dist-cjs/ui/reaction-picker.js.map +7 -0
  81. package/dist-cjs/ui/reaction.js +59 -6
  82. package/dist-cjs/ui/reaction.js.map +2 -2
  83. package/dist-cjs/ui/reactions.js +20 -7
  84. package/dist-cjs/ui/reactions.js.map +2 -2
  85. package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
  86. package/dist-cjs/ui/reply-count.js.map +7 -0
  87. package/dist-cjs/ui/send-button.js +20 -1
  88. package/dist-cjs/ui/send-button.js.map +2 -2
  89. package/dist-cjs/ui/{comment-text.js → tooltip-button.js} +12 -9
  90. package/dist-cjs/ui/tooltip-button.js.map +7 -0
  91. package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
  92. package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
  93. package/dist-esm/canvas/cluster-input.mjs +2 -2
  94. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  95. package/dist-esm/canvas/comment-body.mjs +1 -1
  96. package/dist-esm/canvas/comment-body.mjs.map +2 -2
  97. package/dist-esm/canvas/comment-drafts.mjs +34 -0
  98. package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
  99. package/dist-esm/canvas/comment-mutations.mjs +103 -0
  100. package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
  101. package/dist-esm/canvas/comment-reactions.mjs +152 -0
  102. package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
  103. package/dist-esm/canvas/comment-render.mjs +5 -3
  104. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  105. package/dist-esm/canvas/comment-store.mjs +19 -9
  106. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  107. package/dist-esm/canvas/comment-tool.mjs +67 -17
  108. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  109. package/dist-esm/canvas/comments-filter-menu.mjs +20 -38
  110. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  111. package/dist-esm/canvas/comments-overflow-menu.mjs +23 -27
  112. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  113. package/dist-esm/canvas/comments-overlay.mjs +450 -479
  114. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  115. package/dist-esm/canvas/comments-sidebar.mjs +38 -35
  116. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  117. package/dist-esm/canvas/comments-visibility-toggle.mjs +45 -0
  118. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
  119. package/dist-esm/canvas/context.mjs +1 -0
  120. package/dist-esm/canvas/context.mjs.map +7 -0
  121. package/dist-esm/canvas/hooks.mjs +28 -5
  122. package/dist-esm/canvas/hooks.mjs.map +2 -2
  123. package/dist-esm/canvas/options.mjs +21 -3
  124. package/dist-esm/canvas/options.mjs.map +2 -2
  125. package/dist-esm/canvas/pin-stacking.mjs +36 -0
  126. package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
  127. package/dist-esm/canvas/sidebar-filters.mjs +2 -2
  128. package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
  129. package/dist-esm/canvas/state.mjs +29 -19
  130. package/dist-esm/canvas/state.mjs.map +2 -2
  131. package/dist-esm/canvas/thread-preview.mjs +170 -0
  132. package/dist-esm/canvas/thread-preview.mjs.map +7 -0
  133. package/dist-esm/canvas/thread-stack.mjs +190 -0
  134. package/dist-esm/canvas/thread-stack.mjs.map +7 -0
  135. package/dist-esm/canvas/thread-state.mjs +71 -22
  136. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  137. package/dist-esm/canvas/thread-view.mjs +402 -0
  138. package/dist-esm/canvas/thread-view.mjs.map +7 -0
  139. package/dist-esm/clustering/computeClusterTable.mjs +4 -4
  140. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  141. package/dist-esm/clustering/runtime.mjs +119 -7
  142. package/dist-esm/clustering/runtime.mjs.map +2 -2
  143. package/dist-esm/index.d.mts +763 -333
  144. package/dist-esm/index.mjs +87 -41
  145. package/dist-esm/index.mjs.map +2 -2
  146. package/dist-esm/ui/byline.mjs +6 -4
  147. package/dist-esm/ui/byline.mjs.map +2 -2
  148. package/dist-esm/ui/comment-card.mjs +15 -8
  149. package/dist-esm/ui/comment-card.mjs.map +2 -2
  150. package/dist-esm/ui/comment-composer.mjs +148 -36
  151. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  152. package/dist-esm/ui/comment-pin.mjs +24 -2
  153. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  154. package/dist-esm/ui/comment-thread.mjs +9 -7
  155. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  156. package/dist-esm/ui/comments-list.mjs +43 -29
  157. package/dist-esm/ui/comments-list.mjs.map +2 -2
  158. package/dist-esm/ui/count-badge.mjs +3 -2
  159. package/dist-esm/ui/count-badge.mjs.map +2 -2
  160. package/dist-esm/ui/emoji-picker.mjs +34 -0
  161. package/dist-esm/ui/emoji-picker.mjs.map +7 -0
  162. package/dist-esm/ui/empty-state.mjs +3 -3
  163. package/dist-esm/ui/empty-state.mjs.map +2 -2
  164. package/dist-esm/ui/format-time.mjs +4 -1
  165. package/dist-esm/ui/format-time.mjs.map +2 -2
  166. package/dist-esm/ui/reaction-picker.mjs +60 -0
  167. package/dist-esm/ui/reaction-picker.mjs.map +7 -0
  168. package/dist-esm/ui/reaction.mjs +60 -7
  169. package/dist-esm/ui/reaction.mjs.map +2 -2
  170. package/dist-esm/ui/reactions.mjs +21 -8
  171. package/dist-esm/ui/reactions.mjs.map +2 -2
  172. package/dist-esm/ui/reply-count.mjs +9 -0
  173. package/dist-esm/ui/reply-count.mjs.map +7 -0
  174. package/dist-esm/ui/send-button.mjs +20 -1
  175. package/dist-esm/ui/send-button.mjs.map +2 -2
  176. package/dist-esm/ui/tooltip-button.mjs +12 -0
  177. package/dist-esm/ui/tooltip-button.mjs.map +7 -0
  178. package/package.json +6 -6
  179. package/src/canvas/anchor-lifecycle.test.ts +290 -0
  180. package/src/canvas/anchor-lifecycle.ts +205 -0
  181. package/src/canvas/canvas.css +461 -46
  182. package/src/canvas/cluster-input.test.ts +31 -23
  183. package/src/canvas/cluster-input.ts +3 -4
  184. package/src/canvas/comment-body.tsx +1 -1
  185. package/src/canvas/comment-drafts.test.ts +55 -0
  186. package/src/canvas/comment-drafts.ts +46 -0
  187. package/src/canvas/comment-mutations.test.ts +410 -0
  188. package/src/canvas/comment-mutations.ts +288 -0
  189. package/src/canvas/comment-reactions.test.ts +132 -0
  190. package/src/canvas/comment-reactions.tsx +245 -0
  191. package/src/canvas/comment-render.test.ts +1 -1
  192. package/src/canvas/comment-render.ts +9 -5
  193. package/src/canvas/comment-store.ts +67 -27
  194. package/src/canvas/comment-tool.test.ts +162 -0
  195. package/src/canvas/comment-tool.tsx +105 -23
  196. package/src/canvas/comments-filter-menu.tsx +23 -32
  197. package/src/canvas/comments-overflow-menu.tsx +14 -21
  198. package/src/canvas/comments-overlay.tsx +695 -622
  199. package/src/canvas/comments-sidebar.test.ts +45 -0
  200. package/src/canvas/comments-sidebar.tsx +76 -58
  201. package/src/canvas/comments-visibility-toggle.tsx +49 -0
  202. package/src/canvas/context.ts +47 -0
  203. package/src/canvas/hooks.ts +52 -11
  204. package/src/canvas/options.test.ts +52 -2
  205. package/src/canvas/options.ts +133 -9
  206. package/src/canvas/pin-stacking.test.ts +114 -0
  207. package/src/canvas/pin-stacking.ts +57 -0
  208. package/src/canvas/sidebar-filters.ts +4 -3
  209. package/src/canvas/state.ts +92 -35
  210. package/src/canvas/thread-preview.test.ts +91 -0
  211. package/src/canvas/thread-preview.tsx +304 -0
  212. package/src/canvas/thread-stack.tsx +252 -0
  213. package/src/canvas/thread-state.test.ts +209 -0
  214. package/src/canvas/thread-state.ts +137 -36
  215. package/src/canvas/thread-view.tsx +516 -0
  216. package/src/clustering/computeClusterTable.test.ts +24 -23
  217. package/src/clustering/computeClusterTable.ts +5 -5
  218. package/src/clustering/runtime.test.ts +217 -7
  219. package/src/clustering/runtime.ts +171 -17
  220. package/src/clustering/types.ts +9 -9
  221. package/src/index.ts +76 -40
  222. package/src/ui/byline.tsx +7 -5
  223. package/src/ui/comment-card.tsx +20 -10
  224. package/src/ui/comment-composer.tsx +206 -40
  225. package/src/ui/comment-pin.tsx +26 -2
  226. package/src/ui/comment-thread.tsx +13 -6
  227. package/src/ui/comments-list.test.ts +31 -0
  228. package/src/ui/comments-list.tsx +45 -21
  229. package/src/ui/comments.css +566 -281
  230. package/src/ui/count-badge.tsx +9 -2
  231. package/src/ui/emoji-picker.test.ts +25 -0
  232. package/src/ui/emoji-picker.tsx +76 -0
  233. package/src/ui/empty-state.tsx +3 -3
  234. package/src/ui/format-time.ts +7 -2
  235. package/src/ui/reaction-picker.tsx +109 -0
  236. package/src/ui/reaction.tsx +121 -6
  237. package/src/ui/reactions.tsx +69 -10
  238. package/src/ui/reply-count.ts +16 -0
  239. package/src/ui/send-button.tsx +18 -3
  240. package/src/ui/tooltip-button.tsx +20 -0
  241. package/dist-cjs/canvas/region-options.js +0 -49
  242. package/dist-cjs/canvas/region-options.js.map +0 -7
  243. package/dist-cjs/ui/avatar.js +0 -43
  244. package/dist-cjs/ui/avatar.js.map +0 -7
  245. package/dist-cjs/ui/comment-mention.js +0 -42
  246. package/dist-cjs/ui/comment-mention.js.map +0 -7
  247. package/dist-cjs/ui/comment-text.js.map +0 -7
  248. package/dist-cjs/ui/mention-list.js +0 -70
  249. package/dist-cjs/ui/mention-list.js.map +0 -7
  250. package/dist-cjs/ui/mention-suggestion.js +0 -206
  251. package/dist-cjs/ui/mention-suggestion.js.map +0 -7
  252. package/dist-cjs/ui/mention.js.map +0 -7
  253. package/dist-cjs/ui/render-markdown.js +0 -63
  254. package/dist-cjs/ui/render-markdown.js.map +0 -7
  255. package/dist-esm/canvas/region-options.mjs +0 -29
  256. package/dist-esm/canvas/region-options.mjs.map +0 -7
  257. package/dist-esm/ui/avatar.mjs +0 -23
  258. package/dist-esm/ui/avatar.mjs.map +0 -7
  259. package/dist-esm/ui/comment-mention.mjs +0 -22
  260. package/dist-esm/ui/comment-mention.mjs.map +0 -7
  261. package/dist-esm/ui/comment-text.mjs +0 -9
  262. package/dist-esm/ui/comment-text.mjs.map +0 -7
  263. package/dist-esm/ui/mention-list.mjs +0 -50
  264. package/dist-esm/ui/mention-list.mjs.map +0 -7
  265. package/dist-esm/ui/mention-suggestion.mjs +0 -186
  266. package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
  267. package/dist-esm/ui/mention.mjs +0 -11
  268. package/dist-esm/ui/mention.mjs.map +0 -7
  269. package/dist-esm/ui/render-markdown.mjs +0 -45
  270. package/dist-esm/ui/render-markdown.mjs.map +0 -7
  271. package/src/canvas/region-options.ts +0 -57
  272. package/src/ui/avatar.tsx +0 -31
  273. package/src/ui/comment-mention.ts +0 -47
  274. package/src/ui/comment-text.tsx +0 -12
  275. package/src/ui/mention-list.tsx +0 -106
  276. package/src/ui/mention-suggestion.test.ts +0 -18
  277. package/src/ui/mention-suggestion.tsx +0 -285
  278. package/src/ui/mention.tsx +0 -9
  279. package/src/ui/render-markdown.tsx +0 -72
@@ -0,0 +1,209 @@
1
+ import {
2
+ Box,
3
+ commentSchemaRecords,
4
+ createShapeId,
5
+ createTLSchema,
6
+ createTLStore,
7
+ defaultBindingUtils,
8
+ defaultShapeUtils,
9
+ defaultTools,
10
+ Editor,
11
+ TLShapeId,
12
+ } from 'tldraw'
13
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
14
+ import { commentsSidebarOpen } from './state'
15
+ import {
16
+ anchorPagePoint,
17
+ commentCenterScreenOffset,
18
+ commentTargetShapeAt,
19
+ impreciseShapePinInset,
20
+ IMPRECISE_PIN_INSET_PX,
21
+ shapeAnchorAt,
22
+ } from './thread-state'
23
+
24
+ /**
25
+ * A real editor: the behavior under test is hit-testing against real shape geometry, which is
26
+ * exactly what a stub can't model.
27
+ */
28
+ let editor: Editor
29
+
30
+ beforeEach(() => {
31
+ editor = new Editor({
32
+ store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
33
+ shapeUtils: defaultShapeUtils,
34
+ bindingUtils: defaultBindingUtils,
35
+ tools: defaultTools,
36
+ getContainer: () => document.body,
37
+ })
38
+ editor.updateViewportScreenBounds(new Box(0, 0, 1000, 1000))
39
+ })
40
+
41
+ describe('commentTargetShapeAt', () => {
42
+ it('hits an arrow pointed at near its line', () => {
43
+ const id = createShapeId()
44
+ editor.createShape({
45
+ id,
46
+ type: 'arrow',
47
+ x: 100,
48
+ y: 100,
49
+ props: { start: { x: 0, y: 0 }, end: { x: 200, y: 0 } },
50
+ })
51
+
52
+ // Dead on the line, and a couple of pixels off it — both are a hit. Without a hit-test
53
+ // margin only the pixel-perfect click would land, which made arrows uncommentable.
54
+ expect(commentTargetShapeAt(editor, { x: 200, y: 100 })?.id).toBe(id)
55
+ expect(commentTargetShapeAt(editor, { x: 200, y: 102 })?.id).toBe(id)
56
+ })
57
+
58
+ it('hits a line shape near its segment', () => {
59
+ const id = createShapeId()
60
+ editor.createShape({ id, type: 'line', x: 100, y: 100 })
61
+
62
+ const bounds = editor.getShapePageBounds(id)!
63
+ expect(commentTargetShapeAt(editor, { x: bounds.center.x, y: bounds.center.y })?.id).toBe(id)
64
+ })
65
+
66
+ it('hits inside a hollow shape', () => {
67
+ const id = createShapeId()
68
+ editor.createShape({
69
+ id,
70
+ type: 'geo',
71
+ x: 100,
72
+ y: 100,
73
+ props: { w: 100, h: 100, fill: 'none' },
74
+ })
75
+
76
+ expect(commentTargetShapeAt(editor, { x: 150, y: 150 })?.id).toBe(id)
77
+ })
78
+
79
+ it('returns undefined on empty canvas', () => {
80
+ editor.createShape({
81
+ id: createShapeId(),
82
+ type: 'arrow',
83
+ x: 100,
84
+ y: 100,
85
+ props: { start: { x: 0, y: 0 }, end: { x: 200, y: 0 } },
86
+ })
87
+
88
+ expect(commentTargetShapeAt(editor, { x: 600, y: 600 })).toBeUndefined()
89
+ })
90
+ })
91
+
92
+ /** A 100×100 square whose page bounds are (100,100)–(200,200), unrotated. */
93
+ function createSquare(): TLShapeId {
94
+ const id = createShapeId()
95
+ editor.createShape({ id, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } })
96
+ return id
97
+ }
98
+
99
+ describe('shape anchors under a shape transform', () => {
100
+ it('carries a precise pin around when the shape rotates', () => {
101
+ const id = createSquare()
102
+ // Top-right corner of the square.
103
+ const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, true)
104
+ expect(anchor).toMatchObject({ type: 'shape', shapeId: id, x: 1, y: 0, isPrecise: true })
105
+ expect(anchorPagePoint(editor, anchor)).toMatchObject({ x: 200, y: 100 })
106
+
107
+ // A quarter turn about the square's centre takes that corner to the bottom-right. The
108
+ // square's page bounds don't change, so a pin normalized against them would sit still.
109
+ editor.rotateShapesBy([id], Math.PI / 2)
110
+ const rotated = anchorPagePoint(editor, anchor)!
111
+ expect(rotated.x).toBeCloseTo(200)
112
+ expect(rotated.y).toBeCloseTo(200)
113
+ })
114
+
115
+ it('records the spot a pin was dropped on a rotated shape', () => {
116
+ const id = createSquare()
117
+ editor.rotateShapesBy([id], Math.PI / 2)
118
+
119
+ // After the quarter turn the square's local top-right corner sits at the page's bottom-right.
120
+ const anchor = shapeAnchorAt(editor, id, { x: 200, y: 200 }, true)
121
+ expect(anchor.type).toBe('shape')
122
+ if (anchor.type !== 'shape') return
123
+ expect(anchor.x).toBeCloseTo(1)
124
+ expect(anchor.y).toBeCloseTo(0)
125
+
126
+ const point = anchorPagePoint(editor, anchor)!
127
+ expect(point.x).toBeCloseTo(200)
128
+ expect(point.y).toBeCloseTo(200)
129
+ })
130
+
131
+ it('still tracks moves and resizes', () => {
132
+ const id = createSquare()
133
+ const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, true)
134
+
135
+ editor.updateShape({ id, type: 'geo', x: 300, props: { w: 200 } })
136
+ expect(anchorPagePoint(editor, anchor)).toMatchObject({ x: 500, y: 100 })
137
+ })
138
+
139
+ it('turns an imprecise pin inset with the shape so it keeps stepping inward', () => {
140
+ const id = createSquare()
141
+ const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, false)
142
+ // The default top-right spot: step left and down, into the square.
143
+ expect(impreciseShapePinInset(editor, anchor)).toMatchObject({
144
+ x: -IMPRECISE_PIN_INSET_PX,
145
+ y: IMPRECISE_PIN_INSET_PX,
146
+ })
147
+
148
+ editor.rotateShapesBy([id], Math.PI / 2)
149
+ // The corner is now bottom-right, so stepping inward means left and up.
150
+ const inset = impreciseShapePinInset(editor, anchor)!
151
+ expect(inset.x).toBeCloseTo(-IMPRECISE_PIN_INSET_PX)
152
+ expect(inset.y).toBeCloseTo(-IMPRECISE_PIN_INSET_PX)
153
+ })
154
+
155
+ it('hides the pin for a shape that no longer exists', () => {
156
+ const id = createSquare()
157
+ const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, true)
158
+ editor.deleteShape(id)
159
+ expect(anchorPagePoint(editor, anchor)).toBe(null)
160
+ })
161
+ })
162
+
163
+ describe('commentCenterScreenOffset', () => {
164
+ /** A sidebar element whose left edge sits at `left` client px, in the editor's container. */
165
+ function mountSidebar(left: number) {
166
+ const el = document.createElement('div')
167
+ el.className = 'tlui-cmt-canvas-sidebar'
168
+ el.getBoundingClientRect = () => ({ left }) as DOMRect
169
+ document.body.appendChild(el)
170
+ return el
171
+ }
172
+
173
+ afterEach(() => {
174
+ document.querySelector('.tlui-cmt-canvas-sidebar')?.remove()
175
+ })
176
+
177
+ it('is zero while the sidebar is closed', () => {
178
+ mountSidebar(712)
179
+ expect(commentCenterScreenOffset(editor)).toBe(0)
180
+ })
181
+
182
+ it('is half the covered width, so the pin centers in the uncovered area', () => {
183
+ commentsSidebarOpen.set(editor, true)
184
+ // Viewport is 1000px wide; the sidebar covers the rightmost 288px. The centered pin (at
185
+ // 356px) leaves the thread UI clear of the sidebar, so no nudge is needed.
186
+ mountSidebar(712)
187
+ expect(commentCenterScreenOffset(editor)).toBe(144)
188
+ })
189
+
190
+ it('nudges the pin further left when the thread UI would reach the sidebar', () => {
191
+ commentsSidebarOpen.set(editor, true)
192
+ // Centered in the uncovered area the pin sits at 300px, and the thread UI (334px wide,
193
+ // plus the 8px gap) would cross the sidebar's edge — so the pin backs off to 258px.
194
+ mountSidebar(600)
195
+ expect(commentCenterScreenOffset(editor)).toBe(500 - 258)
196
+ })
197
+
198
+ it('never nudges the pin past the left edge', () => {
199
+ commentsSidebarOpen.set(editor, true)
200
+ // Clearing the sidebar would need a negative pin position; the left inset wins.
201
+ mountSidebar(300)
202
+ expect(commentCenterScreenOffset(editor)).toBe(500 - 8)
203
+ })
204
+
205
+ it('is zero while the sidebar element is not mounted', () => {
206
+ commentsSidebarOpen.set(editor, true)
207
+ expect(commentCenterScreenOffset(editor)).toBe(0)
208
+ })
209
+ })
@@ -1,15 +1,57 @@
1
- import { BoxModel, Editor, TLCommentAnchor, TLCommentThread, TLShapeId, VecLike } from 'tldraw'
2
- import { getRegionCommentOptions } from './region-options'
3
- import { openThreadId } from './state'
1
+ import {
2
+ BoxModel,
3
+ Editor,
4
+ Mat,
5
+ TLCommentAnchor,
6
+ TLCommentThread,
7
+ TLShape,
8
+ TLShapeId,
9
+ Vec,
10
+ VecLike,
11
+ } from 'tldraw'
12
+ import { getCommentingOptions } from './options'
13
+ import { commentsSidebarOpen, openThreadId } from './state'
14
+ import { POPOVER_OFFSET } from './thread-view'
4
15
 
5
- /** Where an imprecise shape comment sits by default: the shape's top-right corner. Overridable. @public */
6
- export const DEFAULT_IMPRECISE_SHAPE_ANCHOR = { x: 1, y: 0 }
16
+ /** How far an imprecise shape pin steps inside the shape from its anchor spot, in screen px —
17
+ * most of the marker sits within the shape, with a small overhang past the corner. */
18
+ export const IMPRECISE_PIN_INSET_PX = 20
7
19
 
8
- /** The default corner a region's pin and composer sit on, as a normalized 0–1 offset (bottom-right).
9
- * Overridable per editor via region options; pin position, composer placement, region move, and
10
- * which corner has no resize handle all derive from the chosen corner. */
20
+ /** Imprecise shape pins tuck inside the shape rather than hanging off its edge: the marker
21
+ * extends up-right of its anchor point, so step it toward the shape's centre. Screen px — the
22
+ * pin is screen-fixed while the shape scales with zoom. Null for anchors that need no inset. */
23
+ export function impreciseShapePinInset(
24
+ editor: Editor,
25
+ anchor: TLCommentThread['anchor']
26
+ ): { x: number; y: number } | null {
27
+ if (anchor.type !== 'shape' || anchor.isPrecise) return null
28
+ const spot = getCommentingOptions(editor).impreciseShapeAnchor
29
+ const inset = {
30
+ x: Math.sign(0.5 - spot.x) * IMPRECISE_PIN_INSET_PX,
31
+ y: Math.sign(0.5 - spot.y) * IMPRECISE_PIN_INSET_PX,
32
+ }
33
+ // The spot is a corner of the shape's own bounds, so which way "toward the centre" points turns
34
+ // with the shape: without this a rotated shape's pin would step out of the shape, not into it.
35
+ const rotation = editor.getShapePageTransform(anchor.shapeId as TLShapeId)?.rotation() ?? 0
36
+ return rotation === 0 ? inset : Vec.Rot(inset, rotation)
37
+ }
38
+
39
+ /** The corner a region's pin and composer sit on, as a normalized 0–1 offset (bottom-right). Pin
40
+ * position, composer placement, region move, and which corner has no resize handle all derive
41
+ * from it. */
11
42
  export const REGION_PIN_CORNER: VecLike = { x: 1, y: 1 }
12
43
 
44
+ /** A region anchor's pin corner: the corner its creating drag released on, when recorded, else
45
+ * {@link REGION_PIN_CORNER}. @internal */
46
+ export function regionAnchorPinCorner(
47
+ anchor: Extract<TLCommentAnchor, { type: 'region' }>
48
+ ): VecLike {
49
+ if (anchor.pinX !== undefined && anchor.pinY !== undefined) {
50
+ return { x: anchor.pinX, y: anchor.pinY }
51
+ }
52
+ return REGION_PIN_CORNER
53
+ }
54
+
13
55
  /** The page point of a region's pin corner. */
14
56
  export function regionPinPoint(region: BoxModel, corner: VecLike = REGION_PIN_CORNER): VecLike {
15
57
  return {
@@ -20,41 +62,68 @@ export function regionPinPoint(region: BoxModel, corner: VecLike = REGION_PIN_CO
20
62
 
21
63
  /**
22
64
  * Where a thread's pin sits on the page, for each anchor kind. Null hides the pin. For imprecise
23
- * shape anchors the pin uses `impreciseShapeAnchor` (a normalized 0–1 spot, top-right by default)
24
- * rather than the stored `x`/`y`.
65
+ * shape anchors the pin uses the editor's {@link CommentingOptions.impreciseShapeAnchor} (a
66
+ * normalized 0–1 spot, top-right by default) rather than the stored `x`/`y`.
67
+ *
68
+ * A shape anchor's `x`/`y` are normalized within the shape's own bounds and resolved through the
69
+ * shape's page transform, so the pin rides every part of that transform — rotating the shape
70
+ * carries the pin around with it instead of leaving it behind in the bounding box.
25
71
  * @public
26
72
  */
27
73
  export function anchorPagePoint(
28
74
  editor: Editor,
29
- anchor: TLCommentAnchor,
30
- impreciseShapeAnchor: { x: number; y: number } = DEFAULT_IMPRECISE_SHAPE_ANCHOR
75
+ anchor: TLCommentAnchor
31
76
  ): { x: number; y: number } | null {
32
77
  switch (anchor.type) {
33
78
  case 'shape': {
34
- const bounds = editor.getShapePageBounds(anchor.shapeId as TLShapeId)
35
- if (!bounds) return null
36
- // Precise pins sit at their stored x/y; imprecise ones at the consumer's default spot.
37
- const { x, y } = anchor.isPrecise ? anchor : impreciseShapeAnchor
38
- return { x: bounds.minX + x * bounds.w, y: bounds.minY + y * bounds.h }
39
- }
40
- case 'text-range': {
41
- const bounds = editor.getShapePageBounds(anchor.shapeId as TLShapeId)
42
- if (!bounds) return null
43
- return { x: bounds.maxX, y: bounds.minY }
79
+ const shape = editor.getShape(anchor.shapeId as TLShapeId)
80
+ if (!shape) return null
81
+ const transform = editor.getShapePageTransform(shape)
82
+ if (!transform) return null
83
+ const { point, size } = editor.getShapeGeometry(shape).bounds
84
+ // Precise pins sit at their stored x/y; imprecise ones at the editor's configured spot.
85
+ const spot = anchor.isPrecise ? anchor : getCommentingOptions(editor).impreciseShapeAnchor
86
+ return Mat.applyToPoint(transform, Vec.Add(point, Vec.MulV(spot, size)))
44
87
  }
45
88
  case 'point':
46
89
  return { x: anchor.x, y: anchor.y }
47
90
  case 'region':
48
- return regionPinPoint(anchor, getRegionCommentOptions(editor).pinCorner)
91
+ return regionPinPoint(anchor, regionAnchorPinCorner(anchor))
49
92
  case 'page':
50
93
  return null
51
94
  }
52
95
  }
53
96
 
97
+ /**
98
+ * The shape a comment placed at a page point should anchor to, or undefined for empty canvas.
99
+ *
100
+ * Uses the editor's hit-test margin, the same slack select and hover use. Without it, shapes whose
101
+ * geometry is an open path — arrows, lines, draw strokes — are unhittable in practice: their
102
+ * geometry reports a positive distance for every point off the stroke, so a zero margin only
103
+ * matches a pixel-perfect click right on the line.
104
+ *
105
+ * `hitFrameInside` lets a click inside a frame's body anchor to the frame — without it a frame is
106
+ * hit only on its edge/label (the select-tool convention), so the frame's interior would fall
107
+ * through to a bare point. A child shape under the pointer still wins (children sort above the
108
+ * frame), so only a frame's empty interior anchors the frame.
109
+ *
110
+ * @internal
111
+ */
112
+ export function commentTargetShapeAt(editor: Editor, page: VecLike): TLShape | undefined {
113
+ return editor.getShapeAtPoint(page, {
114
+ hitInside: true,
115
+ hitFrameInside: true,
116
+ margin: editor.getHitTestMargin(),
117
+ })
118
+ }
119
+
54
120
  /**
55
121
  * A shape anchor for a page point. `x`/`y` are the point's normalized (0–1) offset within the
56
- * shape's page bounds, remembered either way. When `precise` (Alt held) the pin sits at exactly
57
- * `x`/`y`; otherwise it sits at the consumer's imprecise default (top-right out of the box).
122
+ * shape's own bounds taken in the shape's own space, so a pin placed on a rotated shape records
123
+ * the spot it was dropped on rather than a spot in the bounding box. Remembered either way: when
124
+ * `precise` the pin sits at exactly `x`/`y`; otherwise it sits at the consumer's imprecise default
125
+ * (top-right out of the box). Placement gestures get `precise` from the `shouldBePrecise`
126
+ * commenting option (always precise, by default).
58
127
  * @public
59
128
  */
60
129
  export function shapeAnchorAt(
@@ -63,29 +132,61 @@ export function shapeAnchorAt(
63
132
  page: { x: number; y: number },
64
133
  precise: boolean
65
134
  ): TLCommentAnchor {
66
- const bounds = editor.getShapePageBounds(shapeId)
67
- if (!bounds || bounds.w === 0 || bounds.h === 0) {
135
+ const shape = editor.getShape(shapeId)
136
+ const bounds = shape && editor.getShapeGeometry(shape).bounds
137
+ if (!shape || !bounds || bounds.w === 0 || bounds.h === 0) {
68
138
  return { type: 'shape', shapeId, x: 0.5, y: 0.5, isPrecise: precise }
69
139
  }
140
+ const local = editor.getPointInShapeSpace(shape, page)
70
141
  return {
71
142
  type: 'shape',
72
143
  shapeId,
73
- x: (page.x - bounds.minX) / bounds.w,
74
- y: (page.y - bounds.minY) / bounds.h,
144
+ x: (local.x - bounds.minX) / bounds.w,
145
+ y: (local.y - bounds.minY) / bounds.h,
75
146
  isPrecise: precise,
76
147
  }
77
148
  }
78
149
 
79
150
  /** Open a thread and bring it into view — switch to its page if needed, then center its pin. @public */
80
- export function focusThread(
81
- editor: Editor,
82
- thread: TLCommentThread,
83
- impreciseShapeAnchor?: { x: number; y: number }
84
- ): void {
151
+ export function focusThread(editor: Editor, thread: TLCommentThread): void {
85
152
  if (thread.pageId !== editor.getCurrentPageId()) {
86
153
  editor.setCurrentPage(thread.pageId as any)
87
154
  }
88
155
  openThreadId.set(editor, thread.id)
89
- const point = anchorPagePoint(editor, thread.anchor, impreciseShapeAnchor)
90
- if (point) editor.centerOnPoint(point, { animation: { duration: 200 } })
156
+ const point = anchorPagePoint(editor, thread.anchor)
157
+ if (!point) return
158
+ const offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()
159
+ editor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration: 200 } })
160
+ }
161
+
162
+ /** The left inset a nudged pin never crosses, and the gap kept between thread UI and sidebar. */
163
+ const CENTER_MARGIN_PX = 8
164
+
165
+ /** How far the open thread UI reaches right of its pin, in screen px: the popover's offset plus
166
+ * the thread panel's fixed width (300px, `.tlui-cmt-thread`). */
167
+ const THREAD_UI_EXTENT_PX = POPOVER_OFFSET.thread.x + 300
168
+
169
+ /**
170
+ * How far right of a centered-on pin the true viewport center should sit, in screen px. While the
171
+ * comments sidebar covers the viewport's right edge, centering a pin dead-center puts the thread
172
+ * popover that opens on it under the sidebar — so centering aims the pin at the middle of the
173
+ * uncovered area instead, nudged further left if the thread UI would still reach the sidebar, but
174
+ * never past the viewport's left edge. Zero when the sidebar is closed or not on screen.
175
+ * @internal
176
+ */
177
+ export function commentCenterScreenOffset(editor: Editor): number {
178
+ if (!commentsSidebarOpen.get(editor)) return 0
179
+ const sidebar = editor.getContainer().querySelector('.tlui-cmt-canvas-sidebar')
180
+ if (!sidebar) return 0
181
+ const viewport = editor.getViewportScreenBounds()
182
+ // Viewport screen bounds are the container's client rect, so this is container-local.
183
+ const sidebarLeft = sidebar.getBoundingClientRect().left - viewport.x
184
+ if (sidebarLeft >= viewport.w) return 0
185
+ // Aim the pin at the middle of the uncovered area, nudged left until the thread UI clears the
186
+ // sidebar — but never past the left edge (the edge wins when there's no room for both).
187
+ const pinX = Math.max(
188
+ Math.min(sidebarLeft / 2, sidebarLeft - CENTER_MARGIN_PX - THREAD_UI_EXTENT_PX),
189
+ CENTER_MARGIN_PX
190
+ )
191
+ return viewport.w / 2 - pinX
91
192
  }