tldraw 5.3.0-next.fc37bd96825b → 5.3.0

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 (199) hide show
  1. package/DOCS.md +1029 -27
  2. package/RELEASE_NOTES.md +45 -1
  3. package/dist-cjs/index.d.ts +109 -8
  4. package/dist-cjs/index.js +8 -3
  5. package/dist-cjs/index.js.map +2 -2
  6. package/dist-cjs/lib/TldrawImage.js +5 -1
  7. package/dist-cjs/lib/TldrawImage.js.map +2 -2
  8. package/dist-cjs/lib/defaultEmbedDefinitions.js +4 -0
  9. package/dist-cjs/lib/defaultEmbedDefinitions.js.map +2 -2
  10. package/dist-cjs/lib/defaultExternalContentHandlers.js +8 -0
  11. package/dist-cjs/lib/defaultExternalContentHandlers.js.map +2 -2
  12. package/dist-cjs/lib/defaultOverlayUtils.js +0 -2
  13. package/dist-cjs/lib/defaultOverlayUtils.js.map +2 -2
  14. package/dist-cjs/lib/defaultSideEffects.js +18 -0
  15. package/dist-cjs/lib/defaultSideEffects.js.map +2 -2
  16. package/dist-cjs/lib/overlays/CollaboratorCursorOverlayUtil.js.map +2 -2
  17. package/dist-cjs/lib/overlays/CollaboratorHintOverlayUtil.js +2 -6
  18. package/dist-cjs/lib/overlays/CollaboratorHintOverlayUtil.js.map +2 -2
  19. package/dist-cjs/lib/shapes/embed/EmbedShapeUtil.js +72 -4
  20. package/dist-cjs/lib/shapes/embed/EmbedShapeUtil.js.map +2 -2
  21. package/dist-cjs/lib/shapes/geo/GeoShapeUtil.js +5 -1
  22. package/dist-cjs/lib/shapes/geo/GeoShapeUtil.js.map +2 -2
  23. package/dist-cjs/lib/shapes/geo/getGeoShapePath.js +7 -1
  24. package/dist-cjs/lib/shapes/geo/getGeoShapePath.js.map +2 -2
  25. package/dist-cjs/lib/shapes/image/ImageShapeUtil.js +2 -3
  26. package/dist-cjs/lib/shapes/image/ImageShapeUtil.js.map +2 -2
  27. package/dist-cjs/lib/shapes/note/NoteShapeUtil.js +7 -0
  28. package/dist-cjs/lib/shapes/note/NoteShapeUtil.js.map +2 -2
  29. package/dist-cjs/lib/shapes/shared/PathBuilder.js +38 -0
  30. package/dist-cjs/lib/shapes/shared/PathBuilder.js.map +2 -2
  31. package/dist-cjs/lib/shapes/shared/flip.js +42 -0
  32. package/dist-cjs/lib/shapes/shared/flip.js.map +7 -0
  33. package/dist-cjs/lib/ui/components/MainMenu/DefaultMainMenuContent.js +3 -0
  34. package/dist-cjs/lib/ui/components/MainMenu/DefaultMainMenuContent.js.map +2 -2
  35. package/dist-cjs/lib/ui/components/Minimap/MinimapManager.js +8 -0
  36. package/dist-cjs/lib/ui/components/Minimap/MinimapManager.js.map +2 -2
  37. package/dist-cjs/lib/ui/components/QuickActions/DefaultQuickActionsContent.js +15 -1
  38. package/dist-cjs/lib/ui/components/QuickActions/DefaultQuickActionsContent.js.map +2 -2
  39. package/dist-cjs/lib/ui/components/SharePanel/DefaultPeopleMenuAvatar.js +1 -1
  40. package/dist-cjs/lib/ui/components/SharePanel/DefaultPeopleMenuAvatar.js.map +2 -2
  41. package/dist-cjs/lib/ui/components/SharePanel/DefaultPeopleMenuFacePile.js +2 -1
  42. package/dist-cjs/lib/ui/components/SharePanel/DefaultPeopleMenuFacePile.js.map +2 -2
  43. package/dist-cjs/lib/ui/components/Toolbar/DefaultToolbar.js +4 -4
  44. package/dist-cjs/lib/ui/components/Toolbar/DefaultToolbar.js.map +2 -2
  45. package/dist-cjs/lib/ui/components/Toolbar/DefaultToolbarContent.js +7 -0
  46. package/dist-cjs/lib/ui/components/Toolbar/DefaultToolbarContent.js.map +2 -2
  47. package/dist-cjs/lib/ui/components/menu-items.js +17 -8
  48. package/dist-cjs/lib/ui/components/menu-items.js.map +2 -2
  49. package/dist-cjs/lib/ui/components/primitives/TldrawUiContextualToolbar.js +0 -1
  50. package/dist-cjs/lib/ui/components/primitives/TldrawUiContextualToolbar.js.map +2 -2
  51. package/dist-cjs/lib/ui/components/primitives/menus/TldrawUiMenuItem.js +2 -0
  52. package/dist-cjs/lib/ui/components/primitives/menus/TldrawUiMenuItem.js.map +2 -2
  53. package/dist-cjs/lib/ui/hooks/menu-hooks.js +1 -1
  54. package/dist-cjs/lib/ui/hooks/menu-hooks.js.map +2 -2
  55. package/dist-cjs/lib/ui/hooks/useClipboardEvents.js +21 -22
  56. package/dist-cjs/lib/ui/hooks/useClipboardEvents.js.map +2 -2
  57. package/dist-cjs/lib/ui/hooks/useCommentingEnabled.js +28 -0
  58. package/dist-cjs/lib/ui/hooks/useCommentingEnabled.js.map +7 -0
  59. package/dist-cjs/lib/ui/hooks/useKeyboardShortcuts.js +8 -1
  60. package/dist-cjs/lib/ui/hooks/useKeyboardShortcuts.js.map +2 -2
  61. package/dist-cjs/lib/ui/hooks/useTranslation/TLUiTranslationKey.js.map +1 -1
  62. package/dist-cjs/lib/ui/hooks/useTranslation/defaultTranslation.js +44 -1
  63. package/dist-cjs/lib/ui/hooks/useTranslation/defaultTranslation.js.map +2 -2
  64. package/dist-cjs/lib/ui/hooks/useTranslation/useTranslation.js +6 -2
  65. package/dist-cjs/lib/ui/hooks/useTranslation/useTranslation.js.map +2 -2
  66. package/dist-cjs/lib/ui/icon-types.js +1 -0
  67. package/dist-cjs/lib/ui/icon-types.js.map +2 -2
  68. package/dist-cjs/lib/ui/version.js +3 -3
  69. package/dist-cjs/lib/ui/version.js.map +1 -1
  70. package/dist-cjs/lib/utils/embeds/embeds.js +12 -0
  71. package/dist-cjs/lib/utils/embeds/embeds.js.map +2 -2
  72. package/dist-cjs/lib/utils/text/richText.js +32 -23
  73. package/dist-cjs/lib/utils/text/richText.js.map +3 -3
  74. package/dist-esm/index.d.mts +109 -8
  75. package/dist-esm/index.mjs +15 -7
  76. package/dist-esm/index.mjs.map +2 -2
  77. package/dist-esm/lib/TldrawImage.mjs +5 -1
  78. package/dist-esm/lib/TldrawImage.mjs.map +2 -2
  79. package/dist-esm/lib/defaultEmbedDefinitions.mjs +4 -0
  80. package/dist-esm/lib/defaultEmbedDefinitions.mjs.map +2 -2
  81. package/dist-esm/lib/defaultExternalContentHandlers.mjs +9 -0
  82. package/dist-esm/lib/defaultExternalContentHandlers.mjs.map +2 -2
  83. package/dist-esm/lib/defaultOverlayUtils.mjs +0 -2
  84. package/dist-esm/lib/defaultOverlayUtils.mjs.map +2 -2
  85. package/dist-esm/lib/defaultSideEffects.mjs +18 -0
  86. package/dist-esm/lib/defaultSideEffects.mjs.map +2 -2
  87. package/dist-esm/lib/overlays/CollaboratorCursorOverlayUtil.mjs.map +2 -2
  88. package/dist-esm/lib/overlays/CollaboratorHintOverlayUtil.mjs +3 -7
  89. package/dist-esm/lib/overlays/CollaboratorHintOverlayUtil.mjs.map +2 -2
  90. package/dist-esm/lib/shapes/embed/EmbedShapeUtil.mjs +73 -5
  91. package/dist-esm/lib/shapes/embed/EmbedShapeUtil.mjs.map +2 -2
  92. package/dist-esm/lib/shapes/geo/GeoShapeUtil.mjs +5 -1
  93. package/dist-esm/lib/shapes/geo/GeoShapeUtil.mjs.map +2 -2
  94. package/dist-esm/lib/shapes/geo/getGeoShapePath.mjs +7 -1
  95. package/dist-esm/lib/shapes/geo/getGeoShapePath.mjs.map +2 -2
  96. package/dist-esm/lib/shapes/image/ImageShapeUtil.mjs +2 -3
  97. package/dist-esm/lib/shapes/image/ImageShapeUtil.mjs.map +2 -2
  98. package/dist-esm/lib/shapes/note/NoteShapeUtil.mjs +7 -0
  99. package/dist-esm/lib/shapes/note/NoteShapeUtil.mjs.map +2 -2
  100. package/dist-esm/lib/shapes/shared/PathBuilder.mjs +39 -0
  101. package/dist-esm/lib/shapes/shared/PathBuilder.mjs.map +2 -2
  102. package/dist-esm/lib/shapes/shared/flip.mjs +22 -0
  103. package/dist-esm/lib/shapes/shared/flip.mjs.map +7 -0
  104. package/dist-esm/lib/ui/components/MainMenu/DefaultMainMenuContent.mjs +3 -0
  105. package/dist-esm/lib/ui/components/MainMenu/DefaultMainMenuContent.mjs.map +2 -2
  106. package/dist-esm/lib/ui/components/Minimap/MinimapManager.mjs +9 -0
  107. package/dist-esm/lib/ui/components/Minimap/MinimapManager.mjs.map +2 -2
  108. package/dist-esm/lib/ui/components/QuickActions/DefaultQuickActionsContent.mjs +15 -1
  109. package/dist-esm/lib/ui/components/QuickActions/DefaultQuickActionsContent.mjs.map +2 -2
  110. package/dist-esm/lib/ui/components/SharePanel/DefaultPeopleMenuAvatar.mjs +2 -2
  111. package/dist-esm/lib/ui/components/SharePanel/DefaultPeopleMenuAvatar.mjs.map +2 -2
  112. package/dist-esm/lib/ui/components/SharePanel/DefaultPeopleMenuFacePile.mjs +2 -1
  113. package/dist-esm/lib/ui/components/SharePanel/DefaultPeopleMenuFacePile.mjs.map +2 -2
  114. package/dist-esm/lib/ui/components/Toolbar/DefaultToolbar.mjs +4 -4
  115. package/dist-esm/lib/ui/components/Toolbar/DefaultToolbar.mjs.map +2 -2
  116. package/dist-esm/lib/ui/components/Toolbar/DefaultToolbarContent.mjs +7 -0
  117. package/dist-esm/lib/ui/components/Toolbar/DefaultToolbarContent.mjs.map +2 -2
  118. package/dist-esm/lib/ui/components/menu-items.mjs +18 -9
  119. package/dist-esm/lib/ui/components/menu-items.mjs.map +2 -2
  120. package/dist-esm/lib/ui/components/primitives/TldrawUiContextualToolbar.mjs +0 -2
  121. package/dist-esm/lib/ui/components/primitives/TldrawUiContextualToolbar.mjs.map +2 -2
  122. package/dist-esm/lib/ui/components/primitives/menus/TldrawUiMenuItem.mjs +2 -0
  123. package/dist-esm/lib/ui/components/primitives/menus/TldrawUiMenuItem.mjs.map +2 -2
  124. package/dist-esm/lib/ui/hooks/menu-hooks.mjs +1 -1
  125. package/dist-esm/lib/ui/hooks/menu-hooks.mjs.map +2 -2
  126. package/dist-esm/lib/ui/hooks/useClipboardEvents.mjs +21 -22
  127. package/dist-esm/lib/ui/hooks/useClipboardEvents.mjs.map +2 -2
  128. package/dist-esm/lib/ui/hooks/useCommentingEnabled.mjs +8 -0
  129. package/dist-esm/lib/ui/hooks/useCommentingEnabled.mjs.map +7 -0
  130. package/dist-esm/lib/ui/hooks/useKeyboardShortcuts.mjs +8 -1
  131. package/dist-esm/lib/ui/hooks/useKeyboardShortcuts.mjs.map +2 -2
  132. package/dist-esm/lib/ui/hooks/useTranslation/defaultTranslation.mjs +44 -1
  133. package/dist-esm/lib/ui/hooks/useTranslation/defaultTranslation.mjs.map +2 -2
  134. package/dist-esm/lib/ui/hooks/useTranslation/useTranslation.mjs +6 -2
  135. package/dist-esm/lib/ui/hooks/useTranslation/useTranslation.mjs.map +2 -2
  136. package/dist-esm/lib/ui/icon-types.mjs +1 -0
  137. package/dist-esm/lib/ui/icon-types.mjs.map +2 -2
  138. package/dist-esm/lib/ui/version.mjs +3 -3
  139. package/dist-esm/lib/ui/version.mjs.map +1 -1
  140. package/dist-esm/lib/utils/embeds/embeds.mjs +12 -0
  141. package/dist-esm/lib/utils/embeds/embeds.mjs.map +2 -2
  142. package/dist-esm/lib/utils/text/richText.mjs +32 -23
  143. package/dist-esm/lib/utils/text/richText.mjs.map +3 -3
  144. package/package.json +7 -4
  145. package/src/index.ts +14 -8
  146. package/src/lib/TldrawImage.tsx +12 -1
  147. package/src/lib/defaultEmbedDefinitions.ts +12 -0
  148. package/src/lib/defaultExternalContentHandlers.test.ts +59 -1
  149. package/src/lib/defaultExternalContentHandlers.ts +17 -0
  150. package/src/lib/defaultOverlayUtils.ts +0 -2
  151. package/src/lib/defaultSideEffects.ts +28 -1
  152. package/src/lib/overlays/CollaboratorCursorOverlayUtil.ts +12 -1
  153. package/src/lib/overlays/CollaboratorHintOverlayUtil.ts +3 -17
  154. package/src/lib/shapes/embed/EmbedShapeUtil.tsx +98 -7
  155. package/src/lib/shapes/geo/GeoShapeUtil.test.tsx +65 -0
  156. package/src/lib/shapes/geo/GeoShapeUtil.tsx +4 -0
  157. package/src/lib/shapes/geo/getGeoShapePath.ts +14 -1
  158. package/src/lib/shapes/image/ImageShapeUtil.tsx +2 -3
  159. package/src/lib/shapes/note/NoteShapeUtil.tsx +15 -0
  160. package/src/lib/shapes/shared/PathBuilder.test.tsx +31 -0
  161. package/src/lib/shapes/shared/PathBuilder.tsx +44 -0
  162. package/src/lib/shapes/shared/flip.ts +59 -0
  163. package/src/lib/ui/components/MainMenu/DefaultMainMenuContent.tsx +6 -0
  164. package/src/lib/ui/components/Minimap/MinimapManager.ts +12 -0
  165. package/src/lib/ui/components/QuickActions/DefaultQuickActionsContent.tsx +17 -0
  166. package/src/lib/ui/components/SharePanel/DefaultPeopleMenuAvatar.tsx +2 -2
  167. package/src/lib/ui/components/SharePanel/DefaultPeopleMenuFacePile.tsx +2 -2
  168. package/src/lib/ui/components/Toolbar/DefaultToolbar.tsx +4 -4
  169. package/src/lib/ui/components/Toolbar/DefaultToolbarContent.tsx +14 -0
  170. package/src/lib/ui/components/menu-items.tsx +27 -11
  171. package/src/lib/ui/components/primitives/TldrawUiContextualToolbar.tsx +0 -2
  172. package/src/lib/ui/components/primitives/menus/TldrawUiMenuItem.tsx +2 -0
  173. package/src/lib/ui/hooks/menu-hooks.ts +2 -1
  174. package/src/lib/ui/hooks/useClipboardEvents.ts +37 -31
  175. package/src/lib/ui/hooks/useCommentingEnabled.ts +13 -0
  176. package/src/lib/ui/hooks/useKeyboardShortcuts.ts +19 -1
  177. package/src/lib/ui/hooks/useTranslation/TLUiTranslationKey.ts +43 -0
  178. package/src/lib/ui/hooks/useTranslation/defaultTranslation.ts +43 -0
  179. package/src/lib/ui/hooks/useTranslation/useTranslation.test.tsx +72 -0
  180. package/src/lib/ui/hooks/useTranslation/useTranslation.tsx +12 -2
  181. package/src/lib/ui/icon-types.ts +2 -0
  182. package/src/lib/ui/version.ts +3 -3
  183. package/src/lib/ui.css +19 -0
  184. package/src/lib/utils/embeds/embeds.test.ts +35 -1
  185. package/src/lib/utils/embeds/embeds.ts +29 -0
  186. package/src/lib/utils/excalidraw/__snapshots__/putExcalidrawContent.test.tsx.snap +16 -0
  187. package/src/lib/utils/text/richText.test.ts +52 -1
  188. package/src/lib/utils/text/richText.ts +61 -26
  189. package/src/lib/utils/tldr/__snapshots__/buildFromV1Document.test.ts.snap +12 -0
  190. package/src/test/attribution.test.ts +127 -0
  191. package/src/test/commands/__snapshots__/packShapes.test.ts.snap +18 -0
  192. package/src/test/commands/clipboardPaste.test.ts +544 -0
  193. package/src/test/embed-aspect-ratio.test.ts +254 -0
  194. package/src/test/flipShapes.test.ts +7 -2
  195. package/src/test/overlays/CollaboratorCursorOverlayUtil.test.ts +9 -2
  196. package/src/test/ui/ContextMenu.test.tsx +80 -1
  197. package/src/test/ui/EditorPortal.test.tsx +35 -0
  198. package/src/test/ui/keyboardShortcuts.test.tsx +50 -0
  199. package/tldraw.css +62 -4
package/DOCS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # tldraw Documentation
2
2
 
3
- Version: `5.3.0-next.fc37bd96825b`
3
+ Version: `5.3.0`
4
4
 
5
5
  This file is generated during package publishing from the tldraw docs content for this exact package version.
6
6
 
@@ -661,6 +661,91 @@ export default function () {
661
661
  }
662
662
  ```
663
663
 
664
+ #### Meta
665
+
666
+ Every shape has a `meta` property for your own data. Tldraw stores and syncs this data but doesn't use it itself. It's an escape hatch for attaching extra information to shapes, like the name of the user who created a shape or the date it was last changed.
667
+
668
+ Like `props`, the data in `meta` must be JSON-serializable. Shapes aren't the only records with a `meta` property: pages, bindings, assets, and the document record have one too.
669
+
670
+ By default, a shape's `meta` is an empty object typed as `JsonObject`. To type your meta data, use an intersection:
671
+
672
+ ```ts
673
+ type ShapeWithMyMeta = TLGeoShape & { meta: { createdBy: string } }
674
+
675
+ const shape = editor.getShape<ShapeWithMyMeta>(myGeoShapeId)
676
+ ```
677
+
678
+ You can update a shape's `meta` with `Editor#updateShapes`, the same way you update its props:
679
+
680
+ ```ts
681
+ editor.updateShapes<ShapeWithMyMeta>([
682
+ {
683
+ id: myGeoShapeId,
684
+ type: 'geo',
685
+ meta: { createdBy: 'Steve' },
686
+ },
687
+ ])
688
+ ```
689
+
690
+ ##### Initial meta
691
+
692
+ When `Editor#createShapes` creates a shape, it sets the shape's meta using `Editor#getInitialMetaForShape`. By default this method returns an empty object. Replace it to provide your own initial meta:
693
+
694
+ ```tsx
695
+ editor.getInitialMetaForShape = (shape) => {
696
+ if (shape.type === 'text') {
697
+ return { createdBy: currentUser.id, lastModified: Date.now() }
698
+ }
699
+ return { createdBy: currentUser.id }
700
+ }
701
+ ```
702
+
703
+ > For a working example, see our [shape meta on create example](https://tldraw.dev/examples/events/meta-on-create).
704
+
705
+ ##### Updating meta with side effects
706
+
707
+ To keep meta up to date as shapes change, register a side effect that runs before each shape update:
708
+
709
+ ```tsx
710
+ editor.sideEffects.registerBeforeChangeHandler('shape', (_prev, next, source) => {
711
+ if (source !== 'user') return next
712
+ return {
713
+ ...next,
714
+ meta: { updatedBy: editor.user.getExternalId(), updatedAt: Date.now() },
715
+ }
716
+ })
717
+ ```
718
+
719
+ Side effects can run on create, update, and delete for shapes and other records. See [Side effects](https://tldraw.dev/sdk-features/side-effects) for the full API.
720
+
721
+ > For a working example, see our [shape meta on change example](https://tldraw.dev/examples/events/meta-on-change).
722
+
723
+ ##### Validating meta
724
+
725
+ By default, the store accepts any JSON value in `meta`. To validate meta data at runtime, build your own schema with `createTLSchema` and pass validators for each shape type's meta:
726
+
727
+ ```tsx
728
+ import { useState } from 'react'
729
+ import { createTLSchema, createTLStore, defaultShapeSchemas, T, Tldraw } from 'tldraw'
730
+
731
+ const schema = createTLSchema({
732
+ shapes: {
733
+ ...defaultShapeSchemas,
734
+ geo: {
735
+ ...defaultShapeSchemas.geo,
736
+ meta: { createdBy: T.string },
737
+ },
738
+ },
739
+ })
740
+
741
+ export default function App() {
742
+ const [store] = useState(() => createTLStore({ schema }))
743
+ return <Tldraw store={store} />
744
+ }
745
+ ```
746
+
747
+ Bindings, assets, and user records accept meta validators the same way. For user records, see our [custom user metadata example](https://tldraw.dev/examples/users/custom-user).
748
+
664
749
  #### Extending shapes
665
750
 
666
751
  - **`BaseBoxShapeUtil`** - Extend this for standard rectangular shape behavior
@@ -1264,6 +1349,52 @@ Any apps connecting to the same room ID will enter a shared collaboration sessio
1264
1349
 
1265
1350
  The demo server is great for prototyping, but data only lasts 24 hours and rooms are publicly accessible. For production, you'll need to self-host the tldraw sync server—see our full guide on [tldraw sync](https://tldraw.dev/docs/sync).
1266
1351
 
1352
+ #### Comments
1353
+
1354
+ Collaborators can also leave anchored comments on the canvas. The `@tldraw/commenting` package provides a comment tool, pins that stick to points and shapes, thread popovers with replies, mentions, reactions, and resolve, and a sidebar list of threads.
1355
+
1356
+ ```tsx
1357
+ import {
1358
+ CanvasComments,
1359
+ CommentAuthor,
1360
+ commentToolOverrides,
1361
+ commentTools,
1362
+ } from '@tldraw/commenting'
1363
+ import { useSync } from '@tldraw/sync'
1364
+ import { commentSchemaRecords, TLComponents, Tldraw } from 'tldraw'
1365
+ import '@tldraw/commenting/commenting.css'
1366
+
1367
+ // Your app's user directory, however you already hold it.
1368
+ const AUTHORS: Record<string, CommentAuthor> = {
1369
+ 'user-123': { name: 'You', color: '#EC5E41' },
1370
+ }
1371
+
1372
+ const components: TLComponents = {
1373
+ InFrontOfTheCanvas: () => (
1374
+ <CanvasComments currentUserId="user-123" resolveAuthor={(id) => AUTHORS[id]} />
1375
+ ),
1376
+ }
1377
+
1378
+ function MyApp() {
1379
+ const store = useSync({
1380
+ uri: 'wss://your-server.com/sync/my-room',
1381
+ assets: myAssetStore,
1382
+ records: commentSchemaRecords,
1383
+ })
1384
+
1385
+ return (
1386
+ <Tldraw
1387
+ store={store}
1388
+ tools={commentTools}
1389
+ overrides={[commentToolOverrides]}
1390
+ components={components}
1391
+ />
1392
+ )
1393
+ }
1394
+ ```
1395
+
1396
+ Comment records are opt-in, so register `commentSchemaRecords` on your server's schema too. Commenting is a licensed feature. See [Commenting](https://tldraw.dev/docs/commenting) for an introduction, or the [full guide](https://tldraw.dev/sdk-features/commenting) for anchors, options, and server setup.
1397
+
1267
1398
  #### Using other backends
1268
1399
 
1269
1400
  While [tldraw sync](https://tldraw.dev/docs/sync) is our recommended solution, the tldraw SDK works with any real-time data backend. For example, [Liveblocks offers a tldraw integration](https://liveblocks.io/examples/tldraw-whiteboard), and many teams have connected tldraw to their own infrastructure.
@@ -1278,6 +1409,7 @@ Collaboration involves three concerns:
1278
1409
 
1279
1410
  - [tldraw sync](https://tldraw.dev/docs/sync) — Our recommended multiplayer solution
1280
1411
  - [Collaboration](https://tldraw.dev/sdk-features/collaboration) — Deep dive on presence, sync hooks, and building custom sync
1412
+ - [Commenting](https://tldraw.dev/docs/commenting) — Anchored comment threads on the canvas
1281
1413
  - [Cursors](https://tldraw.dev/sdk-features/cursors) — Cursor types and collaborator cursor customization
1282
1414
  - [User following](https://tldraw.dev/sdk-features/user-following) — Track collaborator viewports in real-time
1283
1415
  - [Store](https://tldraw.dev/sdk-features/store) — Understanding the reactive store
@@ -1522,6 +1654,132 @@ We provide several starter kits for AI integrations:
1522
1654
  - [Bindings](https://tldraw.dev/sdk-features/bindings) — Connecting shapes for workflow systems
1523
1655
  - [Image export](https://tldraw.dev/sdk-features/image-export) — Exporting canvas content for AI analysis
1524
1656
 
1657
+ ### Commenting
1658
+
1659
+ In tldraw, a comment is a message pinned to a place on the canvas. Comments group into threads, one conversation per pin. Every comment records who wrote it, as an id your app resolves to a name.
1660
+
1661
+ The `@tldraw/commenting` package works at two levels. `CanvasComments` is a comments layer you render in front of the canvas, and it handles the whole flow. Everything it's built from is exported too, so you can replace any part of it.
1662
+
1663
+ Commenting is a licensed feature. It runs in development without a key. In production it needs a tldraw license that includes commenting.
1664
+
1665
+ #### Quick start
1666
+
1667
+ Three pieces: register the comment record types, register the comment tool, and render the layer.
1668
+
1669
+ ```tsx
1670
+ import {
1671
+ CanvasComments,
1672
+ CommentAuthor,
1673
+ commentToolOverrides,
1674
+ commentTools,
1675
+ } from '@tldraw/commenting'
1676
+ import { useMemo } from 'react'
1677
+ import { commentSchemaRecords, createTLSchema, createTLStore, TLComponents, Tldraw } from 'tldraw'
1678
+ import '@tldraw/commenting/commenting.css'
1679
+ import 'tldraw/tldraw.css'
1680
+
1681
+ const AUTHORS: Record<string, CommentAuthor> = { me: { name: 'You', color: '#EC5E41' } }
1682
+ const resolveAuthor = (id: string) => AUTHORS[id]
1683
+
1684
+ const components: TLComponents = {
1685
+ InFrontOfTheCanvas: () => <CanvasComments currentUserId="me" resolveAuthor={resolveAuthor} />,
1686
+ }
1687
+
1688
+ export default function App() {
1689
+ const store = useMemo(
1690
+ () => createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
1691
+ []
1692
+ )
1693
+
1694
+ return (
1695
+ <div style={{ position: 'fixed', inset: 0 }}>
1696
+ <Tldraw
1697
+ licenseKey={YOUR_LICENSE_KEY}
1698
+ store={store}
1699
+ tools={commentTools}
1700
+ overrides={[commentToolOverrides]}
1701
+ components={components}
1702
+ />
1703
+ </div>
1704
+ )
1705
+ }
1706
+ ```
1707
+
1708
+ Pick the comment tool from Quick Actions, or press `C`, then click the canvas to start a thread.
1709
+
1710
+ The layer's two required inputs are both about identity: `currentUserId` is the id stamped on whatever the user posts, and `resolveAuthor` turns an author id into a name, a color, and an optional avatar image. Commenting reads no user directory of its own, so these are where you connect it to whatever your app already knows about its users. With the read-status and mention callbacks they make up the `CommentingContext`, which `CanvasCommentsSidebar` takes too — build it once and spread it into both.
1711
+
1712
+ #### Comments are records
1713
+
1714
+ Comment threads and comments are records in the editor's store, exactly like shapes. That's the fact most worth holding onto, because almost everything else follows from it.
1715
+
1716
+ They aren't in the default schema, so you opt in by registering `commentSchemaRecords`. Once you have, comments persist and sync however your document already does. Adding a `persistenceKey` or a sync backend carries them along with no extra work.
1717
+
1718
+ It also means you can read and write them yourself. Query the store for threads, seed a document with review notes, or build a panel that does something the built-in sidebar doesn't.
1719
+
1720
+ ```tsx
1721
+ import { useCommentThreads } from '@tldraw/commenting'
1722
+ import { useEditor } from 'tldraw'
1723
+
1724
+ function OpenThreadCount() {
1725
+ const editor = useEditor()
1726
+ const threads = useCommentThreads(editor)
1727
+ return <div>{threads.filter((thread) => !thread.resolved).length} open threads</div>
1728
+ }
1729
+ ```
1730
+
1731
+ #### Anchors
1732
+
1733
+ What pins a thread to the canvas is its anchor. A thread can anchor to a point on the page, to a shape it then follows as that shape moves and resizes, to a rectangular region, or to the page as a whole.
1734
+
1735
+ Clicking empty canvas gives you a point anchor and clicking a shape gives you a shape anchor. Region anchors are off by default; turn them on and dragging the tool out covers an area.
1736
+
1737
+ Shape-anchored threads outlive their shape. Delete the shape and the thread converts to a point anchor where its pin last sat, so the conversation doesn't disappear along with the thing it was about.
1738
+
1739
+ #### What you get
1740
+
1741
+ The comments layer covers the whole flow out of the box:
1742
+
1743
+ | Feature | Description |
1744
+ | ------------ | ------------------------------------------------------------------ |
1745
+ | Threads | Pins on the canvas, opening to replies, edit, resolve, and delete. |
1746
+ | Mentions | `@`-mentions in composers, resolved against a roster you supply. |
1747
+ | Reactions | Emoji reactions on comments, with a pluggable palette. |
1748
+ | A sidebar | A filterable list of threads beside the canvas. |
1749
+ | Clustering | Nearby pins fold into count badges as you zoom out. |
1750
+ | Unread state | Pin badges and an unread filter, driven by your app's read data. |
1751
+
1752
+ Each of these has options, and each visible piece is a component slot you can replace. See [Commenting](https://tldraw.dev/sdk-features/commenting) for the full guide.
1753
+
1754
+ #### Configuring
1755
+
1756
+ Commenting options live on the tool. `CommentTool.configure()` returns a configured subclass to register, mirroring `ShapeUtil.configure`:
1757
+
1758
+ ```tsx
1759
+ const tools = [CommentTool.configure({ enableRegions: true })]
1760
+ ```
1761
+
1762
+ The option worth knowing about first is `history`. Comment writes default to `'ignore'`, which keeps them off the undo stack, and that default matters in a shared document: an undoable delete would resurrect a thread a collaborator already removed. See [Comments and undo](https://tldraw.dev/sdk-features/commenting#comments-and-undo).
1763
+
1764
+ #### Permissions
1765
+
1766
+ `canComment` decides whether the viewer may participate, and the UI follows it: composers give way to a fallback slot and the action affordances hide. `canModifyComment` decides the writes that belong to someone in particular — editing a comment, deleting a comment, deleting a thread. Unset, each is its record's owner's to make; a callback widens that (a workspace admin who may remove anyone's comment) or narrows it.
1767
+
1768
+ They're UI-level controls and nothing more. Comment records carry a client-supplied author id, so rules about who may post, edit, or delete belong in your sync server's record authorization, where each incoming record is checked against the session's identity.
1769
+
1770
+ #### Syncing
1771
+
1772
+ Comments sync like the rest of your document, with one registration on each side. Pass `records: commentSchemaRecords` to your sync hook, and the same map to `createTLSchema` on the server.
1773
+
1774
+ On the server you can go a step further and serve comments through the room's object-store lane. Lane records are gated by their own per-session permission rather than by `isReadonly`, which is how a viewer who can't edit the document can still comment. See [Syncing comments](https://tldraw.dev/sdk-features/commenting#syncing-comments).
1775
+
1776
+ #### Related
1777
+
1778
+ - [Commenting](https://tldraw.dev/sdk-features/commenting) — The full guide: anchors, options, components, and sync
1779
+ - [Collaboration](https://tldraw.dev/docs/collaboration) — Adding multiplayer to your project
1780
+ - [tldraw sync](https://tldraw.dev/docs/sync) — Running a sync server
1781
+ - [Commenting example](https://tldraw.dev/examples/collaboration/commenting) — The flow end to end
1782
+
1525
1783
  ### tldraw sync
1526
1784
 
1527
1785
  You can add realtime multi-user collaboration to your tldraw app by using **tldraw sync**. It's our library for fast, fault-tolerant shared document syncing. We use it in production on our flagship app [tldraw.com](https://tldraw.com).
@@ -1851,6 +2109,43 @@ Both `props` and `migrations` are optional. If you omit `props`, you won't have
1851
2109
  validation for your shape, which could result in bad data being stored. If you omit `migrations`,
1852
2110
  clients on different versions won't be able to collaborate without errors.
1853
2111
 
2112
+ ##### Comments and the object-store lane
2113
+
2114
+ Comment threads, comments, and reactions are record types that aren't in the default schema. Register
2115
+ them with the `records` option on both ends—`useSync` on the client, `createTLSchema` on the
2116
+ server. Both sides must register the same types, or the connection will fail schema validation.
2117
+
2118
+ ```ts
2119
+ import { TLSocketRoom } from '@tldraw/sync-core'
2120
+ import { commentSchemaRecords, createTLSchema } from '@tldraw/tlschema'
2121
+
2122
+ const schema = createTLSchema({ records: commentSchemaRecords })
2123
+
2124
+ const room = new TLSocketRoom({
2125
+ schema,
2126
+ objectTypes: ['comment', 'comment-thread', 'comment-reaction'],
2127
+ })
2128
+ ```
2129
+
2130
+ `objectTypes` serves those record types through the room's object-store lane instead of the document
2131
+ lane. Lane records are persisted separately from the document, left out of document snapshots, and
2132
+ gated by a per-session `objectAccess` permission rather than by `isReadonly`. That's how a session
2133
+ can be allowed to comment without being allowed to edit:
2134
+
2135
+ ```ts
2136
+ room.handleSocketConnect({
2137
+ sessionId,
2138
+ socket,
2139
+ isReadonly: true,
2140
+ objectAccess: 'write',
2141
+ })
2142
+ ```
2143
+
2144
+ `objectAccess` is `'read'` or `'write'` and defaults to `'write'`. Read the lane's contents with
2145
+ `TLSocketRoom`'s `getCurrentObjectsSnapshot()`, and use the room's `onCommittedChanges` callback to
2146
+ mirror committed records into your own database. See [Commenting](https://tldraw.dev/sdk-features/commenting) for the
2147
+ client side.
2148
+
1854
2149
  ##### Deployment concerns
1855
2150
 
1856
2151
  You must make sure that the tldraw version in your client matches the version on the server. We
@@ -6134,6 +6429,26 @@ export default function App({ roomId }: { roomId: string }) {
6134
6429
 
6135
6430
  Pass the shape and binding utilities to both the sync hook (for schema registration) and the Tldraw component (for rendering). If they don't match, shapes may fail to sync or render correctly.
6136
6431
 
6432
+ #### Comments
6433
+
6434
+ Comment threads sync the same way, through record types you opt into with the `records` option:
6435
+
6436
+ ```tsx
6437
+ import { useSync } from '@tldraw/sync'
6438
+ import { commentSchemaRecords } from 'tldraw'
6439
+
6440
+ const store = useSync({
6441
+ uri: `wss://your-server.com/sync/${roomId}`,
6442
+ assets: myAssetStore,
6443
+ records: commentSchemaRecords,
6444
+ })
6445
+ ```
6446
+
6447
+ Your server's schema needs the same registration. On the server you can also serve comments through
6448
+ the room's object-store lane, which gates them by their own per-session permission—so a viewer who
6449
+ can't edit the document can still comment. See [Commenting](https://tldraw.dev/sdk-features/commenting) for the tool,
6450
+ the canvas layer, and the server setup.
6451
+
6137
6452
  #### Building custom sync
6138
6453
 
6139
6454
  The `@tldraw/sync` package handles many complexities: connection management, reconnection, conflict resolution, and protocol versioning. For most applications, it's the right choice. However, you might need custom sync when integrating with existing infrastructure, using a different transport (like WebRTC), or implementing specialized conflict resolution.
@@ -6337,6 +6652,690 @@ This example omits important concerns like initial state sync, reconnection hand
6337
6652
  - [Custom presence](https://tldraw.dev/examples/collaboration/sync-custom-presence) — Customizing presence data sent to collaborators
6338
6653
  - [Custom shapes](https://tldraw.dev/examples/collaboration/sync-custom-shape) — Syncing custom shapes with multiplayer
6339
6654
 
6655
+ ### Commenting
6656
+
6657
+ In tldraw, a comment is a message pinned to a place on the canvas. Comments group into threads, one conversation per pin. Every comment records who wrote it and comments can mention other people.
6658
+
6659
+ The `@tldraw/commenting` package works at two levels. `CanvasComments` is a comments layer you render in front of the canvas: it draws the pins, opens the threads, and reads and writes the records itself. Everything it's built from is exported too, so you can replace any part of it or assemble your own layer from the pieces.
6660
+
6661
+ Commenting is a licensed feature. It runs in development without a key. In production it needs a tldraw license that includes commenting.
6662
+
6663
+ #### Setup
6664
+
6665
+ There are three pieces: register the comment record types with your store, register the comment tool, and render the comments layer.
6666
+
6667
+ ```tsx
6668
+ import {
6669
+ CanvasComments,
6670
+ CommentAuthor,
6671
+ commentTools,
6672
+ commentToolOverrides,
6673
+ } from '@tldraw/commenting'
6674
+ import { useMemo } from 'react'
6675
+ import { commentSchemaRecords, createTLSchema, createTLStore, TLComponents, Tldraw } from 'tldraw'
6676
+ import '@tldraw/commenting/commenting.css'
6677
+ import 'tldraw/tldraw.css'
6678
+
6679
+ // Your app's user directory. Any id you can't resolve falls back to a generic byline.
6680
+ const AUTHORS: Record<string, CommentAuthor> = {
6681
+ me: { name: 'You', color: '#EC5E41' },
6682
+ ada: { name: 'Ada Lovelace', color: '#0E9F6E' },
6683
+ }
6684
+ const resolveAuthor = (id: string) => AUTHORS[id]
6685
+
6686
+ const components: TLComponents = {
6687
+ InFrontOfTheCanvas: () => <CanvasComments currentUserId="me" resolveAuthor={resolveAuthor} />,
6688
+ }
6689
+
6690
+ export default function App() {
6691
+ const store = useMemo(
6692
+ () => createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
6693
+ []
6694
+ )
6695
+
6696
+ return (
6697
+ <div style={{ position: 'fixed', inset: 0 }}>
6698
+ <Tldraw
6699
+ licenseKey={YOUR_LICENSE_KEY}
6700
+ store={store}
6701
+ tools={commentTools}
6702
+ overrides={[commentToolOverrides]}
6703
+ components={components}
6704
+ />
6705
+ </div>
6706
+ )
6707
+ }
6708
+ ```
6709
+
6710
+ Comments are records in the editor's store, exactly like shapes. `commentSchemaRecords` registers the `comment-thread` and `comment` types, which aren't in the default schema. Once they're registered, comments persist and sync however your document already does: add a `persistenceKey` or a sync backend and they come along with it.
6711
+
6712
+ `CanvasComments` needs two things, and both are about identity. `currentUserId` is the id of the person commenting, or `null` for a viewer who can read comments but not post them. `resolveAuthor` maps an author id to a `CommentAuthor`: a `name`, an optional `color` for their avatar and pin, and an optional `image`. Return `undefined` for an id you can't resolve and the layer falls back to an anonymous user. Together with the read-status and mention callbacks below they make up the `CommentingContext`, which the sidebar takes too — so a host mounting both surfaces builds one object and spreads it into each.
6713
+
6714
+ Mount the layer through the `InFrontOfTheCanvas` component slot so it sits above the canvas but below the UI, and import `commenting.css` alongside `tldraw.css`.
6715
+
6716
+ Without a license key that includes commenting, every commenting component renders nothing in
6717
+ production. The feature is fully enabled in development, so a missing key shows up as a blank
6718
+ canvas after you deploy. See [License key](https://tldraw.dev/sdk-features/license-key).
6719
+
6720
+ #### Placing comments
6721
+
6722
+ `commentToolOverrides` puts the comment tool in Quick Actions with the `C` shortcut. With the tool active, clicking empty canvas starts a thread anchored to that point, and clicking a shape anchors a comment to the shape. You can also turn on region comments, where dragging the tool out covers an area. Either way the click only opens a composer: nothing is written to the store until the comment is posted.
6723
+
6724
+ Once a comment thread exists, its pin is the handle for everything else.
6725
+
6726
+ | Input | Result |
6727
+ | ----------- | -------------------------------------------------------------------- |
6728
+ | Click a pin | Opens the thread, with its replies and a reply composer. |
6729
+ | Drag a pin | Re-anchors the thread. Drop it on a shape to attach it to the shape. |
6730
+ | `Escape` | Closes the open thread. |
6731
+ | `Shift+C` | Hides and shows the pins on the canvas. |
6732
+
6733
+ In the built-in layer, resolve and delete-thread sit in the thread's header, and the edit and delete controls for a single comment appear only on the author's own comments by default. [`canModifyComment`](#who-can-edit-and-delete) changes who gets those controls, and [Syncing comments](#syncing-comments) covers enforcing the same rules on the server.
6734
+
6735
+ To put the show and hide toggle in a menu of your own, use `CommentsMenuItem`. It's a checkbox item wired to the same state as the `Shift+C` shortcut.
6736
+
6737
+ ```tsx
6738
+ import { CommentsMenuItem } from '@tldraw/commenting'
6739
+ import { DefaultMainMenu, TldrawUiMenuGroup } from 'tldraw'
6740
+
6741
+ function MainMenu() {
6742
+ return (
6743
+ <DefaultMainMenu>
6744
+ <TldrawUiMenuGroup id="comments">
6745
+ <CommentsMenuItem />
6746
+ </TldrawUiMenuGroup>
6747
+ </DefaultMainMenu>
6748
+ )
6749
+ }
6750
+ ```
6751
+
6752
+ #### Configuring the tool
6753
+
6754
+ Commenting options live on the comment tool. Set them with `CommentTool.configure()`, which mirrors `ShapeUtil.configure` and returns a configured subclass to register:
6755
+
6756
+ ```tsx
6757
+ import { CommentTool, commentToolOverrides } from '@tldraw/commenting'
6758
+ import { Tldraw } from 'tldraw'
6759
+
6760
+ const tools = [CommentTool.configure({ enableRegions: true })]
6761
+
6762
+ function App() {
6763
+ return <Tldraw tools={tools} overrides={[commentToolOverrides]} components={components} />
6764
+ }
6765
+ ```
6766
+
6767
+ Options are fixed once the tool is registered, so this is static configuration only. Live values, like the current user and the author resolver, are the `CommentingContext` instead, passed as props to each surface. Calls to `configure` can be chained, and each one layers over the last.
6768
+
6769
+ The sections below introduce the relevant comment options, and [All options](#all-options) lists every one together.
6770
+
6771
+ #### Anchors
6772
+
6773
+ A thread's `anchor` says where it lives. It's a discriminated union, so new anchor kinds can arrive without breaking existing threads.
6774
+
6775
+ | Anchor | Description |
6776
+ | -------- | ------------------------------------------------------------------------------------------------------------------------ |
6777
+ | `point` | A fixed page point. What a click on empty canvas produces. |
6778
+ | `shape` | A shape, with `x`/`y` as a normalized (0–1) offset in its bounds. The pin keeps its spot as the shape moves and resizes. |
6779
+ | `region` | A rectangular area of the page, with the pin on one corner. |
6780
+ | `page` | The page itself, with no spatial anchor. These threads have no pin and surface in a list instead. |
6781
+
6782
+ Shape-anchored threads outlive their shape. Delete the shape and the thread converts to a `point` anchor where its pin last sat, so the conversation doesn't vanish with the thing it was about. Bring the shape back, whether by undoing the delete or by a page move that re-creates it, and the shape anchor is restored. The exception is a pin someone moved by hand in the meantime: a manual placement wins over the restore.
6783
+
6784
+ Threads follow their shape across pages too. Move the shape to another page and the thread's `pageId`, along with the denormalized `pageId` on each of its comments, updates to match. Comments do not move when you cut or copy a shape.
6785
+
6786
+ ##### Shape anchor precision
6787
+
6788
+ A comment that lands on a shape anchors in one of two ways. A **precise** anchor pins to the exact clicked spot. An **imprecise** one addresses the shape as a whole and renders its pin at a spot you choose, the `impreciseShapeAnchor`, which is the shape's top-right corner by default. Both track the shape as it moves and resizes, and both store the clicked `x`/`y`, so precision governs rendering rather than data.
6789
+
6790
+ Shape comments are precise by default. `shouldBePrecise` makes the call, and it receives the gesture: the target shape, the release point, and whether Alt was held.
6791
+
6792
+ ```tsx
6793
+ // Precise on notes, shape-level everywhere else
6794
+ const tools = [
6795
+ CommentTool.configure({
6796
+ shouldBePrecise: (editor, { shapeId }) => editor.getShape(shapeId)?.type === 'note',
6797
+ }),
6798
+ ]
6799
+ ```
6800
+
6801
+ Return `() => false` for shape-level anchoring throughout, or `(editor, { altKey }) => altKey` to make precision a per-placement choice the user holds Alt for. The predicate governs new placements only. Anchors already stored render the way they were made.
6802
+
6803
+ #### Comments and undo
6804
+
6805
+ Comment writes are not undoable by default. The `history` option governs all of them, including posting, replying, editing and resolving, and it defaults to `'ignore'`. It covers your own writes too: a record you write with `putCommentRecords` lands on the undo stack, or doesn't, exactly like one the built-in UI writes.
6806
+
6807
+ In a shared document an undoable delete resurrects a thread a collaborator already removed, and an undoable resolve reverts their newer state. `'record'` is safe single-player, or against a comment store that isn't synced.
6808
+
6809
+ Deleting is the one write `history` doesn't reach. It's never undoable, for a reason particular to how deletes work — see [Deleting is a soft delete](#deleting-is-a-soft-delete).
6810
+
6811
+ Pin drags are the exception worth configuring separately. Re-anchoring a comment is a spatial edit that may reasonably undo alongside the shape move that prompted it, so `dragHistory` overrides `history` for drags alone:
6812
+
6813
+ ```tsx
6814
+ CommentTool.configure({ dragHistory: 'record' })
6815
+ ```
6816
+
6817
+ The [comments and undo example](https://tldraw.dev/examples/collaboration/comment-history) lets you feel the difference: post a comment, press undo, and watch whether the thread count moves.
6818
+
6819
+ #### Who can comment
6820
+
6821
+ Left unset, `canComment` allows participation whenever `currentUserId` is set. Participation covers composing threads and replies, editing and deleting your own comments, resolving threads, and moving pins. Pass a callback to decide for yourself:
6822
+
6823
+ ```tsx
6824
+ CommentTool.configure({
6825
+ canComment: ({ currentUserId }) => currentUserId !== null && getRole() !== 'viewer',
6826
+ components: {
6827
+ ComposerFallback: ({ context }) => (context === 'thread' ? <SignInPrompt /> : null),
6828
+ },
6829
+ })
6830
+ ```
6831
+
6832
+ When `canComment` returns false, composers give way to the `ComposerFallback` slot and the action affordances hide. That slot's `context` says which surface is asking: the bottom of an open thread (`'thread'`), or the placement popover the tool opens (`'pending'`). Leave the slot unset and those surfaces render nothing.
6833
+
6834
+ `canComment` is read during render through `useCanComment`, so a callback that reads signals re-evaluates when they change.
6835
+
6836
+ #### Who can edit and delete
6837
+
6838
+ `canComment` is about the viewer; `canModifyComment` is about the viewer and one particular record. It's asked for the three writes that belong to someone in particular — editing a comment, deleting a comment, deleting a thread — and where it returns false, that affordance isn't rendered.
6839
+
6840
+ Left unset, each is its record's owner's to make: you edit and delete your own comments, and delete threads you started. Pass a callback to widen that, composing with `defaultCanModifyComment` so the owner keeps what they already had:
6841
+
6842
+ ```tsx
6843
+ import { CommentTool, defaultCanModifyComment } from '@tldraw/commenting'
6844
+
6845
+ CommentTool.configure({
6846
+ canModifyComment: (ctx) =>
6847
+ // Moderators may remove anything. Editing stays the author's, whoever you are.
6848
+ (ctx.action !== 'edit-comment' && isModerator(ctx.currentUserId)) ||
6849
+ defaultCanModifyComment(ctx),
6850
+ })
6851
+ ```
6852
+
6853
+ The `ctx` carries the editor, the viewer's `currentUserId`, and the write itself as a discriminated union: `{ action: 'edit-comment' | 'delete-comment', comment }` or `{ action: 'delete-thread', thread }`. Narrowing works too — return false to close edits after an hour, or on a resolved thread.
6854
+
6855
+ Resolving, reopening, reacting, and moving a pin aren't asked about: none of them is anyone's in particular, so `canComment` is the only gate on them. `canModifyComment` is checked after `canComment`, so a viewer who may not participate gets no action affordances whatever it returns. Like `canComment`, it's read during render (through `useCanModifyComment`), so a callback that reads signals re-evaluates when they change.
6856
+
6857
+ Whatever you decide here, decide it again on the server: `createCommentAuthorizers` takes a `canModifyComment` of its own, and it's the one that counts. See [enforcing the same rules on the server](#syncing-comments).
6858
+
6859
+ `canComment` and `canModifyComment` hide UI. They don't enforce anything. Comment records carry a
6860
+ client-supplied `createdBy` and `authorId`, and a client can write whatever your sync server
6861
+ accepts. Enforce permissions in your server's record authorization, and verify author ids against
6862
+ the session's authenticated identity. See [Syncing comments](#syncing-comments).
6863
+
6864
+ #### Mentions
6865
+
6866
+ Composers support `@`-mentions. Supply the roster with `getMentionSuggestions`, which can be synchronous or async:
6867
+
6868
+ ```tsx
6869
+ import { CanvasComments, filterMentionMembers, MentionMember } from '@tldraw/commenting'
6870
+
6871
+ const MEMBERS: MentionMember[] = [
6872
+ { id: 'me', name: 'You', color: '#EC5E41', you: true },
6873
+ { id: 'ada', name: 'Ada Lovelace', color: '#0E9F6E' },
6874
+ { id: 'grace', name: 'Grace Hopper', color: '#4465E9', secondary: 'grace@example.com' },
6875
+ ]
6876
+
6877
+ function Comments() {
6878
+ return (
6879
+ <CanvasComments
6880
+ currentUserId="me"
6881
+ resolveAuthor={resolveAuthor}
6882
+ getMentionSuggestions={(query) => filterMentionMembers(MEMBERS, query)}
6883
+ />
6884
+ )
6885
+ }
6886
+ ```
6887
+
6888
+ A `MentionMember` is a `CommentAuthor` plus an `id`, an optional `secondary` line for the picker, and `you` to mark the current user. `filterMentionMembers` does the matching; supply your own filter to query a server instead. To change how a row looks, pass `renderMentionSuggestion`.
6889
+
6890
+ A mention is a node in the body rather than text in it, so you can't find one by searching the string. To detect a mention, look for `{ type: 'mention', attrs: { id } }` in the body's `content` tree.
6891
+
6892
+ The mention components come from `@tldraw/mentions` and are re-exported here. That package also powers mentions in shape rich text. See [Rich text](https://tldraw.dev/sdk-features/rich-text).
6893
+
6894
+ #### Reactions
6895
+
6896
+ Hover a comment and open the picker to react with an emoji. A pill appears with a live count, and hovering it names who reacted.
6897
+
6898
+ Each reaction is its own `comment-reaction` record, one per (comment, user, emoji), rather than a field on the comment. Two people reacting at once therefore write different records and neither can clobber the other. The record id is derived from the triple, so re-picking an emoji toggles it. Registering `commentSchemaRecords` registers the reaction type along with the rest.
6899
+
6900
+ Reactions are multi-select by default, where each emoji toggles independently. Set `allowMultipleReactions: false` for single-select, where a new emoji replaces your existing one.
6901
+
6902
+ The palette is pluggable. A reaction's emoji is treated as an opaque token: the layer stores it, syncs it, and hands it to a renderer, and never assumes it's a glyph. So you can swap in tokens of your own.
6903
+
6904
+ ```tsx
6905
+ CommentTool.configure({
6906
+ components: {
6907
+ ReactionContent: MyTokenRenderer, // how a token is drawn
6908
+ ReactionPalette: MyPalette, // what the add-reaction button opens
6909
+ },
6910
+ isAllowedReaction: (token) => isMyToken(token) || isAllowedReactionEmoji(token),
6911
+ })
6912
+ ```
6913
+
6914
+ `isAllowedReaction` is enforced client-side. If arbitrary tokens would be a problem for you, validate them on your server too.
6915
+
6916
+ #### Region comments
6917
+
6918
+ A region thread covers a rectangular area rather than a point. Regions are off by default, so the tool stays click-only until you turn them on:
6919
+
6920
+ ```tsx
6921
+ CommentTool.configure({ enableRegions: true })
6922
+ ```
6923
+
6924
+ That's the whole configuration. A region reveals its dashed box and resize handles while the pointer is inside it, moves by its pin, and resizes from its corners. The pin sits on whichever corner the creating drag was released on, which the anchor remembers.
6925
+
6926
+ #### The sidebar
6927
+
6928
+ `CanvasCommentsSidebar` lists comment threads in a panel beside the canvas. Clicking a row brings that thread's pin into view and opens it.
6929
+
6930
+ Its open state is a signal you drive, so the toggle lives wherever your app wants it. `useCommentsSidebarOpen` reads it and `toggleCommentsSidebar` flips it; the underlying `commentsSidebarOpen` atom is exported too, for code that has an editor but no React context:
6931
+
6932
+ ```tsx
6933
+ import {
6934
+ CanvasComments,
6935
+ CanvasCommentsSidebar,
6936
+ CommentingContext,
6937
+ toggleCommentsSidebar,
6938
+ useCommentsSidebarOpen,
6939
+ } from '@tldraw/commenting'
6940
+ import { TldrawUiButton, TldrawUiButtonLabel, useEditor } from 'tldraw'
6941
+
6942
+ function SidebarToggle() {
6943
+ const editor = useEditor()
6944
+ const open = useCommentsSidebarOpen()
6945
+ return (
6946
+ <TldrawUiButton type="normal" onClick={() => toggleCommentsSidebar(editor)}>
6947
+ <TldrawUiButtonLabel>{open ? 'Hide comments' : 'Comments'}</TldrawUiButtonLabel>
6948
+ </TldrawUiButton>
6949
+ )
6950
+ }
6951
+
6952
+ // Both surfaces read the same context, so build it once and spread it into each.
6953
+ const commenting: CommentingContext = { currentUserId: 'me', resolveAuthor }
6954
+
6955
+ const components: TLComponents = {
6956
+ InFrontOfTheCanvas: () => (
6957
+ <>
6958
+ <CanvasComments {...commenting} />
6959
+ <CanvasCommentsSidebar {...commenting} />
6960
+ </>
6961
+ ),
6962
+ SharePanel: SidebarToggle,
6963
+ }
6964
+ ```
6965
+
6966
+ The sidebar's filters cover resolved threads, only comments you made, only unread comments, and only the current page. They're held per editor, so a user's choices survive the panel closing. Pass `header` and `empty` to replace the chrome around the list.
6967
+
6968
+ #### Unread state
6969
+
6970
+ The layer doesn't track who has read what. That data lives in your app. Supply it as two callbacks. `isCommentUnread` reports whether a comment is unread, and `onCommentRead` fires for each unread comment the user actually sees in an open thread.
6971
+
6972
+ ```tsx
6973
+ <CanvasComments
6974
+ currentUserId="me"
6975
+ resolveAuthor={resolveAuthor}
6976
+ isCommentUnread={(commentId) => !readReceipts.has(commentId)}
6977
+ onCommentRead={(commentId) => markCommentRead(commentId)}
6978
+ onPostComment={(comment) => notifyMentionedUsers(comment)}
6979
+ />
6980
+ ```
6981
+
6982
+ Unread state drives the pin badges and the sidebar's unread filter. Without `isCommentUnread`, both are hidden. `onPostComment` fires when this user posts a comment through one of the built-in composers — a new thread or a reply — which is where notifications and mention emails belong. It does not fire for comments arriving over sync, so the sender is the one who notifies.
6983
+
6984
+ #### Custom components
6985
+
6986
+ Every visible piece of the layer is a slot. Set them through the `components` option, and leave a slot unset to keep its default.
6987
+
6988
+ | Slot | Replaces |
6989
+ | ------------------ | ---------------------------------------------------------------------- |
6990
+ | `CommentBody` | A comment's body, normally the rich-text renderer. |
6991
+ | `PinContent` | A pin's inner content, normally the author's initial. |
6992
+ | `ThreadPreview` | A sidebar row's preview, normally the body as plain text. |
6993
+ | `ThreadRow` | A whole sidebar row, normally `CommentListItem`. |
6994
+ | `ThreadActions` | Adds controls to an open thread's header. Additive, not a replacement. |
6995
+ | `ComposerFallback` | What shows where a composer would sit when the viewer can't comment. |
6996
+ | `ReactionContent` | How a reaction token is drawn. |
6997
+ | `ReactionPalette` | What the add-reaction button opens. |
6998
+ | `ReactionTooltip` | The list of who reacted, shown on a reaction pill. |
6999
+
7000
+ ```tsx
7001
+ import { CommentTool, richTextToPlaintext } from '@tldraw/commenting'
7002
+ import { TLComment } from 'tldraw'
7003
+
7004
+ function PriorityBody({ comment }: { comment: TLComment }) {
7005
+ const urgent = comment.meta.priority === 'urgent'
7006
+ return (
7007
+ <div className={urgent ? 'urgent-comment' : 'comment'}>{richTextToPlaintext(comment.body)}</div>
7008
+ )
7009
+ }
7010
+
7011
+ const tools = [CommentTool.configure({ components: { CommentBody: PriorityBody } })]
7012
+ ```
7013
+
7014
+ Both record types carry a `meta` field the layer never reads. Use it for priorities, categories, external ticket ids, or anything else your app tracks alongside a comment.
7015
+
7016
+ `ThreadRow` and `ThreadActions` are the two slots that add to a surface rather than replace a piece of it, so they get the records behind what's on screen.
7017
+
7018
+ `ThreadRow` receives the summarized row along with the thread record, and the default row is exported — so a row that only adds something can spread the props into `CommentListItem` rather than start over:
7019
+
7020
+ ```tsx
7021
+ import { CommentListItem, CommentTool } from '@tldraw/commenting'
7022
+
7023
+ function StatusRow({ thread, ...row }: CommentListItemRenderProps & { thread: TLCommentThread }) {
7024
+ return (
7025
+ <div className="status-row">
7026
+ <CommentListItem {...row} />
7027
+ {thread.meta.status === 'blocked' && <span className="blocked">Blocked</span>}
7028
+ </div>
7029
+ )
7030
+ }
7031
+
7032
+ const tools = [CommentTool.configure({ components: { ThreadRow: StatusRow } })]
7033
+ ```
7034
+
7035
+ `ThreadActions` adds controls to an open thread's header, ahead of the built-in resolve and dismiss buttons. This is where host verbs go — assign a thread, link it to a ticket, mark it as a to-do. It adds alongside the built-in actions rather than replacing them, so a thread never loses the ability to be resolved.
7036
+
7037
+ ```tsx
7038
+ function AssignAction({ thread }: { thread: TLCommentThread }) {
7039
+ return (
7040
+ <button type="button" className="tlui-cmt-thread__action" onClick={() => assign(thread.id)}>
7041
+ Assign
7042
+ </button>
7043
+ )
7044
+ }
7045
+
7046
+ const tools = [CommentTool.configure({ components: { ThreadActions: AssignAction } })]
7047
+ ```
7048
+
7049
+ A link affordance doesn't need this slot: see [Linking to a thread](#linking-to-a-thread).
7050
+
7051
+ #### Working with comment records
7052
+
7053
+ Comment records aren't part of the `TLRecord` union, so `editor.store` doesn't know their types statically. These helpers own that reinterpretation and keep your call sites typed.
7054
+
7055
+ | Helper | Description |
7056
+ | ----------------------- | ------------------------------------------------------------------------------------ |
7057
+ | `getLiveCommentThreads` | The threads that render. |
7058
+ | `getLiveComments` | The comments that render. |
7059
+ | `getCommentThreads` | Every thread in the store, including deleted ones. |
7060
+ | `getComments` | Every comment in the store, including deleted ones. |
7061
+ | `getCommentRecord` | One record by id, or `undefined`. |
7062
+ | `putCommentRecords` | Write threads and comments. |
7063
+ | `removeCommentRecords` | Remove them by id. Rarely what you want — see [Writing comments](#writing-comments). |
7064
+
7065
+ Prefer the live reads. Deleting a comment doesn't remove its record — it flags it and lets the server prune it, as [Writing comments](#writing-comments) explains — so the unfiltered reads include records that nothing renders. `getLiveCommentThreads` also drops threads whose comments have all gone, which have no surface left.
7066
+
7067
+ Those reads are non-reactive. In React use the hooks instead, which read the live set: `useCommentThreads`, `useComments`, and `useThreadComments` for one thread's replies, oldest first.
7068
+
7069
+ ```tsx
7070
+ import { useCommentThreads } from '@tldraw/commenting'
7071
+ import { useEditor } from 'tldraw'
7072
+
7073
+ function OpenThreadCount() {
7074
+ const editor = useEditor()
7075
+ const threads = useCommentThreads(editor)
7076
+ const open = threads.filter((thread) => !thread.resolved)
7077
+ return <div>{open.length} open threads</div>
7078
+ }
7079
+ ```
7080
+
7081
+ #### Writing comments
7082
+
7083
+ To post a thread yourself, whether you're seeding a document with review notes or importing comments from another system, build the records and write them:
7084
+
7085
+ ```tsx
7086
+ import { putCommentRecords } from '@tldraw/commenting'
7087
+ import { createComment, createCommentThread, toRichText } from 'tldraw'
7088
+
7089
+ const thread = createCommentThread({
7090
+ pageId: editor.getCurrentPageId(),
7091
+ anchor: { type: 'point', x: 120, y: 240 },
7092
+ createdBy: 'ada',
7093
+ })
7094
+
7095
+ const comment = createComment({
7096
+ threadId: thread.id,
7097
+ pageId: thread.pageId,
7098
+ authorId: 'ada',
7099
+ body: toRichText('Can we make this arrow dashed?'),
7100
+ })
7101
+
7102
+ putCommentRecords(editor, [thread, comment])
7103
+ ```
7104
+
7105
+ The other writes each have a rule attached — a timestamp to stamp, or the delete protocol below — so they come as functions. The built-in thread view calls exactly these, so a UI of your own behaves like the one in the box.
7106
+
7107
+ | Helper | Description |
7108
+ | ------------------------------- | ------------------------------------------------------ |
7109
+ | `editComment` | Replace a comment's body and mark it edited. |
7110
+ | `resolveThread`, `reopenThread` | Resolve a thread, stamping who and when, or reopen it. |
7111
+ | `deleteComment` | Delete a comment. |
7112
+ | `deleteThread` | Delete a thread and its whole conversation. |
7113
+
7114
+ ```tsx
7115
+ import { deleteComment, resolveThread } from '@tldraw/commenting'
7116
+
7117
+ resolveThread(editor, thread, currentUserId)
7118
+ deleteComment(editor, comment)
7119
+ ```
7120
+
7121
+ The record you pass says which comment or thread to act on; the change itself lands on the version currently in the store. So a record you took a copy of earlier is safe to pass: it won't put back a field that has moved since — a thread's anchor changes on its own as pinned shapes move, and a comment's body changes when its author edits it elsewhere — and it won't re-create a record that has already been deleted, which a plain `putCommentRecords` of a stale copy would. If the record is gone, the call does nothing.
7122
+
7123
+ ##### Deleting is a soft delete
7124
+
7125
+ `deleteComment` and `deleteThread` don't remove records. They set an `isDeleted` flag and leave the pruning to the server, which then removes the thread, its comments, and their reactions.
7126
+
7127
+ That indirection is what makes deleting safe in a shared document. A reaction belongs to whoever left it, not to whoever is deleting the comment underneath it, so no client should be removing it — and a server enforcing per-record permissions gets a write it can check against the session's identity rather than a deletion it can only refuse. `removeCommentRecords` is a hard delete, and against such a server it will simply be rejected. Reach for it on a local, unsynced comment store.
7128
+
7129
+ The flag is write-once server-side, so deletes are never undoable, whatever `history` says: an undo clearing the flag would be vetoed and rebased rather than bring the comment back.
7130
+
7131
+ Deleting a thread's last comment leaves the thread with nothing to render. The record stays for the server to prune, since whoever deleted the comment may not be the thread's creator.
7132
+
7133
+ #### Revealing a thread
7134
+
7135
+ To open a thread from outside the canvas, whether from a notification, a shared link, or a list of your own, call `revealThread` with a thread or comment id. `CanvasComments` serves the request: it waits for the records to arrive, switches pages if it needs to, zooms in if the pin is inside a cluster, then opens the thread.
7136
+
7137
+ ```tsx
7138
+ import { revealThread } from '@tldraw/commenting'
7139
+
7140
+ // e.g. from a ?comment=<id> search param
7141
+ revealThread(editor, commentId)
7142
+ ```
7143
+
7144
+ An unserved request is inert, so it's safe to call before the records have synced in. `useRevealThreadPending` returns the id of a request that hasn't been served yet, which is how you notice a deep link to a comment that no longer exists — give it a grace period first, since a request also sits there while its records are still arriving, and re-check with `getRevealThreadPending(editor)` when the grace period elapses so you don't act on a request that cleared inside it.
7145
+
7146
+ For a thread you already hold, `focusThread(editor, thread)` centers and opens it directly.
7147
+
7148
+ #### Linking to a thread
7149
+
7150
+ The other half of a deep link is producing one. Give the commenting context a `getThreadHref` and every surface that can link to a thread does:
7151
+
7152
+ ```tsx
7153
+ <CanvasComments
7154
+ currentUserId="me"
7155
+ resolveAuthor={resolveAuthor}
7156
+ getThreadHref={(threadId) => `/file/${fileId}?comment=${encodeURIComponent(threadId)}`}
7157
+ />
7158
+ ```
7159
+
7160
+ Sidebar rows become anchors, so ctrl/cmd-click and middle-click open a thread in a new tab, and an open thread's header menu offers **Copy link**. A relative href is resolved against the current document before it reaches the clipboard, so what gets pasted is a whole URL.
7161
+
7162
+ Without `getThreadHref` neither appears — a link the host can't construct isn't one the layer can invent.
7163
+
7164
+ #### Pin clustering
7165
+
7166
+ Zoom out far enough and pins pile on top of each other. Clustering folds nearby pins into a count badge as you zoom out, then splits them apart as you zoom back in. Splits happen at a wider spacing than merges, so pins don't flicker at the boundary. Clicking a badge zooms to just past the point where that cluster breaks up.
7167
+
7168
+ Clustering is on by default. Turn it off with `enableClustering: false`.
7169
+
7170
+ The work is precomputed once per comment add, remove, or move, so per-frame camera changes cost a single walk over a sorted event table. See the [comment clustering example](https://tldraw.dev/examples/collaboration/comment-clustering).
7171
+
7172
+ #### Syncing comments
7173
+
7174
+ Comment records don't ship in the default schema, so both ends of the connection have to register them, and they have to match. A client with the types talking to a server without them will fail schema validation.
7175
+
7176
+ On the client, pass `records` to the sync hook:
7177
+
7178
+ ```tsx
7179
+ const store = useSync({
7180
+ uri: `wss://your-server.com/sync/${roomId}`,
7181
+ assets: myAssetStore,
7182
+ records: commentSchemaRecords,
7183
+ })
7184
+ ```
7185
+
7186
+ On the server, pass the same map to `createTLSchema`:
7187
+
7188
+ ```ts
7189
+ import { TLSocketRoom } from '@tldraw/sync-core'
7190
+ import { commentSchemaRecords, createTLSchema } from '@tldraw/tlschema'
7191
+
7192
+ const schema = createTLSchema({ records: commentSchemaRecords })
7193
+
7194
+ const room = new TLSocketRoom({
7195
+ schema,
7196
+ // Serve comments through the object-store lane rather than the document
7197
+ objectTypes: ['comment', 'comment-thread', 'comment-reaction'],
7198
+ })
7199
+ ```
7200
+
7201
+ `objectTypes` moves those record types onto a separate lane. Lane records are stored apart from the document and left out of document snapshots. More usefully, they're gated by their own per-session permission rather than by `isReadonly`, so a session can be allowed to comment without being allowed to edit:
7202
+
7203
+ ```ts
7204
+ room.handleSocketConnect({
7205
+ sessionId,
7206
+ socket,
7207
+ isReadonly: true, // can't touch the document
7208
+ objectAccess: 'write', // but can still comment
7209
+ })
7210
+ ```
7211
+
7212
+ `objectAccess` is `'read'` or `'write'` and defaults to `'write'`. To persist the lane separately, read it with `TLSocketRoom`'s `getCurrentObjectsSnapshot()`. To mirror comments into your own database as they commit, use the room's `onCommittedChanges` callback:
7213
+
7214
+ ```ts
7215
+ const room = new TLSocketRoom({
7216
+ schema,
7217
+ objectTypes: ['comment', 'comment-thread', 'comment-reaction'],
7218
+ onCommittedChanges({ diff }) {
7219
+ // Project comment records into Postgres for notifications and search
7220
+ projectComments(diff)
7221
+ },
7222
+ })
7223
+ ```
7224
+
7225
+ `onCommittedChanges` only fires for client pushes. Server-initiated writes don't trigger it, including `updateStore`, `loadSnapshot`, and writing to storage directly, so anything mirroring room state has to handle those paths itself.
7226
+
7227
+ This is also where per-record permissions belong. The lane's write access is all or nothing, so rules like "only the author may edit a comment" or "only the thread's creator may delete it" are enforced as your server validates each incoming record against the session's identity. `createCommentAuthorizers` is that validation, ready-made — pass it to the room's `authorizeRecord`:
7228
+
7229
+ ```ts
7230
+ import { createCommentAuthorizers } from '@tldraw/sync-collaboration'
7231
+
7232
+ const room = new TLSocketRoom({
7233
+ schema,
7234
+ objectTypes: ['comment', 'comment-thread', 'comment-reaction'],
7235
+ authorizeRecord: {
7236
+ ...createCommentAuthorizers<SessionMeta>({
7237
+ getUserId: (session) => session.meta.userId,
7238
+ // Moderators may take anything down. Editing stays the author's, whoever you are.
7239
+ canModifyComment: (ctx) =>
7240
+ (ctx.action !== 'edit-comment' && isModerator(ctx.session.meta)) ||
7241
+ ctx.userId === ctx.ownerId,
7242
+ }),
7243
+ },
7244
+ })
7245
+ ```
7246
+
7247
+ It stamps authorship from the session so nothing can be posted or resolved in someone else's name, keeps `isDeleted` write-once, and — through `canModifyComment` — decides who may edit a comment, delete a comment, or delete a thread. That last one is the same question the client's [`canModifyComment`](#who-can-edit-and-delete) answers, and this is the answer that counts: the client's only decides which affordances the UI offers. Widen the two together. A moderator offered a delete the server then rejects sees the comment disappear and come back, with nothing to explain it.
7248
+
7249
+ Left unset it defaults to the record's owner, matching the client. The callback is asked after the structural rules, so widening it grants those three writes and nothing else: attribution stays immutable, a soft delete stays write-once, and clients still can't hard-delete a record.
7250
+
7251
+ #### Building your own comments UI
7252
+
7253
+ `CanvasComments` is one way to assemble the parts, and every part it uses is exported. You can rebuild it, or build something quite different, from the same pieces.
7254
+
7255
+ | Export | Description |
7256
+ | ---------------------------------------- | ----------------------------------------------------------- |
7257
+ | `CommentTool`, `commentToolOverrides` | Placement: the tool state machine and its toolbar entry. |
7258
+ | `CommentPin`, `CountBadge` | The pin marker and the clustered-count badge. |
7259
+ | `CommentThread`, `CommentCard`, `Byline` | A thread and its comments. |
7260
+ | `CommentComposer`, `SendButton` | The composer and its send control. |
7261
+ | `CommentsList`, `CommentListItem` | A list of threads and one row of it. |
7262
+ | `EmptyState`, `sortSidebarRows` | The list's empty state, and the sidebar's ordering. |
7263
+ | `Avatar`, `Mention`, `MentionList` | Author avatars and the mention picker. |
7264
+ | `Reaction`, `Reactions` | A reaction pill and a comment's row of them. |
7265
+ | `anchorPagePoint`, `shapeAnchorAt` | Anchor math: page position from an anchor, and the reverse. |
7266
+ | `editComment`, `deleteComment` | The write verbs. See [Writing comments](#writing-comments). |
7267
+ | `registerCommentAnchorLifecycle` | Keeps shape-anchored threads alive across shape deletion. |
7268
+
7269
+ The presentational components take plain props and know nothing about the editor. The canvas layer, the tool, and the hooks build on them. `CanvasComments` registers the anchor lifecycle for you, so call `registerCommentAnchorLifecycle` yourself only if you're replacing the layer wholesale.
7270
+
7271
+ #### All options
7272
+
7273
+ Every option below is set with `CommentTool.configure()`. Anything you leave unset falls back to `defaultCommentingOptions`.
7274
+
7275
+ ##### History
7276
+
7277
+ | Option | Default | Description |
7278
+ | ------------- | ----------- | ----------------------------------------------------------------------- |
7279
+ | `history` | `'ignore'` | How comment writes interact with the undo stack. |
7280
+ | `dragHistory` | `undefined` | History mode for pin drags specifically. Unset, drags follow `history`. |
7281
+
7282
+ ##### Anchoring
7283
+
7284
+ | Option | Default | Description |
7285
+ | ---------------------- | -------------- | --------------------------------------------------------- |
7286
+ | `shouldBePrecise` | `() => true` | Whether a shape placement anchors precisely. |
7287
+ | `impreciseShapeAnchor` | `{x: 1, y: 0}` | Where imprecise shape pins sit within the shape's bounds. |
7288
+
7289
+ ##### Permissions
7290
+
7291
+ | Option | Default | Description |
7292
+ | ------------------ | ----------- | ----------------------------------------------------------------------------------- |
7293
+ | `canComment` | `undefined` | Whether the viewer may participate. Unset, allowed whenever `currentUserId` is set. |
7294
+ | `canModifyComment` | `undefined` | Whether the viewer may edit or delete a record. Unset, each is its owner's to make. |
7295
+
7296
+ ##### Reactions
7297
+
7298
+ | Option | Default | Description |
7299
+ | ------------------------ | ------------- | ------------------------------------------------------------ |
7300
+ | `allowMultipleReactions` | `true` | Whether a user can hold more than one reaction on a comment. |
7301
+ | `isAllowedReaction` | emoji palette | Which reaction tokens may be written. |
7302
+
7303
+ ##### Regions
7304
+
7305
+ | Option | Default | Description |
7306
+ | --------------- | ------- | ---------------------------------------------------------- |
7307
+ | `enableRegions` | `false` | Whether dragging the comment tool creates a region anchor. |
7308
+
7309
+ ##### Clustering
7310
+
7311
+ | Option | Default | Description |
7312
+ | ------------------ | ------- | ----------------------------------------------------------- |
7313
+ | `enableClustering` | `true` | Fold nearby pins into count badges as the camera zooms out. |
7314
+
7315
+ ##### Components
7316
+
7317
+ | Option | Default | Description |
7318
+ | ------------ | ------- | ----------------------------------------------------------------- |
7319
+ | `components` | `{}` | Component overrides. See [Custom components](#custom-components). |
7320
+
7321
+ #### Related articles
7322
+
7323
+ - [Commenting](https://tldraw.dev/docs/commenting) — A shorter introduction to the feature
7324
+ - [Collaboration](https://tldraw.dev/sdk-features/collaboration) — Presence, sync hooks, and custom sync
7325
+ - [tldraw sync](https://tldraw.dev/docs/sync) — Running a sync server
7326
+ - [Rich text](https://tldraw.dev/sdk-features/rich-text) — The `TLRichText` format comment bodies use
7327
+ - [License key](https://tldraw.dev/sdk-features/license-key) — Enabling licensed features in production
7328
+
7329
+ #### Related examples
7330
+
7331
+ - [Commenting](https://tldraw.dev/examples/collaboration/commenting) — The full flow: tool, pins, threads, mentions
7332
+ - [Commenting sidebar](https://tldraw.dev/examples/collaboration/commenting-sidebar) — A thread list panel beside the canvas
7333
+ - [Comment clustering](https://tldraw.dev/examples/collaboration/comment-clustering) — Merging pins into count badges as you zoom out
7334
+ - [Comment anchors](https://tldraw.dev/examples/collaboration/comment-anchors) — The ways a comment can attach to the canvas
7335
+ - [Shape comment precision](https://tldraw.dev/examples/collaboration/comment-shape-precision) — Precise, shape-level, and Alt-gated anchoring
7336
+ - [Comments and undo](https://tldraw.dev/examples/collaboration/comment-history) — How comment writes interact with the undo stack
7337
+ - [Region comments](https://tldraw.dev/examples/collaboration/comment-regions) — Commenting on an area, and tuning the interaction
7338
+
6340
7339
  ### Coordinates
6341
7340
 
6342
7341
  The editor uses three coordinate systems: screen space, viewport space, and page space. When you move the mouse over the canvas, the browser gives you screen coordinates. To create a shape at that location, you need to convert those coordinates to page space.
@@ -6746,7 +7745,7 @@ export default function App() {
6746
7745
  }
6747
7746
  ```
6748
7747
 
6749
- Remote users' chat messages render as part of the collaborator cursor overlay. To customize how they appear, replace `CollaboratorCursorOverlayUtil` — see [Cursors](https://tldraw.dev/sdk-features/cursors#rendering-collaborator-cursors) for details.
7748
+ Remote users' chat messages render as part of the collaborator cursor, in the DOM cursor layer. To customize how they appear, replace the `CollaboratorCursor` component — see [Cursors](https://tldraw.dev/sdk-features/cursors#rendering-collaborator-cursors) for details.
6750
7749
 
6751
7750
  #### Availability
6752
7751
 
@@ -6916,48 +7915,50 @@ editor.user.updateUserPreferences({ color: '#FF802B' })
6916
7915
 
6917
7916
  ##### Rendering collaborator cursors
6918
7917
 
6919
- Remote cursors render through `CollaboratorCursorOverlayUtil`, one of the default [overlay utils](https://tldraw.dev/sdk-features/overlay-utils). It draws each collaborator's cursor directly to the canvas:
7918
+ Remote cursors render as DOM elements in a dedicated layer stacked above the canvas and below the UI panels. Each visible collaborator gets a cursor showing:
6920
7919
 
6921
7920
  - The cursor arrow in the user's color
6922
7921
  - The user's name as a label next to the cursor
6923
7922
  - Any active chat message in a bubble
6924
7923
 
6925
- To customize the rendering, pass your own overlay util to the `overlayUtils` prop. An overlay util with the same static `type` replaces the default one:
7924
+ A collaborator whose cursor is outside your viewport shows as a small hint arrow clamped to the viewport edge, pointing toward them the hints stay canvas-drawn (`CollaboratorHintOverlayUtil`), like the other drawing chrome.
7925
+
7926
+ To customize the cursor, pass your own component via the `components` prop's `CollaboratorCursor` slot (see `DefaultCursor` and `TLCursorProps`):
6926
7927
 
6927
7928
  ```tsx
6928
- import { CollaboratorCursorOverlayUtil, TLCollaboratorCursorOverlay, Tldraw } from 'tldraw'
7929
+ import { TLCursorProps, Tldraw } from 'tldraw'
6929
7930
  import 'tldraw/tldraw.css'
6930
7931
 
6931
- class CustomCollaboratorCursorOverlayUtil extends CollaboratorCursorOverlayUtil {
6932
- override render(ctx: CanvasRenderingContext2D, overlays: TLCollaboratorCursorOverlay[]) {
6933
- const zoom = this.editor.getZoomLevel()
6934
- for (const overlay of overlays) {
6935
- const { x, y, color, name } = overlay.props
6936
-
6937
- // Draw a dot instead of the default arrow
6938
- ctx.beginPath()
6939
- ctx.arc(x, y, 8 / zoom, 0, Math.PI * 2)
6940
- ctx.fillStyle = color
6941
- ctx.fill()
6942
-
6943
- if (name) {
6944
- ctx.font = `${12 / zoom}px sans-serif`
6945
- ctx.fillText(name, x + 12 / zoom, y + 4 / zoom)
6946
- }
6947
- }
6948
- }
7932
+ function CustomCollaboratorCursor({ point, color, name, zoom }: TLCursorProps) {
7933
+ if (!point) return null
7934
+ return (
7935
+ <div
7936
+ style={{
7937
+ position: 'absolute',
7938
+ // The layer is scaled by the camera, so counter-scale by 1 / zoom to keep the
7939
+ // cursor a constant on-screen size (the default components do the same).
7940
+ transform: `translate(${point.x}px, ${point.y}px) scale(${1 / zoom})`,
7941
+ transformOrigin: 'top left',
7942
+ pointerEvents: 'none',
7943
+ }}
7944
+ >
7945
+ {/* A dot instead of the default arrow */}
7946
+ <div style={{ width: 16, height: 16, borderRadius: '50%', backgroundColor: color }} />
7947
+ {name && <div style={{ color }}>{name}</div>}
7948
+ </div>
7949
+ )
6949
7950
  }
6950
7951
 
6951
7952
  export default function App() {
6952
7953
  return (
6953
7954
  <div style={{ position: 'fixed', inset: 0 }}>
6954
- <Tldraw overlayUtils={[CustomCollaboratorCursorOverlayUtil]} />
7955
+ <Tldraw components={{ CollaboratorCursor: CustomCollaboratorCursor }} />
6955
7956
  </div>
6956
7957
  )
6957
7958
  }
6958
7959
  ```
6959
7960
 
6960
- The render context is transformed to page space, so divide screen-pixel sizes by the zoom level to keep them constant on screen. See [Overlay utils](https://tldraw.dev/sdk-features/overlay-utils) for the full overlay API.
7961
+ Cursor components are positioned in page space inside a camera-transformed layer, so `point` can be used directly as a translation; sizes in screen pixels stay constant on screen by scaling with `1 / zoom` (the default components do this via their `zoom` prop).
6961
7962
 
6962
7963
  ##### Cursor position in presence
6963
7964
 
@@ -13405,10 +14406,9 @@ The `tldraw` package includes these overlay utils by default:
13405
14406
  | `ScribbleOverlayUtil` | Eraser and lasso scribbles |
13406
14407
  | `ArrowHintOverlayUtil` | Target hints while drawing arrows |
13407
14408
  | `ArrowBindingHintOverlayUtil` | Binding hints when dragging arrow handles |
13408
- | `CollaboratorCursorOverlayUtil` | Remote users' cursors, name tags, and chat |
13409
14409
  | `CollaboratorBrushOverlayUtil` | Remote users' brush rectangles |
13410
14410
  | `CollaboratorScribbleOverlayUtil` | Remote users' scribbles |
13411
- | `CollaboratorHintOverlayUtil` | Remote users' viewport hints |
14411
+ | `CollaboratorHintOverlayUtil` | Remote users' viewport-edge hint arrows |
13412
14412
  | `CollaboratorShapeIndicatorOverlayUtil` | Remote users' selection indicators |
13413
14413
 
13414
14414
  These are exported as `defaultOverlayUtils` from `tldraw`.
@@ -16079,6 +17079,8 @@ Shapes support parent-child hierarchies for grouping and frames, participate in
16079
17079
 
16080
17080
  A shape record is a plain object stored in the editor's reactive store. All shape types extend `TLBaseShape`, which defines the common properties every shape has: a unique identifier, position and rotation, z-ordering index, parent reference, lock state, opacity, and a `props` field for shape-specific properties. The `props` field contains data unique to each shape type. A geo shape stores its width, height, and geometry type. A text shape stores its text content and font size. Each shape type defines its own props structure.
16081
17081
 
17082
+ Shapes also have a `meta` field for your own application data, which tldraw stores but doesn't use itself. See [Meta](https://tldraw.dev/docs/shapes#Meta) for how to set, type, and validate it.
17083
+
16082
17084
  ##### Shape types in tldraw
16083
17085
 
16084
17086
  The default tldraw installation includes these shape types: