@tldraw/commenting 5.4.2 → 5.5.0-canary.0241f11f2e99
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/commenting.css +0 -32
- package/dist-cjs/canvas/anchor-lifecycle.js +4 -5
- package/dist-cjs/canvas/anchor-lifecycle.js.map +2 -2
- package/dist-cjs/canvas/cluster-fade.js +1 -1
- package/dist-cjs/canvas/cluster-fade.js.map +2 -2
- package/dist-cjs/canvas/cluster-model.js +23 -31
- package/dist-cjs/canvas/cluster-model.js.map +2 -2
- package/dist-cjs/canvas/comment-render.js +19 -19
- package/dist-cjs/canvas/comment-render.js.map +2 -2
- package/dist-cjs/canvas/comment-store.js +10 -5
- package/dist-cjs/canvas/comment-store.js.map +2 -2
- package/dist-cjs/canvas/comments-overlay.js +12 -16
- package/dist-cjs/canvas/comments-overlay.js.map +2 -2
- package/dist-cjs/canvas/comments-sidebar.js +1 -7
- package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
- package/dist-cjs/canvas/hooks.js.map +1 -1
- package/dist-cjs/canvas/thread-pin.js +14 -26
- package/dist-cjs/canvas/thread-pin.js.map +2 -2
- package/dist-cjs/canvas/thread-stack.js +2 -6
- package/dist-cjs/canvas/thread-stack.js.map +2 -2
- package/dist-cjs/canvas/thread-state.js +13 -0
- package/dist-cjs/canvas/thread-state.js.map +2 -2
- package/dist-cjs/canvas/thread-view.js +67 -78
- package/dist-cjs/canvas/thread-view.js.map +2 -2
- package/dist-cjs/clustering/computeClusterTable.js +2 -9
- package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
- package/dist-cjs/clustering/replay.js +10 -23
- package/dist-cjs/clustering/replay.js.map +2 -2
- package/dist-cjs/clustering/runtime.js +9 -15
- package/dist-cjs/clustering/runtime.js.map +2 -2
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/ui/comment-composer.js +2 -4
- package/dist-cjs/ui/comment-composer.js.map +2 -2
- package/dist-cjs/ui/format-time.js +20 -8
- package/dist-cjs/ui/format-time.js.map +2 -2
- package/dist-cjs/ui/reaction.js +5 -15
- package/dist-cjs/ui/reaction.js.map +2 -2
- package/dist-esm/canvas/anchor-lifecycle.mjs +5 -6
- package/dist-esm/canvas/anchor-lifecycle.mjs.map +2 -2
- package/dist-esm/canvas/cluster-fade.mjs +1 -1
- package/dist-esm/canvas/cluster-fade.mjs.map +2 -2
- package/dist-esm/canvas/cluster-model.mjs +24 -32
- package/dist-esm/canvas/cluster-model.mjs.map +2 -2
- package/dist-esm/canvas/comment-render.mjs +19 -19
- package/dist-esm/canvas/comment-render.mjs.map +2 -2
- package/dist-esm/canvas/comment-store.mjs +10 -5
- package/dist-esm/canvas/comment-store.mjs.map +2 -2
- package/dist-esm/canvas/comments-overlay.mjs +13 -17
- package/dist-esm/canvas/comments-overlay.mjs.map +2 -2
- package/dist-esm/canvas/comments-sidebar.mjs +3 -14
- package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
- package/dist-esm/canvas/hooks.mjs.map +1 -1
- package/dist-esm/canvas/thread-pin.mjs +16 -26
- package/dist-esm/canvas/thread-pin.mjs.map +2 -2
- package/dist-esm/canvas/thread-stack.mjs +3 -7
- package/dist-esm/canvas/thread-stack.mjs.map +2 -2
- package/dist-esm/canvas/thread-state.mjs +13 -0
- package/dist-esm/canvas/thread-state.mjs.map +2 -2
- package/dist-esm/canvas/thread-view.mjs +67 -78
- package/dist-esm/canvas/thread-view.mjs.map +2 -2
- package/dist-esm/clustering/computeClusterTable.mjs +2 -9
- package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
- package/dist-esm/clustering/replay.mjs +10 -23
- package/dist-esm/clustering/replay.mjs.map +2 -2
- package/dist-esm/clustering/runtime.mjs +9 -15
- package/dist-esm/clustering/runtime.mjs.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/ui/comment-composer.mjs +2 -4
- package/dist-esm/ui/comment-composer.mjs.map +2 -2
- package/dist-esm/ui/format-time.mjs +20 -8
- package/dist-esm/ui/format-time.mjs.map +2 -2
- package/dist-esm/ui/reaction.mjs +7 -15
- package/dist-esm/ui/reaction.mjs.map +2 -2
- package/package.json +5 -5
- package/src/canvas/anchor-lifecycle.ts +5 -6
- package/src/canvas/cluster-fade.ts +6 -6
- package/src/canvas/cluster-model.test.ts +64 -1
- package/src/canvas/cluster-model.ts +28 -33
- package/src/canvas/comment-render.ts +30 -28
- package/src/canvas/comment-store.ts +16 -6
- package/src/canvas/comments-overlay.tsx +18 -19
- package/src/canvas/comments-sidebar.tsx +3 -18
- package/src/canvas/hooks.ts +1 -1
- package/src/canvas/thread-pin.tsx +21 -31
- package/src/canvas/thread-stack.tsx +3 -7
- package/src/canvas/thread-state.test.ts +35 -0
- package/src/canvas/thread-state.ts +27 -0
- package/src/canvas/thread-view.tsx +93 -95
- package/src/clustering/computeClusterTable.ts +3 -20
- package/src/clustering/replay.ts +10 -23
- package/src/clustering/runtime.ts +9 -15
- package/src/ui/comment-composer.tsx +2 -4
- package/src/ui/comments.css +0 -32
- package/src/ui/format-time.ts +24 -11
- package/src/ui/reaction.tsx +11 -23
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/ui/reaction.tsx"],
|
|
4
|
-
"sourcesContent": ["import { ComponentType, ReactNode } from 'react'\nimport { TldrawUiTooltip, useTranslation } from 'tldraw'\nimport { ReactionReactor } from './reactions'\n\n/** Render a reaction token to its visual \u2014 the emoji glyph by default. @public */\nexport type RenderReaction = (token: string) => ReactNode\n\n/** The default reaction renderer: emits the token string for the OS emoji font to draw. @public */\nexport function defaultRenderReaction(token: string): ReactNode {\n\treturn token\n}\n\n/** @public */\nexport interface ReactionProps {\n\temoji: string\n\tcount: number\n\tactive: boolean\n\t/** How to draw the emoji token. Defaults to the token string (OS emoji font). */\n\trenderReaction?: RenderReaction\n\t/** Who reacted with this emoji, in reaction order \u2014 shown when the pill is hovered. */\n\treactors: ReactionReactor[]\n\t/** Whether hovering shows the reactor list. Pass false to suppress it \u2014 e.g. while another\n\t * popup menu on the comment is open. Defaults to true. */\n\tenableHoverList?: boolean\n\t/** The hover affordance for the pill, naming who reacted. It receives the reactors and the pill\n\t * itself (as `children`) and returns the whole thing \u2014 so it owns the tooltip, its box, size,\n\t * shape, and position, not just the text inside. Defaults to `DefaultReactionTooltip`, which\n\t * hangs the built-in inline sentence below the pill. */\n\tReactionTooltip?: ComponentType<ReactionTooltipProps>\n\t/** Called when the pill is pressed \u2014 toggles the current user's reaction. */\n\tonClick?(): void\n}\n\n/**\n * A single emoji reaction pill with a count, highlighted when the user reacted. Hovering it shows a\n * tooltip naming who reacted (the current user included), when `enableHoverList` is set.\n * @public @react\n */\nexport function Reaction({\n\temoji,\n\tcount,\n\tactive,\n\treactors,\n\tenableHoverList = true,\n\trenderReaction = defaultRenderReaction,\n\tReactionTooltip = DefaultReactionTooltip,\n\tonClick,\n}: ReactionProps) {\n\t// An inert pill (no handler) is plain content, not a control \u2014 rendering it as a span keeps it\n\t// valid inside an anchor row (e.g. a notification list item) and out of the tab order.\n\tconst Tag = onClick ? 'button' : 'span'\n\tconst pill = (\n\t\t<Tag\n\t\t\tclassName={active ? 'tlui-cmt-reaction tlui-cmt-reaction--active' : 'tlui-cmt-reaction'}\n\t\t\t{...(onClick\n\t\t\t\t? { type: 'button' as const, 'aria-pressed': active, onClick }\n\t\t\t\t: { role: 'img' as const })}\n\t\t\t// The emoji alone announces as its unicode name (\"thumbs up\"); pairing it with the count\n\t\t\t// is what makes the pill's state legible to a screen reader. The inert span needs the\n\t\t\t// img role for the label to be announced at all.\n\t\t\taria-label={`${emoji} ${count}`}\n\t\t>\n\t\t\t<span className=\"tlui-cmt-reaction__emoji\">{renderReaction(emoji)}</span>\n\t\t\t<span className=\"tlui-cmt-reaction__count\">{count}</span>\n\t\t</Tag>\n\t)\n\n\t// A bare pill when hovering is suppressed, or when there's no one to name.\n\tif (!enableHoverList || reactors.length === 0) return pill\n\n\t// Hand the whole hover affordance to the tooltip component: it wraps the pill and owns how the\n\t// reactor list is presented.\n\treturn <ReactionTooltip reactors={reactors}>{pill}</ReactionTooltip>\n}\n\n/** @public */\nexport interface ReactionTooltipProps {\n\t/** Who reacted with this emoji, in reaction order. */\n\treactors: ReactionReactor[]\n\t/** The reaction pill to wrap. Anchor the hover affordance to this and render it. */\n\tchildren: ReactNode\n}\n\n/**\n * The default reactor tooltip: hangs the built-in inline sentence (`DefaultReactionTooltipContent`)\n * in a standard tooltip below the pill \u2014 the same tooltip the toolbar uses. Replace it via\n * `CommentingComponents.ReactionTooltip` to present the reactor list any other way (a different box,\n * avatars, a banner, anywhere on screen); it receives the pill as `children`.\n * @public @react\n */\nexport function DefaultReactionTooltip({ reactors, children }: ReactionTooltipProps) {\n\treturn (\n\t\t<TldrawUiTooltip side=\"bottom\" content={<DefaultReactionTooltipContent reactors={reactors} />}>\n\t\t\t{children}\n\t\t</TldrawUiTooltip>\n\t)\n}\n\n/**\n * The default reactor sentence naming who reacted \u2014 up to three names spelled out, then \"and N\n * others\" (e.g. \"You reacted\", \"You and Bo reacted\", \"You, Bo and Ada reacted\", \"You, Bo, Ada and 2\n * others reacted\"). The wording lives in the `comments.reacted-*` translation strings so each locale\n * controls the grammar. Exported so a custom `ReactionTooltip` can reuse it inside its own box.\n * @public @react\n */\nexport function DefaultReactionTooltipContent({ reactors }: { reactors: ReactionReactor[] }) {\n\tconst msg = useTranslation()\n\tconst names = reactors.map((reactor) =>\n\t\treactor.you ? msg('comments.mention-you') : reactor.name\n\t)\n\tconst [a, b, c] = names\n\
|
|
5
|
-
"mappings": "AAoDE,
|
|
4
|
+
"sourcesContent": ["import { ComponentType, ReactNode } from 'react'\nimport { TldrawUiTooltip, useTranslation } from 'tldraw'\nimport { ReactionReactor } from './reactions'\n\n/** Render a reaction token to its visual \u2014 the emoji glyph by default. @public */\nexport type RenderReaction = (token: string) => ReactNode\n\n/** The default reaction renderer: emits the token string for the OS emoji font to draw. @public */\nexport function defaultRenderReaction(token: string): ReactNode {\n\treturn token\n}\n\n/** @public */\nexport interface ReactionProps {\n\temoji: string\n\tcount: number\n\tactive: boolean\n\t/** How to draw the emoji token. Defaults to the token string (OS emoji font). */\n\trenderReaction?: RenderReaction\n\t/** Who reacted with this emoji, in reaction order \u2014 shown when the pill is hovered. */\n\treactors: ReactionReactor[]\n\t/** Whether hovering shows the reactor list. Pass false to suppress it \u2014 e.g. while another\n\t * popup menu on the comment is open. Defaults to true. */\n\tenableHoverList?: boolean\n\t/** The hover affordance for the pill, naming who reacted. It receives the reactors and the pill\n\t * itself (as `children`) and returns the whole thing \u2014 so it owns the tooltip, its box, size,\n\t * shape, and position, not just the text inside. Defaults to `DefaultReactionTooltip`, which\n\t * hangs the built-in inline sentence below the pill. */\n\tReactionTooltip?: ComponentType<ReactionTooltipProps>\n\t/** Called when the pill is pressed \u2014 toggles the current user's reaction. */\n\tonClick?(): void\n}\n\n/**\n * A single emoji reaction pill with a count, highlighted when the user reacted. Hovering it shows a\n * tooltip naming who reacted (the current user included), when `enableHoverList` is set.\n * @public @react\n */\nexport function Reaction({\n\temoji,\n\tcount,\n\tactive,\n\treactors,\n\tenableHoverList = true,\n\trenderReaction = defaultRenderReaction,\n\tReactionTooltip = DefaultReactionTooltip,\n\tonClick,\n}: ReactionProps) {\n\t// An inert pill (no handler) is plain content, not a control \u2014 rendering it as a span keeps it\n\t// valid inside an anchor row (e.g. a notification list item) and out of the tab order.\n\tconst Tag = onClick ? 'button' : 'span'\n\tconst pill = (\n\t\t<Tag\n\t\t\tclassName={active ? 'tlui-cmt-reaction tlui-cmt-reaction--active' : 'tlui-cmt-reaction'}\n\t\t\t{...(onClick\n\t\t\t\t? { type: 'button' as const, 'aria-pressed': active, onClick }\n\t\t\t\t: { role: 'img' as const })}\n\t\t\t// The emoji alone announces as its unicode name (\"thumbs up\"); pairing it with the count\n\t\t\t// is what makes the pill's state legible to a screen reader. The inert span needs the\n\t\t\t// img role for the label to be announced at all.\n\t\t\taria-label={`${emoji} ${count}`}\n\t\t>\n\t\t\t<span className=\"tlui-cmt-reaction__emoji\">{renderReaction(emoji)}</span>\n\t\t\t<span className=\"tlui-cmt-reaction__count\">{count}</span>\n\t\t</Tag>\n\t)\n\n\t// A bare pill when hovering is suppressed, or when there's no one to name.\n\tif (!enableHoverList || reactors.length === 0) return pill\n\n\t// Hand the whole hover affordance to the tooltip component: it wraps the pill and owns how the\n\t// reactor list is presented.\n\treturn <ReactionTooltip reactors={reactors}>{pill}</ReactionTooltip>\n}\n\n/** @public */\nexport interface ReactionTooltipProps {\n\t/** Who reacted with this emoji, in reaction order. */\n\treactors: ReactionReactor[]\n\t/** The reaction pill to wrap. Anchor the hover affordance to this and render it. */\n\tchildren: ReactNode\n}\n\n/**\n * The default reactor tooltip: hangs the built-in inline sentence (`DefaultReactionTooltipContent`)\n * in a standard tooltip below the pill \u2014 the same tooltip the toolbar uses. Replace it via\n * `CommentingComponents.ReactionTooltip` to present the reactor list any other way (a different box,\n * avatars, a banner, anywhere on screen); it receives the pill as `children`.\n * @public @react\n */\nexport function DefaultReactionTooltip({ reactors, children }: ReactionTooltipProps) {\n\treturn (\n\t\t<TldrawUiTooltip side=\"bottom\" content={<DefaultReactionTooltipContent reactors={reactors} />}>\n\t\t\t{children}\n\t\t</TldrawUiTooltip>\n\t)\n}\n\n/**\n * The default reactor sentence naming who reacted \u2014 up to three names spelled out, then \"and N\n * others\" (e.g. \"You reacted\", \"You and Bo reacted\", \"You, Bo and Ada reacted\", \"You, Bo, Ada and 2\n * others reacted\"). The wording lives in the `comments.reacted-*` translation strings so each locale\n * controls the grammar. Exported so a custom `ReactionTooltip` can reuse it inside its own box.\n * @public @react\n */\nexport function DefaultReactionTooltipContent({ reactors }: { reactors: ReactionReactor[] }) {\n\tconst msg = useTranslation()\n\tconst names = reactors.map((reactor) =>\n\t\treactor.you ? msg('comments.mention-you') : reactor.name\n\t)\n\tif (names.length === 0) return null\n\tconst [a, b, c] = names\n\tconst others = names.length - 3\n\tconst key =\n\t\tnames.length <= 3\n\t\t\t? `comments.reacted-${names.length}`\n\t\t\t: others === 1\n\t\t\t\t? 'comments.reacted-more-one'\n\t\t\t\t: 'comments.reacted-more'\n\t// Single pass, so a substituted name containing a placeholder can't be re-substituted.\n\tconst vars: Record<string, string | undefined> = { a, b, c, count: String(others) }\n\treturn <>{msg(key).replace(/\\{(a|b|c|count)\\}/g, (_, k) => vars[k] ?? '')}</>\n}\n"],
|
|
5
|
+
"mappings": "AAoDE,SAqEM,UA3DL,KAVD;AAnDF,SAAS,iBAAiB,sBAAsB;AAOzC,SAAS,sBAAsB,OAA0B;AAC/D,SAAO;AACR;AA4BO,SAAS,SAAS;AAAA,EACxB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB,iBAAiB;AAAA,EACjB,kBAAkB;AAAA,EAClB;AACD,GAAkB;AAGjB,QAAM,MAAM,UAAU,WAAW;AACjC,QAAM,OACL;AAAA,IAAC;AAAA;AAAA,MACA,WAAW,SAAS,gDAAgD;AAAA,MACnE,GAAI,UACF,EAAE,MAAM,UAAmB,gBAAgB,QAAQ,QAAQ,IAC3D,EAAE,MAAM,MAAe;AAAA,MAI1B,cAAY,GAAG,KAAK,IAAI,KAAK;AAAA,MAE7B;AAAA,4BAAC,UAAK,WAAU,4BAA4B,yBAAe,KAAK,GAAE;AAAA,QAClE,oBAAC,UAAK,WAAU,4BAA4B,iBAAM;AAAA;AAAA;AAAA,EACnD;AAID,MAAI,CAAC,mBAAmB,SAAS,WAAW,EAAG,QAAO;AAItD,SAAO,oBAAC,mBAAgB,UAAqB,gBAAK;AACnD;AAiBO,SAAS,uBAAuB,EAAE,UAAU,SAAS,GAAyB;AACpF,SACC,oBAAC,mBAAgB,MAAK,UAAS,SAAS,oBAAC,iCAA8B,UAAoB,GACzF,UACF;AAEF;AASO,SAAS,8BAA8B,EAAE,SAAS,GAAoC;AAC5F,QAAM,MAAM,eAAe;AAC3B,QAAM,QAAQ,SAAS;AAAA,IAAI,CAAC,YAC3B,QAAQ,MAAM,IAAI,sBAAsB,IAAI,QAAQ;AAAA,EACrD;AACA,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,CAAC,GAAG,GAAG,CAAC,IAAI;AAClB,QAAM,SAAS,MAAM,SAAS;AAC9B,QAAM,MACL,MAAM,UAAU,IACb,oBAAoB,MAAM,MAAM,KAChC,WAAW,IACV,8BACA;AAEL,QAAM,OAA2C,EAAE,GAAG,GAAG,GAAG,OAAO,OAAO,MAAM,EAAE;AAClF,SAAO,gCAAG,cAAI,GAAG,EAAE,QAAQ,sBAAsB,CAAC,GAAG,MAAM,KAAK,CAAC,KAAK,EAAE,GAAE;AAC3E;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tldraw/commenting",
|
|
3
3
|
"description": "tldraw commenting UI components.",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.5.0-canary.0241f11f2e99",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "tldraw Inc.",
|
|
7
7
|
"email": "hello@tldraw.com"
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@tldraw/driver": "5.
|
|
49
|
+
"@tldraw/driver": "5.5.0-canary.0241f11f2e99",
|
|
50
50
|
"@types/react": "^19.2.7",
|
|
51
51
|
"@types/react-dom": "^19.2.3",
|
|
52
52
|
"react": "^19.2.1",
|
|
@@ -64,9 +64,9 @@
|
|
|
64
64
|
"@tiptap/core": "^3.12.1",
|
|
65
65
|
"@tiptap/pm": "^3.12.1",
|
|
66
66
|
"@tiptap/react": "^3.12.1",
|
|
67
|
-
"@tldraw/mentions": "5.
|
|
68
|
-
"@tldraw/utils": "5.
|
|
69
|
-
"tldraw": "5.
|
|
67
|
+
"@tldraw/mentions": "5.5.0-canary.0241f11f2e99",
|
|
68
|
+
"@tldraw/utils": "5.5.0-canary.0241f11f2e99",
|
|
69
|
+
"tldraw": "5.5.0-canary.0241f11f2e99"
|
|
70
70
|
},
|
|
71
71
|
"module": "dist-esm/index.mjs",
|
|
72
72
|
"source": "src/index.ts",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WeakCache } from '@tldraw/utils'
|
|
2
2
|
import { Editor, TLCommentAnchor, TLCommentThread, TLPageId, TLShapeId, VecLike } from 'tldraw'
|
|
3
3
|
import { commitCommentMutation } from './comment-mutations'
|
|
4
|
-
import {
|
|
4
|
+
import { getComments, getCommentThread, getCommentThreads, TLCommentRecord } from './comment-store'
|
|
5
5
|
import { anchorPagePoint, impreciseShapePinInset } from './thread-state'
|
|
6
6
|
|
|
7
7
|
type ShapeAnchor = Extract<TLCommentAnchor, { type: 'shape' }>
|
|
@@ -108,9 +108,8 @@ export function registerCommentAnchorLifecycle(editor: Editor): () => void {
|
|
|
108
108
|
if (editor.getShape(shapeId)) continue
|
|
109
109
|
for (const [threadId, point] of threadPoints) {
|
|
110
110
|
if (!point) continue
|
|
111
|
-
const thread =
|
|
112
|
-
if (!thread || thread
|
|
113
|
-
if (!isAnchoredToShape(thread, shapeId)) continue
|
|
111
|
+
const thread = getCommentThread(editor, threadId)
|
|
112
|
+
if (!thread || !isAnchoredToShape(thread, shapeId)) continue
|
|
114
113
|
let converted = convertedByShape.get(shapeId)
|
|
115
114
|
if (!converted) {
|
|
116
115
|
converted = []
|
|
@@ -130,8 +129,8 @@ export function registerCommentAnchorLifecycle(editor: Editor): () => void {
|
|
|
130
129
|
|
|
131
130
|
const pageId = editor.getAncestorPageId(shape)
|
|
132
131
|
for (const { threadId, anchor, point } of converted) {
|
|
133
|
-
const thread =
|
|
134
|
-
if (!thread
|
|
132
|
+
const thread = getCommentThread(editor, threadId)
|
|
133
|
+
if (!thread) continue
|
|
135
134
|
// Re-attach only threads still sitting exactly where the conversion left them — a
|
|
136
135
|
// pin moved since then was placed deliberately, and that placement wins.
|
|
137
136
|
const current = thread.anchor
|
|
@@ -78,14 +78,14 @@ function reconcileFadeNodes(
|
|
|
78
78
|
|
|
79
79
|
for (const node of nextNodes) {
|
|
80
80
|
const previousItem = previousById.get(node.id)
|
|
81
|
+
// A node that was fading out and came back snaps to present rather than re-entering.
|
|
81
82
|
next.push({
|
|
82
83
|
node,
|
|
83
|
-
phase:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
: 'entering',
|
|
84
|
+
phase: !previousItem
|
|
85
|
+
? 'entering'
|
|
86
|
+
: previousItem.phase === 'exiting'
|
|
87
|
+
? 'present'
|
|
88
|
+
: previousItem.phase,
|
|
89
89
|
})
|
|
90
90
|
}
|
|
91
91
|
|
|
@@ -1,8 +1,21 @@
|
|
|
1
|
+
import {
|
|
2
|
+
commentSchemaRecords,
|
|
3
|
+
createCommentThread,
|
|
4
|
+
createShapeId,
|
|
5
|
+
createTLSchema,
|
|
6
|
+
createTLStore,
|
|
7
|
+
defaultBindingUtils,
|
|
8
|
+
defaultShapeUtils,
|
|
9
|
+
defaultTools,
|
|
10
|
+
Editor,
|
|
11
|
+
PageRecordType,
|
|
12
|
+
} from 'tldraw'
|
|
1
13
|
import { describe, expect, it } from 'vitest'
|
|
2
14
|
import { createClusterRuntime } from '../clustering/runtime'
|
|
3
15
|
import type { ClusterNode, ClusterTable, LeafInput } from '../clustering/types'
|
|
4
16
|
import type { ClusterInput } from './cluster-input'
|
|
5
|
-
import { anyFoldedLeafMoved, type ClusterModel } from './cluster-model'
|
|
17
|
+
import { anyFoldedLeafMoved, revealThreadPin, type ClusterModel } from './cluster-model'
|
|
18
|
+
import { defaultCommentingOptions } from './options'
|
|
6
19
|
|
|
7
20
|
function node(ids: string[], x = 0, y = 0): ClusterNode {
|
|
8
21
|
const members = ids.slice().sort()
|
|
@@ -86,3 +99,53 @@ describe('anyFoldedLeafMoved', () => {
|
|
|
86
99
|
expect(anyFoldedLeafMoved(input(AT_REST), next, rendered(0.5))).toBe(false)
|
|
87
100
|
})
|
|
88
101
|
})
|
|
102
|
+
|
|
103
|
+
describe('revealThreadPin', () => {
|
|
104
|
+
it('records a cross-page jump as its own undo step', () => {
|
|
105
|
+
const editor = new Editor({
|
|
106
|
+
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
|
107
|
+
shapeUtils: defaultShapeUtils,
|
|
108
|
+
bindingUtils: defaultBindingUtils,
|
|
109
|
+
tools: defaultTools,
|
|
110
|
+
getContainer: () => document.body,
|
|
111
|
+
})
|
|
112
|
+
try {
|
|
113
|
+
const page1Id = editor.getCurrentPageId()
|
|
114
|
+
const page2Id = PageRecordType.createId()
|
|
115
|
+
const boxId = createShapeId()
|
|
116
|
+
|
|
117
|
+
// Set up the second page outside of history so the only undoable steps are the ones
|
|
118
|
+
// the test creates: one edit on page 1, then the jump to the pin on page 2.
|
|
119
|
+
editor.run(() => editor.createPage({ id: page2Id, name: 'Page 2' }), { history: 'ignore' })
|
|
120
|
+
editor.clearHistory()
|
|
121
|
+
|
|
122
|
+
editor.markHistoryStoppingPoint('create box')
|
|
123
|
+
editor.createShape({ id: boxId, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } })
|
|
124
|
+
|
|
125
|
+
const thread = createCommentThread({
|
|
126
|
+
pageId: page2Id,
|
|
127
|
+
anchor: { type: 'point', x: 50, y: 50 },
|
|
128
|
+
createdBy: 'me',
|
|
129
|
+
})
|
|
130
|
+
revealThreadPin(
|
|
131
|
+
editor,
|
|
132
|
+
thread,
|
|
133
|
+
{ leaves: [], events: [] },
|
|
134
|
+
{ minZoom: 0.1, maxZoom: 8 },
|
|
135
|
+
{ ...defaultCommentingOptions, enableClustering: false },
|
|
136
|
+
0
|
|
137
|
+
)
|
|
138
|
+
expect(editor.getCurrentPageId()).toBe(page2Id)
|
|
139
|
+
|
|
140
|
+
// The first undo only takes us back to page 1; the edit made there is still intact.
|
|
141
|
+
editor.undo()
|
|
142
|
+
expect(editor.getCurrentPageId()).toBe(page1Id)
|
|
143
|
+
expect(editor.getShape(boxId)).toBeDefined()
|
|
144
|
+
|
|
145
|
+
editor.undo()
|
|
146
|
+
expect(editor.getShape(boxId)).toBeUndefined()
|
|
147
|
+
} finally {
|
|
148
|
+
editor.dispose()
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
-
import { Editor, react, TLCommentThread, useValue } from 'tldraw'
|
|
2
|
+
import { clamp, Editor, react, TLCommentThread, useValue } from 'tldraw'
|
|
3
3
|
import { computeClusterTable } from '../clustering/computeClusterTable'
|
|
4
4
|
import { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'
|
|
5
5
|
import type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'
|
|
@@ -169,11 +169,14 @@ export function useClusterModel(
|
|
|
169
169
|
for (const id of newlyMovedIds) next.add(id)
|
|
170
170
|
setHeldThreadIds(next)
|
|
171
171
|
}
|
|
172
|
+
const latestLeafIds = useMemo(
|
|
173
|
+
() => new Set(latestModel.table.leaves.map((leaf) => leaf.id)),
|
|
174
|
+
[latestModel]
|
|
175
|
+
)
|
|
172
176
|
// Local partition maintenance — the only non-zoom visual change. Any displayed leaf that has left the
|
|
173
177
|
// cluster input is detached from its badge in place; the corrected rebuild already sits in latestModel
|
|
174
178
|
// awaiting the next zoom-out. When the two models match, the leaf sets are identical, so skip the scan.
|
|
175
179
|
if (clusterModel !== latestModel) {
|
|
176
|
-
const latestLeafIds = new Set(latestModel.table.leaves.map((leaf) => leaf.id))
|
|
177
180
|
const removedLeafIds: string[] = []
|
|
178
181
|
for (const leaf of clusterModel.table.leaves) {
|
|
179
182
|
if (!latestLeafIds.has(leaf.id)) {
|
|
@@ -188,12 +191,7 @@ export function useClusterModel(
|
|
|
188
191
|
// never folds pins into clusters — merging is a zoom-out-only move, matching the runtime.
|
|
189
192
|
useEffect(() => {
|
|
190
193
|
if (heldThreadIds.size === 0) return
|
|
191
|
-
|
|
192
|
-
return react('rejoin moved comment pins on zoom out', () => {
|
|
193
|
-
const zoom = editor.getZoomLevel()
|
|
194
|
-
const prevZoom = lastZoom
|
|
195
|
-
lastZoom = zoom
|
|
196
|
-
if (zoom >= prevZoom) return
|
|
194
|
+
return reactOnZoomOut(editor, 'rejoin moved comment pins on zoom out', () => {
|
|
197
195
|
adoptOnRebuild.current = true
|
|
198
196
|
setHeldThreadIds(EMPTY_SET)
|
|
199
197
|
})
|
|
@@ -203,12 +201,7 @@ export function useClusterModel(
|
|
|
203
201
|
// splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).
|
|
204
202
|
useEffect(() => {
|
|
205
203
|
if (clusterModel === latestModel) return
|
|
206
|
-
|
|
207
|
-
return react('adopt pending cluster model on zoom out', () => {
|
|
208
|
-
const zoom = editor.getZoomLevel()
|
|
209
|
-
const prevZoom = lastZoom
|
|
210
|
-
lastZoom = zoom
|
|
211
|
-
if (zoom >= prevZoom) return
|
|
204
|
+
return reactOnZoomOut(editor, 'adopt pending cluster model on zoom out', (zoom) => {
|
|
212
205
|
latestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())
|
|
213
206
|
setRenderedModel(latestModel)
|
|
214
207
|
})
|
|
@@ -223,11 +216,10 @@ export function useClusterModel(
|
|
|
223
216
|
for (const node of clusterModel.runtime.getVisible().values()) {
|
|
224
217
|
for (const member of node.members) displayed.add(member)
|
|
225
218
|
}
|
|
226
|
-
|
|
227
|
-
return threads.filter((thread) => latestIds.has(thread.id) && !displayed.has(thread.id))
|
|
219
|
+
return threads.filter((thread) => latestLeafIds.has(thread.id) && !displayed.has(thread.id))
|
|
228
220
|
// The runtime mutates its partition in place; partitionVersion is its change stamp.
|
|
229
221
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
230
|
-
}, [clusterModel, latestModel, threads, partitionVersion])
|
|
222
|
+
}, [clusterModel, latestModel, latestLeafIds, threads, partitionVersion])
|
|
231
223
|
const heldThreads = useMemo(
|
|
232
224
|
() => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),
|
|
233
225
|
[threads, heldThreadIds, openId]
|
|
@@ -259,6 +251,17 @@ export function useClusterModel(
|
|
|
259
251
|
}
|
|
260
252
|
}
|
|
261
253
|
|
|
254
|
+
/** Run `onZoomOut` on each zoom change that decreased the zoom, starting from the current level. */
|
|
255
|
+
function reactOnZoomOut(editor: Editor, name: string, onZoomOut: (zoom: number) => void) {
|
|
256
|
+
let lastZoom = editor.getZoomLevel()
|
|
257
|
+
return react(name, () => {
|
|
258
|
+
const zoom = editor.getZoomLevel()
|
|
259
|
+
const prevZoom = lastZoom
|
|
260
|
+
lastZoom = zoom
|
|
261
|
+
if (zoom < prevZoom) onZoomOut(zoom)
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
262
265
|
/**
|
|
263
266
|
* Whether a position-only input change (ids equal, same order) moved a leaf folded inside a badge
|
|
264
267
|
* of the rendered partition — the one move the mid-drag freeze must let through.
|
|
@@ -342,6 +345,7 @@ export function revealThreadPin(
|
|
|
342
345
|
duration = 200
|
|
343
346
|
) {
|
|
344
347
|
if (thread.pageId !== editor.getCurrentPageId()) {
|
|
348
|
+
editor.markHistoryStoppingPoint('change-page')
|
|
345
349
|
editor.setCurrentPage(thread.pageId as any)
|
|
346
350
|
}
|
|
347
351
|
|
|
@@ -357,12 +361,7 @@ export function revealThreadPin(
|
|
|
357
361
|
Number.isFinite(parentEvent.zSplit) &&
|
|
358
362
|
parentEvent.zSplit <= zoomBounds.maxZoom
|
|
359
363
|
) {
|
|
360
|
-
|
|
361
|
-
parentEvent.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR,
|
|
362
|
-
zoomBounds.minZoom,
|
|
363
|
-
zoomBounds.maxZoom
|
|
364
|
-
)
|
|
365
|
-
centerOnPointAtZoom(editor, point, zoom, duration)
|
|
364
|
+
centerOnPointAtZoom(editor, point, splitZoom(parentEvent, zoomBounds), duration)
|
|
366
365
|
return
|
|
367
366
|
}
|
|
368
367
|
}
|
|
@@ -385,12 +384,12 @@ export function zoomToClusterSplit(
|
|
|
385
384
|
) {
|
|
386
385
|
const event = table.events.find((e) => e.result.id === node.id)
|
|
387
386
|
if (!event || !Number.isFinite(event.zSplit)) return
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
387
|
+
centerOnPointAtZoom(editor, node.centroid, splitZoom(event, zoomBounds), CLUSTER_EXPAND_ZOOM_MS)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The zoom to land on to see a merge event's cluster just after it splits. */
|
|
391
|
+
function splitZoom(event: MergeEvent, zoomBounds: ClusterZoomBounds): number {
|
|
392
|
+
return clamp(event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR, zoomBounds.minZoom, zoomBounds.maxZoom)
|
|
394
393
|
}
|
|
395
394
|
|
|
396
395
|
function findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {
|
|
@@ -415,7 +414,3 @@ function centerOnPointAtZoom(
|
|
|
415
414
|
{ animation: { duration } }
|
|
416
415
|
)
|
|
417
416
|
}
|
|
418
|
-
|
|
419
|
-
function clamp(value: number, min: number, max: number): number {
|
|
420
|
-
return Math.max(min, Math.min(max, value))
|
|
421
|
-
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { generateText, JSONContent } from '@tiptap/core'
|
|
1
|
+
import { type Extensions, generateText, JSONContent } from '@tiptap/core'
|
|
2
2
|
import { type CommentAuthor, createMentionExtension } from '@tldraw/mentions'
|
|
3
3
|
import { renderHtmlFromRichTextWithExtensions, TLRichText } from 'tldraw'
|
|
4
4
|
import { commentTipTapExtensions, isCommentEmpty } from '../ui/comment-extensions'
|
|
@@ -41,34 +41,46 @@ function hasMention(node: JSONContent): boolean {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
const htmlCache = new WeakMap<TLRichText, string>()
|
|
44
|
+
const textCache = new WeakMap<TLRichText, string>()
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
|
-
* Render a
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* from collapsing. `resolveName` maps a member id to its current name for any @mentions.
|
|
47
|
+
* Render a body through the comment extension set, memoized by body identity in `cache` unless the
|
|
48
|
+
* body carries a mention (see {@link hasMention}), in which case the mention extension resolving
|
|
49
|
+
* `resolveName` is added and nothing is cached.
|
|
50
50
|
*/
|
|
51
|
-
|
|
51
|
+
function renderWithCommentExtensions(
|
|
52
|
+
cache: WeakMap<TLRichText, string>,
|
|
52
53
|
richText: TLRichText,
|
|
53
|
-
resolveName
|
|
54
|
+
resolveName: ((id: string) => string | undefined) | undefined,
|
|
55
|
+
render: (doc: JSONContent, extensions: Extensions) => string
|
|
54
56
|
): string {
|
|
55
57
|
const mentions = hasMention(richText as JSONContent)
|
|
56
58
|
if (!mentions) {
|
|
57
|
-
const cached =
|
|
59
|
+
const cached = cache.get(richText)
|
|
58
60
|
if (cached !== undefined) return cached
|
|
59
61
|
}
|
|
60
62
|
const extensions = mentions
|
|
61
63
|
? [...commentTipTapExtensions, createMentionExtension({ resolveName })]
|
|
62
64
|
: commentTipTapExtensions
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
)
|
|
67
|
-
if (!mentions) htmlCache.set(richText, html)
|
|
68
|
-
return html
|
|
65
|
+
const result = render(demoteHeadings(richText as JSONContent), extensions)
|
|
66
|
+
if (!mentions) cache.set(richText, result)
|
|
67
|
+
return result
|
|
69
68
|
}
|
|
70
69
|
|
|
71
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Render a comment body to HTML through the limited comment extension set (no headings), so a body
|
|
72
|
+
* always renders with comment formatting regardless of the host editor's rich-text config. Renders
|
|
73
|
+
* through tldraw's shared helper, so it gets the same empty-paragraph fix that keeps blank lines
|
|
74
|
+
* from collapsing. `resolveName` maps a member id to its current name for any @mentions.
|
|
75
|
+
*/
|
|
76
|
+
export function renderCommentHtml(
|
|
77
|
+
richText: TLRichText,
|
|
78
|
+
resolveName?: (id: string) => string | undefined
|
|
79
|
+
): string {
|
|
80
|
+
return renderWithCommentExtensions(htmlCache, richText, resolveName, (doc, extensions) =>
|
|
81
|
+
renderHtmlFromRichTextWithExtensions(doc as TLRichText, extensions)
|
|
82
|
+
)
|
|
83
|
+
}
|
|
72
84
|
|
|
73
85
|
/**
|
|
74
86
|
* Flatten a comment body to plaintext through the limited comment extension set — paragraphs and
|
|
@@ -80,17 +92,7 @@ export function renderCommentPlaintext(
|
|
|
80
92
|
resolveName?: (id: string) => string | undefined
|
|
81
93
|
): string {
|
|
82
94
|
if (isCommentEmpty(richText)) return ''
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (cached !== undefined) return cached
|
|
87
|
-
}
|
|
88
|
-
const extensions = mentions
|
|
89
|
-
? [...commentTipTapExtensions, createMentionExtension({ resolveName })]
|
|
90
|
-
: commentTipTapExtensions
|
|
91
|
-
const text = generateText(demoteHeadings(richText as JSONContent), extensions, {
|
|
92
|
-
blockSeparator: '\n',
|
|
93
|
-
})
|
|
94
|
-
if (!mentions) textCache.set(richText, text)
|
|
95
|
-
return text
|
|
95
|
+
return renderWithCommentExtensions(textCache, richText, resolveName, (doc, extensions) =>
|
|
96
|
+
generateText(doc, extensions, { blockSeparator: '\n' })
|
|
97
|
+
)
|
|
96
98
|
}
|
|
@@ -33,6 +33,12 @@ export function getCommentRecord(editor: Editor, id: string): TLCommentRecord |
|
|
|
33
33
|
return undefined
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Read one comment thread by id, or `undefined` if the id isn't a present thread. @internal */
|
|
37
|
+
export function getCommentThread(editor: Editor, id: string): TLCommentThread | undefined {
|
|
38
|
+
const record = getCommentRecord(editor, id)
|
|
39
|
+
return record?.typeName === 'comment-thread' ? record : undefined
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
/**
|
|
37
43
|
* Every comment thread in the store, **including soft-deleted and emptied ones** awaiting the
|
|
38
44
|
* server's prune, which nothing renders. For the set the UI shows, use {@link getLiveCommentThreads}.
|
|
@@ -41,8 +47,7 @@ export function getCommentRecord(editor: Editor, id: string): TLCommentRecord |
|
|
|
41
47
|
* @public
|
|
42
48
|
*/
|
|
43
49
|
export function getCommentThreads(editor: Editor): TLCommentThread[] {
|
|
44
|
-
|
|
45
|
-
return editor.store.query.records(typeName).get() as unknown as TLCommentThread[]
|
|
50
|
+
return queryCommentRecords<TLCommentThread>(editor, 'comment-thread')
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
/**
|
|
@@ -53,8 +58,7 @@ export function getCommentThreads(editor: Editor): TLCommentThread[] {
|
|
|
53
58
|
* @public
|
|
54
59
|
*/
|
|
55
60
|
export function getComments(editor: Editor): TLComment[] {
|
|
56
|
-
|
|
57
|
-
return editor.store.query.records(typeName).get() as unknown as TLComment[]
|
|
61
|
+
return queryCommentRecords<TLComment>(editor, 'comment')
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
/**
|
|
@@ -85,6 +89,12 @@ export function getLiveCommentThreads(editor: Editor): TLCommentThread[] {
|
|
|
85
89
|
|
|
86
90
|
/** All comment reactions currently in the store (non-reactive; wrap in `useValue` to react). @public */
|
|
87
91
|
export function getCommentReactions(editor: Editor): TLCommentReaction[] {
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
return queryCommentRecords<TLCommentReaction>(editor, 'comment-reaction')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function queryCommentRecords<T extends TLCommentRecord>(
|
|
96
|
+
editor: Editor,
|
|
97
|
+
typeName: T['typeName']
|
|
98
|
+
): T[] {
|
|
99
|
+
return editor.store.query.records(typeName as TLRecord['typeName']).get() as unknown as T[]
|
|
90
100
|
}
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
useClusterModel,
|
|
12
12
|
zoomToClusterSplit,
|
|
13
13
|
} from './cluster-model'
|
|
14
|
-
import { getCommentRecord } from './comment-store'
|
|
14
|
+
import { getCommentRecord, getCommentThread } from './comment-store'
|
|
15
15
|
import { type CommentingContext } from './context'
|
|
16
16
|
import { useCommentThreads } from './hooks'
|
|
17
17
|
import { useCommentingEnabled } from './license'
|
|
@@ -172,37 +172,36 @@ function CanvasCommentsLayer(props: CommentingContext) {
|
|
|
172
172
|
const record = getCommentRecord(editor, id)
|
|
173
173
|
if (!record) return null
|
|
174
174
|
const thread =
|
|
175
|
-
record.typeName === 'comment' ?
|
|
175
|
+
record.typeName === 'comment' ? getCommentThread(editor, record.threadId) : record
|
|
176
176
|
return thread?.typeName === 'comment-thread' ? thread : null
|
|
177
177
|
},
|
|
178
178
|
[editor]
|
|
179
179
|
)
|
|
180
180
|
|
|
181
|
-
//
|
|
182
|
-
// folded into a badge
|
|
181
|
+
// Bring a thread's pin into view (zooming to the first cluster split that unfolds it if it's
|
|
182
|
+
// folded into a badge) and open it.
|
|
183
|
+
const revealAndOpenThread = useCallback(
|
|
184
|
+
(thread: TLCommentThread, duration?: number) => {
|
|
185
|
+
revealThreadPin(editor, thread, clusterModel.table, clusterZoomBounds, options, duration)
|
|
186
|
+
openThreadId.set(editor, thread.id)
|
|
187
|
+
},
|
|
188
|
+
[clusterModel.table, clusterZoomBounds, editor, options]
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
// Serve a pending reveal request. Also unhides pins: the popover opens on the layer that hiding
|
|
192
|
+
// withholds.
|
|
183
193
|
useEffect(() => {
|
|
184
194
|
if (!requestedRevealThread) return
|
|
185
195
|
revealThreadRequest.set(editor, null)
|
|
186
196
|
commentsHidden.set(editor, false)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}, [requestedRevealThread, clusterModel.table, clusterZoomBounds, editor, options])
|
|
197
|
+
revealAndOpenThread(requestedRevealThread)
|
|
198
|
+
}, [requestedRevealThread, revealAndOpenThread, editor])
|
|
190
199
|
|
|
191
200
|
// Picking a thread out of a cluster's hover preview. `openThreadId` alone would work, but would
|
|
192
201
|
// cut straight there from the badge — zoom in first, the same move the badge's own click makes.
|
|
193
202
|
const revealClusteredThread = useCallback(
|
|
194
|
-
(thread: TLCommentThread) =>
|
|
195
|
-
|
|
196
|
-
editor,
|
|
197
|
-
thread,
|
|
198
|
-
clusterModel.table,
|
|
199
|
-
clusterZoomBounds,
|
|
200
|
-
options,
|
|
201
|
-
CLUSTER_EXPAND_ZOOM_MS
|
|
202
|
-
)
|
|
203
|
-
openThreadId.set(editor, thread.id)
|
|
204
|
-
},
|
|
205
|
-
[clusterModel.table, clusterZoomBounds, editor, options]
|
|
203
|
+
(thread: TLCommentThread) => revealAndOpenThread(thread, CLUSTER_EXPAND_ZOOM_MS),
|
|
204
|
+
[revealAndOpenThread]
|
|
206
205
|
)
|
|
207
206
|
|
|
208
207
|
const expandCluster = useCallback(
|
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import { ReactNode, useCallback } from 'react'
|
|
2
|
-
import {
|
|
3
|
-
EditorPortal,
|
|
4
|
-
TLComment,
|
|
5
|
-
TLCommentThread,
|
|
6
|
-
useEditor,
|
|
7
|
-
useTranslation,
|
|
8
|
-
useValue,
|
|
9
|
-
} from 'tldraw'
|
|
2
|
+
import { EditorPortal, TLCommentThread, useEditor, useTranslation, useValue } from 'tldraw'
|
|
10
3
|
import { CommentListItemProps, CommentListItemRenderProps, CommentsList } from '../ui/comments-list'
|
|
11
4
|
import { UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
|
12
5
|
import { CommentsFilterMenu } from './comments-filter-menu'
|
|
13
6
|
import { CommentsVisibilityToggle } from './comments-visibility-toggle'
|
|
14
7
|
import { type CommentingContext } from './context'
|
|
15
|
-
import {
|
|
8
|
+
import { getCommentsByThread, useCommentThreads } from './hooks'
|
|
16
9
|
import { useCommentingEnabled } from './license'
|
|
17
10
|
import { useCommentingOptions } from './options'
|
|
18
11
|
import { richTextToPlaintext } from './rich-text'
|
|
@@ -53,7 +46,7 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
|
|
|
53
46
|
const commentingEnabled = useCommentingEnabled()
|
|
54
47
|
const msg = useTranslation()
|
|
55
48
|
const threads = useCommentThreads(editor)
|
|
56
|
-
const
|
|
49
|
+
const byThread = useValue('comments by thread', () => getCommentsByThread(editor).get(), [editor])
|
|
57
50
|
const currentPageId = useValue('page id', () => editor.getCurrentPageId(), [editor])
|
|
58
51
|
const open = useValue('sidebar open', () => commentsSidebarOpen.get(editor), [editor])
|
|
59
52
|
const openId = useValue('open thread', () => openThreadId.get(editor), [editor])
|
|
@@ -66,14 +59,6 @@ export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
|
|
|
66
59
|
|
|
67
60
|
if (!commentingEnabled || !open) return null
|
|
68
61
|
|
|
69
|
-
// Group comments by thread (they arrive oldest-first, so [0] is each thread's first comment).
|
|
70
|
-
const byThread = new Map<string, TLComment[]>()
|
|
71
|
-
for (const comment of comments) {
|
|
72
|
-
const list = byThread.get(comment.threadId) ?? []
|
|
73
|
-
list.push(comment)
|
|
74
|
-
byThread.set(comment.threadId, list)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
62
|
// Page scoping is treated as scoping, not a filter: an empty page reads "no comments yet",
|
|
78
63
|
// while a list emptied by the toggles below reads "nothing matches your filters".
|
|
79
64
|
const pageThreads = threads.filter(
|
package/src/canvas/hooks.ts
CHANGED
|
@@ -30,7 +30,7 @@ export function useCommentThreads(editor: Editor): TLCommentThread[] {
|
|
|
30
30
|
*/
|
|
31
31
|
const commentsByThread = new WeakCache<Editor, Computed<Map<TLCommentThreadId, TLComment[]>>>()
|
|
32
32
|
|
|
33
|
-
/**
|
|
33
|
+
/** Live comments grouped by thread id, cached per editor. @internal */
|
|
34
34
|
export function getCommentsByThread(editor: Editor) {
|
|
35
35
|
return commentsByThread.get(editor, () =>
|
|
36
36
|
computed('comments by thread', (prev) => {
|