@tldraw/commenting 0.0.0-bootstrap
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.
- package/README.md +11 -0
- package/commenting.css +832 -0
- package/dist-cjs/canvas/cluster-input.js +40 -0
- package/dist-cjs/canvas/cluster-input.js.map +7 -0
- package/dist-cjs/canvas/comment-body.js +31 -0
- package/dist-cjs/canvas/comment-body.js.map +7 -0
- package/dist-cjs/canvas/comment-render.js +72 -0
- package/dist-cjs/canvas/comment-render.js.map +7 -0
- package/dist-cjs/canvas/comment-store.js +48 -0
- package/dist-cjs/canvas/comment-store.js.map +7 -0
- package/dist-cjs/canvas/comment-tool.js +160 -0
- package/dist-cjs/canvas/comment-tool.js.map +7 -0
- package/dist-cjs/canvas/comments-filter-menu.js +109 -0
- package/dist-cjs/canvas/comments-filter-menu.js.map +7 -0
- package/dist-cjs/canvas/comments-menu-item.js +42 -0
- package/dist-cjs/canvas/comments-menu-item.js.map +7 -0
- package/dist-cjs/canvas/comments-overflow-menu.js +64 -0
- package/dist-cjs/canvas/comments-overflow-menu.js.map +7 -0
- package/dist-cjs/canvas/comments-overlay.js +1081 -0
- package/dist-cjs/canvas/comments-overlay.js.map +7 -0
- package/dist-cjs/canvas/comments-sidebar.js +134 -0
- package/dist-cjs/canvas/comments-sidebar.js.map +7 -0
- package/dist-cjs/canvas/hooks.js +45 -0
- package/dist-cjs/canvas/hooks.js.map +7 -0
- package/dist-cjs/canvas/license.js +28 -0
- package/dist-cjs/canvas/license.js.map +7 -0
- package/dist-cjs/canvas/options.js +45 -0
- package/dist-cjs/canvas/options.js.map +7 -0
- package/dist-cjs/canvas/region-options.js +49 -0
- package/dist-cjs/canvas/region-options.js.map +7 -0
- package/dist-cjs/canvas/rich-text.js +28 -0
- package/dist-cjs/canvas/rich-text.js.map +7 -0
- package/dist-cjs/canvas/sidebar-filters.js +30 -0
- package/dist-cjs/canvas/sidebar-filters.js.map +7 -0
- package/dist-cjs/canvas/state.js +76 -0
- package/dist-cjs/canvas/state.js.map +7 -0
- package/dist-cjs/canvas/thread-state.js +81 -0
- package/dist-cjs/canvas/thread-state.js.map +7 -0
- package/dist-cjs/clustering/computeClusterTable.js +83 -0
- package/dist-cjs/clustering/computeClusterTable.js.map +7 -0
- package/dist-cjs/clustering/mst.js +117 -0
- package/dist-cjs/clustering/mst.js.map +7 -0
- package/dist-cjs/clustering/replay.js +275 -0
- package/dist-cjs/clustering/replay.js.map +7 -0
- package/dist-cjs/clustering/runtime.js +134 -0
- package/dist-cjs/clustering/runtime.js.map +7 -0
- package/dist-cjs/clustering/schedule.js +170 -0
- package/dist-cjs/clustering/schedule.js.map +7 -0
- package/dist-cjs/clustering/types.js +17 -0
- package/dist-cjs/clustering/types.js.map +7 -0
- package/dist-cjs/index.d.ts +868 -0
- package/dist-cjs/index.js +126 -0
- package/dist-cjs/index.js.map +7 -0
- package/dist-cjs/ui/avatar.js +43 -0
- package/dist-cjs/ui/avatar.js.map +7 -0
- package/dist-cjs/ui/byline.js +35 -0
- package/dist-cjs/ui/byline.js.map +7 -0
- package/dist-cjs/ui/comment-card.js +37 -0
- package/dist-cjs/ui/comment-card.js.map +7 -0
- package/dist-cjs/ui/comment-composer.js +136 -0
- package/dist-cjs/ui/comment-composer.js.map +7 -0
- package/dist-cjs/ui/comment-extensions.js +37 -0
- package/dist-cjs/ui/comment-extensions.js.map +7 -0
- package/dist-cjs/ui/comment-mention.js +42 -0
- package/dist-cjs/ui/comment-mention.js.map +7 -0
- package/dist-cjs/ui/comment-pin.js +29 -0
- package/dist-cjs/ui/comment-pin.js.map +7 -0
- package/dist-cjs/ui/comment-text.js +29 -0
- package/dist-cjs/ui/comment-text.js.map +7 -0
- package/dist-cjs/ui/comment-thread.js +45 -0
- package/dist-cjs/ui/comment-thread.js.map +7 -0
- package/dist-cjs/ui/comments-list.js +106 -0
- package/dist-cjs/ui/comments-list.js.map +7 -0
- package/dist-cjs/ui/count-badge.js +28 -0
- package/dist-cjs/ui/count-badge.js.map +7 -0
- package/dist-cjs/ui/empty-state.js +31 -0
- package/dist-cjs/ui/empty-state.js.map +7 -0
- package/dist-cjs/ui/format-time.js +46 -0
- package/dist-cjs/ui/format-time.js.map +7 -0
- package/dist-cjs/ui/mention-list.js +70 -0
- package/dist-cjs/ui/mention-list.js.map +7 -0
- package/dist-cjs/ui/mention-suggestion.js +206 -0
- package/dist-cjs/ui/mention-suggestion.js.map +7 -0
- package/dist-cjs/ui/mention.js +31 -0
- package/dist-cjs/ui/mention.js.map +7 -0
- package/dist-cjs/ui/reaction.js +31 -0
- package/dist-cjs/ui/reaction.js.map +7 -0
- package/dist-cjs/ui/reactions.js +34 -0
- package/dist-cjs/ui/reactions.js.map +7 -0
- package/dist-cjs/ui/render-markdown.js +63 -0
- package/dist-cjs/ui/render-markdown.js.map +7 -0
- package/dist-cjs/ui/send-button.js +28 -0
- package/dist-cjs/ui/send-button.js.map +7 -0
- package/dist-esm/canvas/cluster-input.mjs +20 -0
- package/dist-esm/canvas/cluster-input.mjs.map +7 -0
- package/dist-esm/canvas/comment-body.mjs +11 -0
- package/dist-esm/canvas/comment-body.mjs.map +7 -0
- package/dist-esm/canvas/comment-render.mjs +52 -0
- package/dist-esm/canvas/comment-render.mjs.map +7 -0
- package/dist-esm/canvas/comment-store.mjs +28 -0
- package/dist-esm/canvas/comment-store.mjs.map +7 -0
- package/dist-esm/canvas/comment-tool.mjs +142 -0
- package/dist-esm/canvas/comment-tool.mjs.map +7 -0
- package/dist-esm/canvas/comments-filter-menu.mjs +99 -0
- package/dist-esm/canvas/comments-filter-menu.mjs.map +7 -0
- package/dist-esm/canvas/comments-menu-item.mjs +22 -0
- package/dist-esm/canvas/comments-menu-item.mjs.map +7 -0
- package/dist-esm/canvas/comments-overflow-menu.mjs +53 -0
- package/dist-esm/canvas/comments-overflow-menu.mjs.map +7 -0
- package/dist-esm/canvas/comments-overlay.mjs +1094 -0
- package/dist-esm/canvas/comments-overlay.mjs.map +7 -0
- package/dist-esm/canvas/comments-sidebar.mjs +120 -0
- package/dist-esm/canvas/comments-sidebar.mjs.map +7 -0
- package/dist-esm/canvas/hooks.mjs +25 -0
- package/dist-esm/canvas/hooks.mjs.map +7 -0
- package/dist-esm/canvas/license.mjs +8 -0
- package/dist-esm/canvas/license.mjs.map +7 -0
- package/dist-esm/canvas/options.mjs +27 -0
- package/dist-esm/canvas/options.mjs.map +7 -0
- package/dist-esm/canvas/region-options.mjs +29 -0
- package/dist-esm/canvas/region-options.mjs.map +7 -0
- package/dist-esm/canvas/rich-text.mjs +8 -0
- package/dist-esm/canvas/rich-text.mjs.map +7 -0
- package/dist-esm/canvas/sidebar-filters.mjs +10 -0
- package/dist-esm/canvas/sidebar-filters.mjs.map +7 -0
- package/dist-esm/canvas/state.mjs +60 -0
- package/dist-esm/canvas/state.mjs.map +7 -0
- package/dist-esm/canvas/thread-state.mjs +61 -0
- package/dist-esm/canvas/thread-state.mjs.map +7 -0
- package/dist-esm/clustering/computeClusterTable.mjs +63 -0
- package/dist-esm/clustering/computeClusterTable.mjs.map +7 -0
- package/dist-esm/clustering/mst.mjs +97 -0
- package/dist-esm/clustering/mst.mjs.map +7 -0
- package/dist-esm/clustering/replay.mjs +255 -0
- package/dist-esm/clustering/replay.mjs.map +7 -0
- package/dist-esm/clustering/runtime.mjs +114 -0
- package/dist-esm/clustering/runtime.mjs.map +7 -0
- package/dist-esm/clustering/schedule.mjs +150 -0
- package/dist-esm/clustering/schedule.mjs.map +7 -0
- package/dist-esm/clustering/types.mjs +1 -0
- package/dist-esm/clustering/types.mjs.map +7 -0
- package/dist-esm/index.d.mts +868 -0
- package/dist-esm/index.mjs +139 -0
- package/dist-esm/index.mjs.map +7 -0
- package/dist-esm/ui/avatar.mjs +23 -0
- package/dist-esm/ui/avatar.mjs.map +7 -0
- package/dist-esm/ui/byline.mjs +15 -0
- package/dist-esm/ui/byline.mjs.map +7 -0
- package/dist-esm/ui/comment-card.mjs +17 -0
- package/dist-esm/ui/comment-card.mjs.map +7 -0
- package/dist-esm/ui/comment-composer.mjs +116 -0
- package/dist-esm/ui/comment-composer.mjs.map +7 -0
- package/dist-esm/ui/comment-extensions.mjs +17 -0
- package/dist-esm/ui/comment-extensions.mjs.map +7 -0
- package/dist-esm/ui/comment-mention.mjs +22 -0
- package/dist-esm/ui/comment-mention.mjs.map +7 -0
- package/dist-esm/ui/comment-pin.mjs +9 -0
- package/dist-esm/ui/comment-pin.mjs.map +7 -0
- package/dist-esm/ui/comment-text.mjs +9 -0
- package/dist-esm/ui/comment-text.mjs.map +7 -0
- package/dist-esm/ui/comment-thread.mjs +25 -0
- package/dist-esm/ui/comment-thread.mjs.map +7 -0
- package/dist-esm/ui/comments-list.mjs +86 -0
- package/dist-esm/ui/comments-list.mjs.map +7 -0
- package/dist-esm/ui/count-badge.mjs +8 -0
- package/dist-esm/ui/count-badge.mjs.map +7 -0
- package/dist-esm/ui/empty-state.mjs +11 -0
- package/dist-esm/ui/empty-state.mjs.map +7 -0
- package/dist-esm/ui/format-time.mjs +26 -0
- package/dist-esm/ui/format-time.mjs.map +7 -0
- package/dist-esm/ui/mention-list.mjs +50 -0
- package/dist-esm/ui/mention-list.mjs.map +7 -0
- package/dist-esm/ui/mention-suggestion.mjs +186 -0
- package/dist-esm/ui/mention-suggestion.mjs.map +7 -0
- package/dist-esm/ui/mention.mjs +11 -0
- package/dist-esm/ui/mention.mjs.map +7 -0
- package/dist-esm/ui/reaction.mjs +11 -0
- package/dist-esm/ui/reaction.mjs.map +7 -0
- package/dist-esm/ui/reactions.mjs +14 -0
- package/dist-esm/ui/reactions.mjs.map +7 -0
- package/dist-esm/ui/render-markdown.mjs +45 -0
- package/dist-esm/ui/render-markdown.mjs.map +7 -0
- package/dist-esm/ui/send-button.mjs +8 -0
- package/dist-esm/ui/send-button.mjs.map +7 -0
- package/package.json +71 -0
- package/src/canvas/canvas.css +148 -0
- package/src/canvas/cluster-input.test.ts +223 -0
- package/src/canvas/cluster-input.ts +29 -0
- package/src/canvas/comment-body.tsx +21 -0
- package/src/canvas/comment-render.test.ts +90 -0
- package/src/canvas/comment-render.ts +92 -0
- package/src/canvas/comment-store.ts +55 -0
- package/src/canvas/comment-tool.tsx +200 -0
- package/src/canvas/comments-filter-menu.tsx +104 -0
- package/src/canvas/comments-menu-item.tsx +24 -0
- package/src/canvas/comments-overflow-menu.tsx +62 -0
- package/src/canvas/comments-overlay.tsx +1460 -0
- package/src/canvas/comments-sidebar.tsx +184 -0
- package/src/canvas/hooks.ts +28 -0
- package/src/canvas/license.ts +15 -0
- package/src/canvas/options.test.ts +127 -0
- package/src/canvas/options.ts +111 -0
- package/src/canvas/region-options.ts +57 -0
- package/src/canvas/rich-text.ts +15 -0
- package/src/canvas/sidebar-filters.ts +25 -0
- package/src/canvas/state.ts +102 -0
- package/src/canvas/thread-state.ts +91 -0
- package/src/clustering/computeClusterTable.test.ts +299 -0
- package/src/clustering/computeClusterTable.ts +82 -0
- package/src/clustering/locality.test.ts +189 -0
- package/src/clustering/mst.test.ts +383 -0
- package/src/clustering/mst.ts +134 -0
- package/src/clustering/replay.test.ts +547 -0
- package/src/clustering/replay.ts +326 -0
- package/src/clustering/runtime.test.ts +596 -0
- package/src/clustering/runtime.ts +153 -0
- package/src/clustering/schedule.test.ts +514 -0
- package/src/clustering/schedule.ts +184 -0
- package/src/clustering/types.ts +102 -0
- package/src/index.ts +96 -0
- package/src/ui/avatar.tsx +31 -0
- package/src/ui/byline.tsx +23 -0
- package/src/ui/comment-card.tsx +32 -0
- package/src/ui/comment-composer.tsx +179 -0
- package/src/ui/comment-extensions.ts +25 -0
- package/src/ui/comment-mention.ts +47 -0
- package/src/ui/comment-pin.tsx +21 -0
- package/src/ui/comment-text.tsx +12 -0
- package/src/ui/comment-thread.tsx +56 -0
- package/src/ui/comments-list.tsx +139 -0
- package/src/ui/comments.css +678 -0
- package/src/ui/count-badge.tsx +9 -0
- package/src/ui/empty-state.tsx +16 -0
- package/src/ui/format-time.ts +29 -0
- package/src/ui/mention-list.tsx +106 -0
- package/src/ui/mention-suggestion.test.ts +18 -0
- package/src/ui/mention-suggestion.tsx +285 -0
- package/src/ui/mention.tsx +9 -0
- package/src/ui/reaction.tsx +16 -0
- package/src/ui/reactions.tsx +15 -0
- package/src/ui/render-markdown.tsx +72 -0
- package/src/ui/send-button.tsx +15 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { ReactNode, useRef } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import {
|
|
4
|
+
TLComment,
|
|
5
|
+
TLCommentId,
|
|
6
|
+
useContainer,
|
|
7
|
+
useEditor,
|
|
8
|
+
usePassThroughMouseOverEvents,
|
|
9
|
+
useTranslation,
|
|
10
|
+
useValue,
|
|
11
|
+
} from 'tldraw'
|
|
12
|
+
import { CommentListItemProps, CommentsList } from '../ui/comments-list'
|
|
13
|
+
import { UNKNOWN_AUTHOR } from './comment-render'
|
|
14
|
+
import { CommentsFilterMenu } from './comments-filter-menu'
|
|
15
|
+
import { CommentsOverflowMenu } from './comments-overflow-menu'
|
|
16
|
+
import { useComments, useCommentThreads } from './hooks'
|
|
17
|
+
import { useCommentingEnabled } from './license'
|
|
18
|
+
import { useCommentingOptions } from './options'
|
|
19
|
+
import { richTextToPlaintext } from './rich-text'
|
|
20
|
+
import { openThreadId, sidebarFilters } from './state'
|
|
21
|
+
import { focusThread } from './thread-state'
|
|
22
|
+
|
|
23
|
+
/** @public */
|
|
24
|
+
export interface CanvasCommentsSidebarProps {
|
|
25
|
+
/** Map an author id to a display name, or `undefined` when the id can't be named. */
|
|
26
|
+
resolveName(id: string): string | undefined
|
|
27
|
+
/** The signed-in user's id. Enables the "only your threads" filter when present. */
|
|
28
|
+
currentUserId?: string
|
|
29
|
+
/**
|
|
30
|
+
* Whether a comment is unread for the current user (return true for unread). Enables the
|
|
31
|
+
* "only unread" filter when present.
|
|
32
|
+
*/
|
|
33
|
+
isCommentUnread?(commentId: TLCommentId): boolean
|
|
34
|
+
/** Tool ids that show the sidebar. Defaults to the comment tool. */
|
|
35
|
+
tools?: string[]
|
|
36
|
+
/** Header above the list. */
|
|
37
|
+
header?: ReactNode
|
|
38
|
+
/** Shown when the page has no threads. */
|
|
39
|
+
empty?: ReactNode
|
|
40
|
+
/** Where imprecise shape pins sit, so navigation centres on the same spot. Default top-right. */
|
|
41
|
+
impreciseShapeAnchor?: { x: number; y: number }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A comments list panel for the current page, shown while the comment tool is active. Clicking a
|
|
46
|
+
* thread brings its pin into view and opens it. Batteries-included over the store (a sibling to
|
|
47
|
+
* `CanvasComments`); `CommentsList` is exported for a differently-placed or always-on list.
|
|
48
|
+
* @public @react
|
|
49
|
+
*/
|
|
50
|
+
export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
|
|
51
|
+
const {
|
|
52
|
+
resolveName,
|
|
53
|
+
currentUserId,
|
|
54
|
+
isCommentUnread,
|
|
55
|
+
tools,
|
|
56
|
+
header,
|
|
57
|
+
empty,
|
|
58
|
+
impreciseShapeAnchor,
|
|
59
|
+
} = props
|
|
60
|
+
const editor = useEditor()
|
|
61
|
+
const options = useCommentingOptions()
|
|
62
|
+
const sidebarTools = tools ?? ['comment']
|
|
63
|
+
const container = useContainer()
|
|
64
|
+
const commentingEnabled = useCommentingEnabled()
|
|
65
|
+
const msg = useTranslation()
|
|
66
|
+
const threads = useCommentThreads(editor)
|
|
67
|
+
const comments = useComments(editor)
|
|
68
|
+
const currentPageId = useValue('page id', () => editor.getCurrentPageId(), [editor])
|
|
69
|
+
const activeTool = useValue('tool id', () => editor.getCurrentToolId(), [editor])
|
|
70
|
+
const openId = useValue('open thread', () => openThreadId.get(editor), [editor])
|
|
71
|
+
const filters = useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
|
|
72
|
+
const pageNames = useValue(
|
|
73
|
+
'page names',
|
|
74
|
+
() => new Map(editor.getPages().map((page) => [page.id, page.name])),
|
|
75
|
+
[editor]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if (!commentingEnabled || !sidebarTools.includes(activeTool)) return null
|
|
79
|
+
|
|
80
|
+
// Group comments by thread (they arrive oldest-first, so [0] is each thread's first comment).
|
|
81
|
+
const byThread = new Map<string, TLComment[]>()
|
|
82
|
+
for (const comment of comments) {
|
|
83
|
+
const list = byThread.get(comment.threadId) ?? []
|
|
84
|
+
list.push(comment)
|
|
85
|
+
byThread.set(comment.threadId, list)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Page scoping is treated as scoping, not a filter: an empty page reads "no comments yet",
|
|
89
|
+
// while a list emptied by the toggles below reads "nothing matches your filters".
|
|
90
|
+
const pageThreads = threads.filter(
|
|
91
|
+
(thread) => !filters.onlyCurrentPage || thread.pageId === currentPageId
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const items: CommentListItemProps[] = pageThreads
|
|
95
|
+
.filter((thread) => filters.showResolved || thread.resolved == null)
|
|
96
|
+
// "Only mine" is ignored without a known user — otherwise a persisted onlyMine=true would
|
|
97
|
+
// empty the list for a signed-out viewer, with the (hidden) toggle giving no way to clear it.
|
|
98
|
+
.filter(
|
|
99
|
+
(thread) =>
|
|
100
|
+
!filters.onlyMine || currentUserId === undefined || thread.createdBy === currentUserId
|
|
101
|
+
)
|
|
102
|
+
// "Only unread" is likewise ignored without a read-status source.
|
|
103
|
+
.filter(
|
|
104
|
+
(thread) =>
|
|
105
|
+
!filters.onlyUnread ||
|
|
106
|
+
isCommentUnread === undefined ||
|
|
107
|
+
(byThread.get(thread.id) ?? []).some((c) => isCommentUnread(c.id))
|
|
108
|
+
)
|
|
109
|
+
.map((thread) => {
|
|
110
|
+
const threadComments = byThread.get(thread.id) ?? []
|
|
111
|
+
const first = threadComments[0]
|
|
112
|
+
let preview: ReactNode = ''
|
|
113
|
+
// The `ThreadPreview` component slot overrides the built-in plaintext default.
|
|
114
|
+
const ThreadPreview = options.components.ThreadPreview
|
|
115
|
+
if (first) {
|
|
116
|
+
preview = ThreadPreview ? (
|
|
117
|
+
<ThreadPreview comment={first} />
|
|
118
|
+
) : (
|
|
119
|
+
richTextToPlaintext(first.body, resolveName)
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
id: thread.id,
|
|
124
|
+
author: resolveName(thread.createdBy) ?? UNKNOWN_AUTHOR,
|
|
125
|
+
preview,
|
|
126
|
+
date: new Date((first ?? thread).createdAt).toISOString(),
|
|
127
|
+
resolved: thread.resolved != null,
|
|
128
|
+
page: pageNames.get(thread.pageId),
|
|
129
|
+
count: threadComments.length,
|
|
130
|
+
selected: openId === thread.id,
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
// unresolved first, then most-recent first
|
|
134
|
+
.sort((a, b) => {
|
|
135
|
+
if (!!a.resolved !== !!b.resolved) return a.resolved ? 1 : -1
|
|
136
|
+
return b.date.localeCompare(a.date)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const focus = (id: string) => {
|
|
140
|
+
const thread = threads.find((t) => t.id === id)
|
|
141
|
+
// Resolve prop-or-option like the overlay pins do, so sidebar navigation centers on the same
|
|
142
|
+
// spot the pin renders at when the anchor is configured via `CommentTool.configure`.
|
|
143
|
+
if (thread) focusThread(editor, thread, impreciseShapeAnchor ?? options.impreciseShapeAnchor)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<SidebarPanel container={container}>
|
|
148
|
+
<CommentsList
|
|
149
|
+
items={items}
|
|
150
|
+
header={header ?? msg('comments.title')}
|
|
151
|
+
headerAction={
|
|
152
|
+
<div className="cmt-list__header-actions">
|
|
153
|
+
<CommentsFilterMenu
|
|
154
|
+
canFilterByAuthor={currentUserId !== undefined}
|
|
155
|
+
canFilterByUnread={isCommentUnread !== undefined}
|
|
156
|
+
/>
|
|
157
|
+
<CommentsOverflowMenu />
|
|
158
|
+
</div>
|
|
159
|
+
}
|
|
160
|
+
empty={
|
|
161
|
+
items.length === 0 && pageThreads.length > 0
|
|
162
|
+
? msg('comments.empty-filtered')
|
|
163
|
+
: (empty ?? msg('comments.empty'))
|
|
164
|
+
}
|
|
165
|
+
resolvedLabel={msg('comments.resolved')}
|
|
166
|
+
onSelect={focus}
|
|
167
|
+
/>
|
|
168
|
+
</SidebarPanel>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The sidebar surface, portaled into the container. It scrolls its own list, so — unlike tldraw's
|
|
173
|
+
* wheel-transparent panels — a wheel over it doesn't pan the canvas. Hover still passes through so
|
|
174
|
+
* shapes beneath it stay interactive. */
|
|
175
|
+
function SidebarPanel({ container, children }: { container: HTMLElement; children: ReactNode }) {
|
|
176
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
177
|
+
usePassThroughMouseOverEvents(ref)
|
|
178
|
+
return createPortal(
|
|
179
|
+
<div ref={ref} className="cmt-canvas-sidebar">
|
|
180
|
+
{children}
|
|
181
|
+
</div>,
|
|
182
|
+
container
|
|
183
|
+
)
|
|
184
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Editor, TLComment, TLCommentThread, TLCommentThreadId, useValue } from 'tldraw'
|
|
2
|
+
import { getComments, getCommentThreads } from './comment-store'
|
|
3
|
+
|
|
4
|
+
/** All comment threads in the store, reactively. @public */
|
|
5
|
+
export function useCommentThreads(editor: Editor): TLCommentThread[] {
|
|
6
|
+
return useValue('comment threads', () => getCommentThreads(editor), [editor])
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** A thread's comments, oldest first, reactively. @public */
|
|
10
|
+
export function useThreadComments(editor: Editor, threadId: TLCommentThreadId): TLComment[] {
|
|
11
|
+
return useValue(
|
|
12
|
+
'thread comments',
|
|
13
|
+
() =>
|
|
14
|
+
getComments(editor)
|
|
15
|
+
.filter((c) => c.threadId === threadId)
|
|
16
|
+
.sort((a, b) => a.createdAt - b.createdAt),
|
|
17
|
+
[editor, threadId]
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Every comment in the store, oldest first, reactively. Group by `threadId` for per-thread lists. @public */
|
|
22
|
+
export function useComments(editor: Editor): TLComment[] {
|
|
23
|
+
return useValue(
|
|
24
|
+
'all comments',
|
|
25
|
+
() => getComments(editor).sort((a, b) => a.createdAt - b.createdAt),
|
|
26
|
+
[editor]
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useLicenseContext, useLicenseFeatureFlag } from 'tldraw'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether commenting is licensed for this editor. Enabled in development; in production it requires
|
|
5
|
+
* a tldraw license that includes the commenting feature (or the collaboration umbrella that grants
|
|
6
|
+
* it). Reactive: re-reads when license validation resolves, and returns `false` while validation is
|
|
7
|
+
* pending, so gated UI stays hidden until the license is confirmed.
|
|
8
|
+
*
|
|
9
|
+
* The built-in commenting components (`CanvasComments`, `CanvasCommentsSidebar`, and the comment
|
|
10
|
+
* tool's toolbar button) gate on this. Use it to gate any custom commenting UI the same way.
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
export function useCommentingEnabled(): boolean {
|
|
14
|
+
return useLicenseFeatureFlag(useLicenseContext(), 'commenting')
|
|
15
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { Editor } from 'tldraw'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { CommentTool } from './comment-tool'
|
|
4
|
+
import { defaultCommentingOptions, getCommentingOptions, type CommentingOptions } from './options'
|
|
5
|
+
import { commitCommentMutation, openThreadId, pendingComment } from './state'
|
|
6
|
+
|
|
7
|
+
// The StateNode constructor doesn't call any editor methods, so a bare stub is enough to
|
|
8
|
+
// instantiate a configured tool and read its merged options.
|
|
9
|
+
function optionsOf(Tool: typeof CommentTool): CommentingOptions {
|
|
10
|
+
return new Tool({} as Editor).options
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('CommentTool.configure', () => {
|
|
14
|
+
it('returns default options when unconfigured', () => {
|
|
15
|
+
expect(new CommentTool({} as Editor).options).toEqual(defaultCommentingOptions)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('merges overrides over the defaults', () => {
|
|
19
|
+
const Tool = CommentTool.configure({ history: 'record', enableClustering: false })
|
|
20
|
+
expect(optionsOf(Tool)).toEqual({
|
|
21
|
+
...defaultCommentingOptions,
|
|
22
|
+
history: 'record',
|
|
23
|
+
enableClustering: false,
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('layers chained configure calls', () => {
|
|
28
|
+
const Tool = CommentTool.configure({ history: 'record' }).configure({ enableClustering: false })
|
|
29
|
+
expect(optionsOf(Tool)).toEqual({
|
|
30
|
+
...defaultCommentingOptions,
|
|
31
|
+
history: 'record',
|
|
32
|
+
enableClustering: false,
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('layers component slots across chained configure calls', () => {
|
|
37
|
+
const CommentBody = () => null
|
|
38
|
+
const PinContent = () => null
|
|
39
|
+
const Tool = CommentTool.configure({ components: { CommentBody } }).configure({
|
|
40
|
+
components: { PinContent },
|
|
41
|
+
})
|
|
42
|
+
// The second call's slot is added without dropping the first call's slot.
|
|
43
|
+
expect(optionsOf(Tool).components).toEqual({ CommentBody, PinContent })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('does not mutate the base tool or the defaults', () => {
|
|
47
|
+
CommentTool.configure({ history: 'record' })
|
|
48
|
+
expect(new CommentTool({} as Editor).options).toEqual(defaultCommentingOptions)
|
|
49
|
+
expect(defaultCommentingOptions.history).toBe('ignore')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// A minimal editor stub: getCommentingOptions reads the comment tool's `options` off
|
|
54
|
+
// getStateDescendant, and commitCommentMutation forwards to run().
|
|
55
|
+
function stubEditor(options: CommentingOptions) {
|
|
56
|
+
const runCalls: Array<{ history: unknown }> = []
|
|
57
|
+
const editor = {
|
|
58
|
+
getStateDescendant: () => ({ options }),
|
|
59
|
+
run: (fn: () => void, opts: { history: unknown }) => {
|
|
60
|
+
runCalls.push(opts)
|
|
61
|
+
fn()
|
|
62
|
+
return editor
|
|
63
|
+
},
|
|
64
|
+
} as unknown as Editor
|
|
65
|
+
return { editor, runCalls }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('getCommentingOptions', () => {
|
|
69
|
+
it('reads the tool options off the editor', () => {
|
|
70
|
+
const options = { ...defaultCommentingOptions, history: 'record' } as CommentingOptions
|
|
71
|
+
const { editor } = stubEditor(options)
|
|
72
|
+
expect(getCommentingOptions(editor)).toBe(options)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('falls back to defaults when the comment tool is absent', () => {
|
|
76
|
+
const editor = { getStateDescendant: () => undefined } as unknown as Editor
|
|
77
|
+
expect(getCommentingOptions(editor)).toBe(defaultCommentingOptions)
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('commitCommentMutation', () => {
|
|
82
|
+
it('uses options.history for a mutation and returns the callback result', () => {
|
|
83
|
+
const { editor, runCalls } = stubEditor({
|
|
84
|
+
...defaultCommentingOptions,
|
|
85
|
+
history: 'record',
|
|
86
|
+
} as CommentingOptions)
|
|
87
|
+
const result = commitCommentMutation(editor, () => 42)
|
|
88
|
+
expect(result).toBe(42)
|
|
89
|
+
expect(runCalls).toEqual([{ history: 'record' }])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('uses dragHistory for a drag, falling back to history when unset', () => {
|
|
93
|
+
const withDrag = stubEditor({
|
|
94
|
+
...defaultCommentingOptions,
|
|
95
|
+
history: 'ignore',
|
|
96
|
+
dragHistory: 'record',
|
|
97
|
+
} as CommentingOptions)
|
|
98
|
+
commitCommentMutation(withDrag.editor, () => undefined, 'drag')
|
|
99
|
+
expect(withDrag.runCalls).toEqual([{ history: 'record' }])
|
|
100
|
+
|
|
101
|
+
const noDrag = stubEditor({
|
|
102
|
+
...defaultCommentingOptions,
|
|
103
|
+
history: 'ignore',
|
|
104
|
+
dragHistory: undefined,
|
|
105
|
+
} as CommentingOptions)
|
|
106
|
+
commitCommentMutation(noDrag.editor, () => undefined, 'drag')
|
|
107
|
+
expect(noDrag.runCalls).toEqual([{ history: 'ignore' }])
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('editor-scoped transient state', () => {
|
|
112
|
+
it('keeps open-thread state independent per editor (multi-editor guard)', () => {
|
|
113
|
+
const a = {} as Editor
|
|
114
|
+
const b = {} as Editor
|
|
115
|
+
openThreadId.set(a, 'thread:1')
|
|
116
|
+
expect(openThreadId.get(a)).toBe('thread:1')
|
|
117
|
+
expect(openThreadId.get(b)).toBe(null)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('keeps pending-comment state independent per editor', () => {
|
|
121
|
+
const a = {} as Editor
|
|
122
|
+
const b = {} as Editor
|
|
123
|
+
pendingComment.set(a, { anchor: { type: 'page' }, point: { x: 0, y: 0 } })
|
|
124
|
+
expect(pendingComment.get(a)).not.toBe(null)
|
|
125
|
+
expect(pendingComment.get(b)).toBe(null)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useMemo, type ComponentType } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
type Editor,
|
|
4
|
+
type TLComment,
|
|
5
|
+
type TLCommentThread,
|
|
6
|
+
type TLHistoryBatchOptions,
|
|
7
|
+
useEditor,
|
|
8
|
+
} from 'tldraw'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Component overrides for the batteries-included comments layer. Each slot replaces a built-in
|
|
12
|
+
* piece; leave a slot unset to keep its default.
|
|
13
|
+
*
|
|
14
|
+
* @public
|
|
15
|
+
*/
|
|
16
|
+
export interface CommentingComponents {
|
|
17
|
+
/** A comment's body. Replaces the default rich-text `<CommentBody>`. */
|
|
18
|
+
CommentBody?: ComponentType<{ comment: TLComment }>
|
|
19
|
+
/** A pin's inner content. Replaces the author-initial default. */
|
|
20
|
+
PinContent?: ComponentType<{ thread: TLCommentThread; comments: TLComment[] }>
|
|
21
|
+
/** A sidebar row's preview. Replaces the plaintext default. */
|
|
22
|
+
ThreadPreview?: ComponentType<{ comment: TLComment }>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Configuration for the commenting layer. Static config only — pass it once via
|
|
27
|
+
* `CommentTool.configure({ ... })`, mirroring `ShapeUtil.configure`. Live, reactive values
|
|
28
|
+
* (`currentUserId`, `resolveName`, read-status callbacks) stay as props on `<CanvasComments>`.
|
|
29
|
+
*
|
|
30
|
+
* For defaults, see {@link defaultCommentingOptions}.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```tsx
|
|
34
|
+
* <Tldraw tools={[CommentTool.configure({ history: 'ignore', enableClustering: false })]} />
|
|
35
|
+
* ```
|
|
36
|
+
*
|
|
37
|
+
* @public
|
|
38
|
+
*/
|
|
39
|
+
export interface CommentingOptions {
|
|
40
|
+
// ── History / undo ───────────────────────────────────────────────────────────────────────
|
|
41
|
+
/**
|
|
42
|
+
* How comment mutations (post, reply, edit, resolve, delete) interact with the editor undo
|
|
43
|
+
* stack. Defaults to `'ignore'` — comments are deliberately not undoable (see `TLComment`).
|
|
44
|
+
* `'record'` is a multiplayer footgun: undoing a delete resurrects a thread a collaborator
|
|
45
|
+
* already removed, and undoing a resolve/edit reverts their newer state. Safe only single-player
|
|
46
|
+
* or on a non-synced local comment store.
|
|
47
|
+
*/
|
|
48
|
+
readonly history: TLHistoryBatchOptions['history']
|
|
49
|
+
/**
|
|
50
|
+
* History mode for the pin drag-to-move re-anchor specifically. Unlike posts/edits this is a
|
|
51
|
+
* spatial edit that may reasonably be undoable alongside a shape move. Defaults to `history`.
|
|
52
|
+
*/
|
|
53
|
+
readonly dragHistory: TLHistoryBatchOptions['history'] | undefined
|
|
54
|
+
|
|
55
|
+
// ── Feature toggles ──────────────────────────────────────────────────────────────────────
|
|
56
|
+
/** Fold nearby pins into count badges as the camera zooms out. */
|
|
57
|
+
readonly enableClustering: boolean
|
|
58
|
+
|
|
59
|
+
// ── Anchoring ────────────────────────────────────────────────────────────────────────────
|
|
60
|
+
/** Normalized (0–1) spot within a shape where imprecise shape pins sit. Default top-right. */
|
|
61
|
+
readonly impreciseShapeAnchor: { readonly x: number; readonly y: number }
|
|
62
|
+
|
|
63
|
+
// ── Clustering tuning ─────────────────────────────────────────────────────────────────────
|
|
64
|
+
/** Screen-pixel margin by which the viewport is inflated when culling cluster badges. */
|
|
65
|
+
readonly clusterCullMargin: number
|
|
66
|
+
/** How far past a cluster's split zoom to land when expanding it (1.05 = 5% overshoot). */
|
|
67
|
+
readonly clusterSplitZoomFactor: number
|
|
68
|
+
|
|
69
|
+
// ── Components ────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
/** Component overrides. See {@link CommentingComponents}. */
|
|
71
|
+
readonly components: CommentingComponents
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The default {@link CommentingOptions}. Override via `CommentTool.configure({ ... })`.
|
|
76
|
+
*
|
|
77
|
+
* @public
|
|
78
|
+
*/
|
|
79
|
+
export const defaultCommentingOptions = {
|
|
80
|
+
history: 'ignore',
|
|
81
|
+
dragHistory: undefined,
|
|
82
|
+
enableClustering: true,
|
|
83
|
+
impreciseShapeAnchor: { x: 1, y: 0 },
|
|
84
|
+
clusterCullMargin: 120,
|
|
85
|
+
clusterSplitZoomFactor: 1.05,
|
|
86
|
+
components: {},
|
|
87
|
+
} as const satisfies CommentingOptions
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The merged {@link CommentingOptions} for an editor, read off the registered comment tool (which
|
|
91
|
+
* carries them via `CommentTool.configure`). Falls back to {@link defaultCommentingOptions} when
|
|
92
|
+
* the comment tool isn't registered. Usable from anywhere with an `Editor` — including the tool's
|
|
93
|
+
* own state, which has no React context.
|
|
94
|
+
*
|
|
95
|
+
* @public
|
|
96
|
+
*/
|
|
97
|
+
export function getCommentingOptions(editor: Editor): CommentingOptions {
|
|
98
|
+
const tool = editor.getStateDescendant('comment') as { options?: CommentingOptions } | undefined
|
|
99
|
+
return tool?.options ?? defaultCommentingOptions
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* React hook for {@link getCommentingOptions}. Options are fixed per editor (set at tool
|
|
104
|
+
* registration), so this doesn't need to be reactive.
|
|
105
|
+
*
|
|
106
|
+
* @public
|
|
107
|
+
*/
|
|
108
|
+
export function useCommentingOptions(): CommentingOptions {
|
|
109
|
+
const editor = useEditor()
|
|
110
|
+
return useMemo(() => getCommentingOptions(editor), [editor])
|
|
111
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { atom, Atom, Editor, VecLike } from 'tldraw'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The configurable dimensions of region comments — a design surface for prototyping the interaction
|
|
5
|
+
* before settling it. Region is **off by default**, so a consumer that leaves it unset keeps plain
|
|
6
|
+
* click-only point/shape commenting. Set it per editor through `<CanvasComments regionOptions>`.
|
|
7
|
+
* @public
|
|
8
|
+
*/
|
|
9
|
+
export interface RegionCommentOptions {
|
|
10
|
+
/** Whether dragging the comment tool out creates a region anchor. Off → click-only. */
|
|
11
|
+
enabled: boolean
|
|
12
|
+
/** Which corner the pin and composer sit on, as a normalized 0–1 offset. Default bottom-right. */
|
|
13
|
+
pinCorner: VecLike
|
|
14
|
+
/** When the dashed box and its handles reveal: while the pointer is within the region, while the
|
|
15
|
+
* pin is hovered, or only while the thread is open. */
|
|
16
|
+
reveal: 'pointer' | 'pin-hover' | 'open'
|
|
17
|
+
/** How a region is moved: dragging its pin, dragging its body, or either. */
|
|
18
|
+
move: 'pin' | 'body' | 'both'
|
|
19
|
+
/** The resize affordance: corner handles, edge handles, or none. */
|
|
20
|
+
resize: 'corners' | 'edges' | 'none'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The out-of-the-box region config: disabled, and — when a consumer enables it — the current
|
|
24
|
+
* bottom-right / pointer-reveal / pin-move / corner-resize behaviour.
|
|
25
|
+
* @public */
|
|
26
|
+
export const DEFAULT_REGION_COMMENT_OPTIONS: RegionCommentOptions = {
|
|
27
|
+
enabled: false,
|
|
28
|
+
pinCorner: { x: 1, y: 1 },
|
|
29
|
+
reveal: 'pointer',
|
|
30
|
+
move: 'pin',
|
|
31
|
+
resize: 'corners',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Per-editor region config. The overlay publishes its merged `regionOptions` here so the comment
|
|
35
|
+
// tool — which has no props of its own — can read the same per-instance config at interaction time.
|
|
36
|
+
// Held in an atom so reactive readers (e.g. `anchorPagePoint`, called inside `useValue`) recompute
|
|
37
|
+
// when the config changes; a plain map read wouldn't, leaving the pin at the stale default corner.
|
|
38
|
+
const byEditor = new WeakMap<Editor, Atom<RegionCommentOptions>>()
|
|
39
|
+
|
|
40
|
+
function getEditorAtom(editor: Editor): Atom<RegionCommentOptions> {
|
|
41
|
+
let a = byEditor.get(editor)
|
|
42
|
+
if (!a) {
|
|
43
|
+
a = atom('regionCommentOptions', DEFAULT_REGION_COMMENT_OPTIONS)
|
|
44
|
+
byEditor.set(editor, a)
|
|
45
|
+
}
|
|
46
|
+
return a
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Publish an editor's region config (called by the overlay from its `regionOptions` prop). */
|
|
50
|
+
export function setRegionCommentOptions(editor: Editor, options: RegionCommentOptions): void {
|
|
51
|
+
getEditorAtom(editor).set(options)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The editor's region config, or the disabled default when none was set. */
|
|
55
|
+
export function getRegionCommentOptions(editor: Editor): RegionCommentOptions {
|
|
56
|
+
return getEditorAtom(editor).get()
|
|
57
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { TLRichText } from 'tldraw'
|
|
2
|
+
import { renderCommentPlaintext } from './comment-render'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Flatten a rich-text comment body to plaintext through the limited comment extension set,
|
|
6
|
+
* separating paragraphs and list items with newlines. A convenience for consumers rendering bodies
|
|
7
|
+
* as plain text (e.g. the sidebar preview); richer rendering can read the `TLRichText` directly.
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
export function richTextToPlaintext(
|
|
11
|
+
body: TLRichText,
|
|
12
|
+
resolveName?: (id: string) => string | undefined
|
|
13
|
+
): string {
|
|
14
|
+
return renderCommentPlaintext(body, resolveName)
|
|
15
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which threads the comments sidebar shows. Held as an editor-scoped signal (the `sidebarFilters`
|
|
3
|
+
* `EditorAtom` in `./state`) rather than component state so it survives the sidebar
|
|
4
|
+
* unmounting when the comment tool deactivates — a user's "hide resolved" choice shouldn't reset
|
|
5
|
+
* every time they leave the tool.
|
|
6
|
+
* @public
|
|
7
|
+
*/
|
|
8
|
+
export interface SidebarFilters {
|
|
9
|
+
/** Include resolved threads. */
|
|
10
|
+
showResolved: boolean
|
|
11
|
+
/** Only threads the current user started. Ignored when there's no current user. */
|
|
12
|
+
onlyMine: boolean
|
|
13
|
+
/** Only threads with unread comments. Ignored when the host provides no read status. */
|
|
14
|
+
onlyUnread: boolean
|
|
15
|
+
/** Only threads on the current page. Off = every page's threads, each labelled. */
|
|
16
|
+
onlyCurrentPage: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @public */
|
|
20
|
+
export const DEFAULT_SIDEBAR_FILTERS: SidebarFilters = {
|
|
21
|
+
showResolved: true,
|
|
22
|
+
onlyMine: false,
|
|
23
|
+
onlyUnread: false,
|
|
24
|
+
onlyCurrentPage: true,
|
|
25
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EditorAtom,
|
|
3
|
+
type BoxModel,
|
|
4
|
+
type Editor,
|
|
5
|
+
type TLHistoryBatchOptions,
|
|
6
|
+
useEditor,
|
|
7
|
+
useValue,
|
|
8
|
+
} from 'tldraw'
|
|
9
|
+
import type { PendingComment } from './comment-tool'
|
|
10
|
+
import { getCommentingOptions } from './options'
|
|
11
|
+
import { DEFAULT_SIDEBAR_FILTERS, type SidebarFilters } from './sidebar-filters'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Transient commenting UI state, scoped per editor via {@link EditorAtom}. Editor-scoping (rather
|
|
15
|
+
* than module-global atoms) keeps two editors on one page — or an editor and a second instance —
|
|
16
|
+
* from sharing open-thread, visibility, and filter state. Reachable from both React (`useEditor()`)
|
|
17
|
+
* and the comment tool (`this.editor`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** The id of the one open thread (only one popover is open at a time), or null when all closed.
|
|
21
|
+
* @public */
|
|
22
|
+
export const openThreadId = new EditorAtom<string | null>('openThreadId', () => null)
|
|
23
|
+
|
|
24
|
+
/** The comment currently being placed (composer open, not yet posted), or null.
|
|
25
|
+
* @public */
|
|
26
|
+
export const pendingComment = new EditorAtom<PendingComment | null>('pendingComment', () => null)
|
|
27
|
+
|
|
28
|
+
/** The region rectangle being dragged out right now (page coords), or null when not dragging. The
|
|
29
|
+
* comment tool writes it on each move; the overlay reads it to draw the live dashed box. */
|
|
30
|
+
export const regionDraft = new EditorAtom<BoxModel | null>('regionDraft', () => null)
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Whether comment pins are hidden on the canvas. Governs the on-canvas layer (pins + open popover)
|
|
34
|
+
* only — the sidebar is unaffected.
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
export const commentsHidden = new EditorAtom<boolean>('commentsHidden', () => false)
|
|
38
|
+
|
|
39
|
+
/** Which threads the comments sidebar shows.
|
|
40
|
+
* @public */
|
|
41
|
+
export const sidebarFilters = new EditorAtom<SidebarFilters>(
|
|
42
|
+
'sidebarFilters',
|
|
43
|
+
() => DEFAULT_SIDEBAR_FILTERS
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
/** Toggle comment-pin visibility for an editor.
|
|
47
|
+
* @public */
|
|
48
|
+
export function toggleCommentsHidden(editor: Editor): void {
|
|
49
|
+
commentsHidden.update(editor, (hidden) => !hidden)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** React hook for the open thread id.
|
|
53
|
+
* @public */
|
|
54
|
+
export function useOpenThreadId(): string | null {
|
|
55
|
+
const editor = useEditor()
|
|
56
|
+
return useValue('open thread id', () => openThreadId.get(editor), [editor])
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** React hook for the pending (being-placed) comment.
|
|
60
|
+
* @public */
|
|
61
|
+
export function usePendingComment(): PendingComment | null {
|
|
62
|
+
const editor = useEditor()
|
|
63
|
+
return useValue('pending comment', () => pendingComment.get(editor), [editor])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** React hook for whether comment pins are hidden.
|
|
67
|
+
* @public */
|
|
68
|
+
export function useCommentsHidden(): boolean {
|
|
69
|
+
const editor = useEditor()
|
|
70
|
+
return useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** React hook for the current sidebar filters.
|
|
74
|
+
* @public */
|
|
75
|
+
export function useSidebarFilters(): SidebarFilters {
|
|
76
|
+
const editor = useEditor()
|
|
77
|
+
return useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Commit a comment mutation with the configured undo/redo behavior. All comment writes go through
|
|
82
|
+
* here so the {@link CommentingOptions.history} option (and {@link CommentingOptions.dragHistory}
|
|
83
|
+
* for pin re-anchors) governs whether they land on the undo stack. Defaults to `'ignore'`.
|
|
84
|
+
* @public
|
|
85
|
+
*/
|
|
86
|
+
export function commitCommentMutation<T>(
|
|
87
|
+
editor: Editor,
|
|
88
|
+
fn: () => T,
|
|
89
|
+
kind: 'mutation' | 'drag' = 'mutation'
|
|
90
|
+
): T {
|
|
91
|
+
const options = getCommentingOptions(editor)
|
|
92
|
+
const history: TLHistoryBatchOptions['history'] =
|
|
93
|
+
kind === 'drag' ? (options.dragHistory ?? options.history) : options.history
|
|
94
|
+
let result: T
|
|
95
|
+
editor.run(
|
|
96
|
+
() => {
|
|
97
|
+
result = fn()
|
|
98
|
+
},
|
|
99
|
+
{ history }
|
|
100
|
+
)
|
|
101
|
+
return result!
|
|
102
|
+
}
|