snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
290
packages/commenting/src/canvas/anchor-lifecycle.test.ts
Normal file
290
packages/commenting/src/canvas/anchor-lifecycle.test.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
commentSchemaRecords,
|
||||
createCommentThread,
|
||||
createShapeId,
|
||||
createTLSchema,
|
||||
createTLStore,
|
||||
defaultBindingUtils,
|
||||
defaultShapeUtils,
|
||||
defaultTools,
|
||||
Editor,
|
||||
toRichText,
|
||||
createComment as tlCreateComment,
|
||||
TLPageId,
|
||||
TLShapeId,
|
||||
} from 'tldraw'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { registerCommentAnchorLifecycle } from './anchor-lifecycle'
|
||||
import { commitCommentMutation, putCommentRecords } from './comment-mutations'
|
||||
import { getCommentRecord, getComments } from './comment-store'
|
||||
import { CommentTool } from './comment-tool'
|
||||
|
||||
/**
|
||||
* These tests need a real editor: the behavior under test is the interplay between store
|
||||
* side effects (the beforeDelete snapshot, operation-complete settlement) and real editor
|
||||
* operations like `deleteShapes` and `moveShapesToPage` — exactly what a stub can't model.
|
||||
*/
|
||||
|
||||
let editor: Editor
|
||||
let dispose: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
tools: defaultTools,
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
dispose = registerCommentAnchorLifecycle(editor)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
dispose()
|
||||
editor.dispose()
|
||||
})
|
||||
|
||||
function makeShape(x = 100, y = 100, w = 100, h = 50): TLShapeId {
|
||||
const id = createShapeId()
|
||||
editor.createShape({ id, type: 'geo', x, y, props: { w, h } })
|
||||
return id
|
||||
}
|
||||
|
||||
function makeThread(shapeId: TLShapeId, anchorX = 0.5, anchorY = 0.5, isPrecise = true) {
|
||||
const thread = createCommentThread({
|
||||
pageId: editor.getCurrentPageId(),
|
||||
anchor: { type: 'shape', shapeId, x: anchorX, y: anchorY, isPrecise },
|
||||
createdBy: 'me',
|
||||
})
|
||||
const comment = tlCreateComment({
|
||||
threadId: thread.id,
|
||||
pageId: thread.pageId,
|
||||
authorId: 'me',
|
||||
body: toRichText('hello'),
|
||||
})
|
||||
putCommentRecords(editor, [thread, comment])
|
||||
return { thread, comment }
|
||||
}
|
||||
|
||||
function makeSecondPage(): TLPageId {
|
||||
editor.createPage({ name: 'page 2' })
|
||||
return editor.getPages()[1].id
|
||||
}
|
||||
|
||||
describe('shape deletion', () => {
|
||||
it('converts a precise shape anchor to a point anchor where the pin sat', () => {
|
||||
const shapeId = makeShape(100, 100, 100, 50)
|
||||
const { thread } = makeThread(shapeId, 0.5, 0.5, true)
|
||||
|
||||
editor.deleteShape(shapeId)
|
||||
|
||||
const updated = getCommentRecord(editor, thread.id)!
|
||||
expect(updated.typeName).toBe('comment-thread')
|
||||
expect((updated as typeof thread).anchor).toEqual({ type: 'point', x: 150, y: 125 })
|
||||
})
|
||||
|
||||
it('converts an imprecise shape anchor where the inset pin is drawn, not at the raw corner', () => {
|
||||
const shapeId = makeShape(100, 100, 100, 50)
|
||||
const { thread } = makeThread(shapeId, 0.2, 0.9, false)
|
||||
|
||||
editor.deleteShape(shapeId)
|
||||
|
||||
// Top-right corner (200, 100) plus the 20px screen inset toward the shape's centre.
|
||||
const updated = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updated.anchor).toEqual({ type: 'point', x: 180, y: 120 })
|
||||
})
|
||||
|
||||
it('resolves the imprecise spot from the commenting options when no override is passed', () => {
|
||||
const configured = new Editor({
|
||||
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
tools: [...defaultTools, CommentTool.configure({ impreciseShapeAnchor: { x: 0, y: 1 } })],
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
const disposeConfigured = registerCommentAnchorLifecycle(configured)
|
||||
try {
|
||||
const shapeId = createShapeId()
|
||||
configured.createShape({ id: shapeId, type: 'geo', x: 100, y: 100, props: { w: 100, h: 50 } })
|
||||
const thread = createCommentThread({
|
||||
pageId: configured.getCurrentPageId(),
|
||||
anchor: { type: 'shape', shapeId, x: 0.5, y: 0.5, isPrecise: false },
|
||||
createdBy: 'me',
|
||||
})
|
||||
putCommentRecords(configured, [thread])
|
||||
|
||||
configured.deleteShape(shapeId)
|
||||
|
||||
// Bottom-left corner (100, 150) plus the inset toward the shape's centre.
|
||||
const updated = getCommentRecord(configured, thread.id) as typeof thread
|
||||
expect(updated.anchor).toEqual({ type: 'point', x: 120, y: 130 })
|
||||
} finally {
|
||||
disposeConfigured()
|
||||
configured.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('converts anchors on children when their frame is deleted with them', () => {
|
||||
const frameId = createShapeId()
|
||||
editor.createShape({ id: frameId, type: 'frame', x: 0, y: 0, props: { w: 400, h: 400 } })
|
||||
const childId = createShapeId()
|
||||
editor.createShape({
|
||||
id: childId,
|
||||
type: 'geo',
|
||||
parentId: frameId,
|
||||
x: 50,
|
||||
y: 50,
|
||||
props: { w: 100, h: 100 },
|
||||
})
|
||||
const { thread } = makeThread(childId, 0.5, 0.5, true)
|
||||
|
||||
editor.deleteShape(frameId)
|
||||
|
||||
const updated = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updated.anchor).toEqual({ type: 'point', x: 100, y: 100 })
|
||||
})
|
||||
|
||||
it('re-attaches the thread when the delete is undone', () => {
|
||||
const shapeId = makeShape(100, 100, 100, 50)
|
||||
const { thread } = makeThread(shapeId, 0.5, 0.5, true)
|
||||
editor.markHistoryStoppingPoint()
|
||||
|
||||
editor.deleteShape(shapeId)
|
||||
expect((getCommentRecord(editor, thread.id) as typeof thread).anchor.type).toBe('point')
|
||||
|
||||
editor.undo()
|
||||
|
||||
const updated = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updated.anchor).toEqual(thread.anchor)
|
||||
})
|
||||
|
||||
it('keeps the restore when a returning shape cannot resolve its page yet', () => {
|
||||
const frameId = createShapeId()
|
||||
editor.createShape({ id: frameId, type: 'frame', x: 0, y: 0, props: { w: 400, h: 400 } })
|
||||
const childId = createShapeId()
|
||||
editor.createShape({
|
||||
id: childId,
|
||||
type: 'geo',
|
||||
parentId: frameId,
|
||||
x: 50,
|
||||
y: 50,
|
||||
props: { w: 100, h: 100 },
|
||||
})
|
||||
const { thread } = makeThread(childId, 0.5, 0.5, true)
|
||||
const childRecord = editor.getShape(childId)!
|
||||
const frameRecord = editor.getShape(frameId)!
|
||||
|
||||
editor.deleteShape(frameId)
|
||||
expect((getCommentRecord(editor, thread.id) as typeof thread).anchor.type).toBe('point')
|
||||
|
||||
// The child returns in an earlier operation than its parent, so its ancestor page can't
|
||||
// resolve when it first reappears.
|
||||
editor.store.put([childRecord])
|
||||
editor.store.put([frameRecord])
|
||||
|
||||
const updated = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updated.anchor).toEqual(thread.anchor)
|
||||
})
|
||||
|
||||
it('re-attaches on undo even when the lifecycle was re-registered in between', () => {
|
||||
const shapeId = makeShape(100, 100, 100, 50)
|
||||
const { thread } = makeThread(shapeId, 0.5, 0.5, true)
|
||||
editor.markHistoryStoppingPoint()
|
||||
|
||||
editor.deleteShape(shapeId)
|
||||
expect((getCommentRecord(editor, thread.id) as typeof thread).anchor.type).toBe('point')
|
||||
|
||||
// The registering effect re-runs whenever its inputs change identity; conversion memory
|
||||
// must not live and die with any one registration.
|
||||
dispose()
|
||||
dispose = registerCommentAnchorLifecycle(editor)
|
||||
|
||||
editor.undo()
|
||||
|
||||
const updated = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updated.anchor).toEqual(thread.anchor)
|
||||
})
|
||||
|
||||
it('does not re-attach on undo when the pin was moved after the conversion', () => {
|
||||
const shapeId = makeShape(100, 100, 100, 50)
|
||||
const { thread } = makeThread(shapeId, 0.5, 0.5, true)
|
||||
editor.markHistoryStoppingPoint()
|
||||
|
||||
editor.deleteShape(shapeId)
|
||||
const converted = getCommentRecord(editor, thread.id) as typeof thread
|
||||
commitCommentMutation(editor, ({ put }) =>
|
||||
put([{ ...converted, anchor: { type: 'point', x: 900, y: 900 } }])
|
||||
)
|
||||
|
||||
editor.undo()
|
||||
|
||||
const updated = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updated.anchor).toEqual({ type: 'point', x: 900, y: 900 })
|
||||
})
|
||||
|
||||
it('leaves threads on other shapes untouched', () => {
|
||||
const shapeId = makeShape()
|
||||
const otherId = makeShape(300, 300)
|
||||
const { thread } = makeThread(otherId)
|
||||
|
||||
editor.deleteShape(shapeId)
|
||||
|
||||
const updated = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updated.anchor.type).toBe('shape')
|
||||
})
|
||||
})
|
||||
|
||||
describe('moving a shape to another page', () => {
|
||||
it('re-homes the thread and its comments to the new page, keeping the shape anchor', () => {
|
||||
const shapeId = makeShape()
|
||||
const { thread, comment } = makeThread(shapeId)
|
||||
const page2 = makeSecondPage()
|
||||
|
||||
editor.moveShapesToPage([shapeId], page2)
|
||||
|
||||
const updatedThread = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updatedThread.pageId).toBe(page2)
|
||||
expect(updatedThread.anchor).toEqual(thread.anchor)
|
||||
const updatedComment = getComments(editor).find((c) => c.id === comment.id)!
|
||||
expect(updatedComment.pageId).toBe(page2)
|
||||
})
|
||||
|
||||
it('re-homes threads anchored to children of a moved frame', () => {
|
||||
const frameId = createShapeId()
|
||||
editor.createShape({ id: frameId, type: 'frame', x: 0, y: 0, props: { w: 400, h: 400 } })
|
||||
const childId = createShapeId()
|
||||
editor.createShape({
|
||||
id: childId,
|
||||
type: 'geo',
|
||||
parentId: frameId,
|
||||
x: 50,
|
||||
y: 50,
|
||||
props: { w: 100, h: 100 },
|
||||
})
|
||||
const { thread } = makeThread(childId)
|
||||
const page2 = makeSecondPage()
|
||||
|
||||
editor.moveShapesToPage([frameId], page2)
|
||||
|
||||
const updatedThread = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updatedThread.pageId).toBe(page2)
|
||||
expect(updatedThread.anchor.type).toBe('shape')
|
||||
})
|
||||
|
||||
it('re-homes the thread back when the move is undone', () => {
|
||||
const shapeId = makeShape()
|
||||
const { thread } = makeThread(shapeId)
|
||||
const page1 = editor.getCurrentPageId()
|
||||
const page2 = makeSecondPage()
|
||||
editor.markHistoryStoppingPoint()
|
||||
|
||||
editor.moveShapesToPage([shapeId], page2)
|
||||
expect((getCommentRecord(editor, thread.id) as typeof thread).pageId).toBe(page2)
|
||||
|
||||
editor.undo()
|
||||
|
||||
const updatedThread = getCommentRecord(editor, thread.id) as typeof thread
|
||||
expect(updatedThread.pageId).toBe(page1)
|
||||
expect(updatedThread.anchor.type).toBe('shape')
|
||||
})
|
||||
})
|
||||
190
packages/commenting/src/canvas/anchor-lifecycle.ts
Normal file
190
packages/commenting/src/canvas/anchor-lifecycle.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { WeakCache } from '@tldraw/utils'
|
||||
import { Editor, TLCommentAnchor, TLCommentThread, TLPageId, TLShapeId, VecLike } from 'tldraw'
|
||||
import { commitCommentMutation } from './comment-mutations'
|
||||
import { getComments, getCommentThread, getCommentThreads, TLCommentRecord } from './comment-store'
|
||||
import { anchorPagePoint, impreciseShapePinInset } from './thread-state'
|
||||
|
||||
type ShapeAnchor = Extract<TLCommentAnchor, { type: 'shape' }>
|
||||
|
||||
/**
|
||||
* Threads converted to point anchors because their shape was deleted, kept so the anchor can be
|
||||
* restored if the shape comes back. Owned by the editor, not the registration closure: the
|
||||
* registering effect can re-run, and an undo can arrive long after the delete.
|
||||
*/
|
||||
const convertedByShapeCache = new WeakCache<
|
||||
Editor,
|
||||
Map<TLShapeId, { threadId: string; anchor: ShapeAnchor; point: VecLike }[]>
|
||||
>()
|
||||
|
||||
function isAnchoredToShape(
|
||||
thread: TLCommentThread,
|
||||
shapeId: TLShapeId
|
||||
): thread is TLCommentThread & { anchor: ShapeAnchor } {
|
||||
const anchor = thread.anchor
|
||||
return anchor.type === 'shape' && anchor.shapeId === shapeId
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep shape-anchored threads alive across their shape's lifecycle:
|
||||
*
|
||||
* - When the shape is deleted, the thread converts to a `point` anchor where its pin last sat, so
|
||||
* the conversation outlives the shape instead of becoming invisible.
|
||||
* - When the shape moves to another page, the thread follows: its `pageId` and each comment's
|
||||
* denormalized `pageId` update, and the anchor keeps riding the shape.
|
||||
* - When a deleted shape comes back, the thread re-attaches — unless its pin was manually moved in
|
||||
* the meantime, in which case the manual placement wins.
|
||||
*
|
||||
* A page move is `deleteShapes` + re-create with preserved ids inside one `editor.run`, but each
|
||||
* store write is its own operation, so "this shape is being moved" is never observable as a single
|
||||
* event. The handlers therefore cooperate across operations: `beforeDelete` snapshots where each
|
||||
* affected pin sits, the operation-complete pass converts threads whose shape is really gone, and
|
||||
* `afterCreate` plus that same pass restore the anchor once the store has settled. Undo/redo of a
|
||||
* move replays as a `parentId` update, so `afterChange` re-homes threads on cross-page reparents —
|
||||
* including threads anchored to descendants, which move without change events of their own.
|
||||
*
|
||||
* Remote changes are ignored: the client that performed the operation runs this same maintenance
|
||||
* and syncs the result. Writes honour the {@link CommentingOptions.history} option.
|
||||
*
|
||||
* Registered by `CanvasComments` on mount; parts-built consumers can call this directly. Returns a
|
||||
* cleanup function that unregisters all handlers.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function registerCommentAnchorLifecycle(editor: Editor): () => void {
|
||||
// Pin positions snapshotted during the current operation: shape id -> (thread id -> pin page
|
||||
// point).
|
||||
const pendingByShape = new Map<TLShapeId, Map<string, VecLike | null>>()
|
||||
const convertedByShape = convertedByShapeCache.get(editor, () => new Map())
|
||||
|
||||
function rehomeThread(thread: TLCommentThread, pageId: TLPageId, updates: TLCommentRecord[]) {
|
||||
updates.push({ ...thread, pageId })
|
||||
for (const comment of getComments(editor)) {
|
||||
if (comment.threadId === thread.id) updates.push({ ...comment, pageId })
|
||||
}
|
||||
}
|
||||
|
||||
const disposeBeforeDelete = editor.sideEffects.registerBeforeDeleteHandler(
|
||||
'shape',
|
||||
(shape, source) => {
|
||||
if (source === 'remote') return
|
||||
for (const thread of getCommentThreads(editor)) {
|
||||
if (!isAnchoredToShape(thread, shape.id)) continue
|
||||
let snapshot = pendingByShape.get(shape.id)
|
||||
if (!snapshot) {
|
||||
snapshot = new Map()
|
||||
pendingByShape.set(shape.id, snapshot)
|
||||
}
|
||||
// Snapshot where the pin is drawn, not just the anchor point: imprecise shape pins render inset
|
||||
// toward the shape's centre, so without baking the inset in at the current zoom the pin would jump.
|
||||
let point = anchorPagePoint(editor, thread.anchor)
|
||||
const inset = impreciseShapePinInset(editor, thread.anchor)
|
||||
if (point && inset) {
|
||||
const zoom = editor.getZoomLevel()
|
||||
point = { x: point.x + inset.x / zoom, y: point.y + inset.y / zoom }
|
||||
}
|
||||
snapshot.set(thread.id, point)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Shape ids from `convertedByShape` re-created during the current operation. Settled at operation
|
||||
// complete rather than in `afterCreate`: creation order within an operation is arbitrary, so a shape
|
||||
// can appear before its parent and not resolve an ancestor page yet.
|
||||
const returnedShapeIds = new Set<TLShapeId>()
|
||||
|
||||
const disposeOperationComplete = editor.sideEffects.registerOperationCompleteHandler((source) => {
|
||||
// Only user-sourced handlers populate the maps and only local operations should settle
|
||||
// them; a remote operation completing leaves any (impossible-in-practice) leftovers for
|
||||
// the next local settle rather than judging them against remote state.
|
||||
if (source === 'remote') return
|
||||
if (pendingByShape.size === 0 && returnedShapeIds.size === 0) return
|
||||
const settled = [...pendingByShape.entries()]
|
||||
pendingByShape.clear()
|
||||
const returned = [...returnedShapeIds]
|
||||
returnedShapeIds.clear()
|
||||
|
||||
const updates: TLCommentRecord[] = []
|
||||
for (const [shapeId, threadPoints] of settled) {
|
||||
if (editor.getShape(shapeId)) continue
|
||||
for (const [threadId, point] of threadPoints) {
|
||||
if (!point) continue
|
||||
const thread = getCommentThread(editor, threadId)
|
||||
if (!thread || !isAnchoredToShape(thread, shapeId)) continue
|
||||
let converted = convertedByShape.get(shapeId)
|
||||
if (!converted) {
|
||||
converted = []
|
||||
convertedByShape.set(shapeId, converted)
|
||||
}
|
||||
converted.push({ threadId, anchor: thread.anchor, point })
|
||||
updates.push({ ...thread, anchor: { type: 'point', x: point.x, y: point.y } })
|
||||
}
|
||||
}
|
||||
|
||||
for (const shapeId of returned) {
|
||||
const shape = editor.getShape(shapeId)
|
||||
if (!shape) continue
|
||||
const converted = convertedByShape.get(shapeId)
|
||||
if (!converted) continue
|
||||
convertedByShape.delete(shapeId)
|
||||
|
||||
const pageId = editor.getAncestorPageId(shape)
|
||||
for (const { threadId, anchor, point } of converted) {
|
||||
const thread = getCommentThread(editor, threadId)
|
||||
if (!thread) continue
|
||||
// Re-attach only threads still sitting exactly where the conversion left them — a
|
||||
// pin moved since then was placed deliberately, and that placement wins.
|
||||
const current = thread.anchor
|
||||
if (current.type !== 'point' || current.x !== point.x || current.y !== point.y) {
|
||||
continue
|
||||
}
|
||||
if (!pageId || thread.pageId === pageId) {
|
||||
updates.push({ ...thread, anchor })
|
||||
} else {
|
||||
rehomeThread({ ...thread, anchor }, pageId, updates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
commitCommentMutation(editor, ({ put }) => put(updates))
|
||||
}
|
||||
})
|
||||
|
||||
const disposeAfterCreate = editor.sideEffects.registerAfterCreateHandler(
|
||||
'shape',
|
||||
(shape, source) => {
|
||||
if (source === 'remote') return
|
||||
if (convertedByShape.has(shape.id)) returnedShapeIds.add(shape.id)
|
||||
}
|
||||
)
|
||||
|
||||
const disposeAfterChange = editor.sideEffects.registerAfterChangeHandler(
|
||||
'shape',
|
||||
(prev, next, source) => {
|
||||
if (source === 'remote') return
|
||||
if (prev.parentId === next.parentId) return
|
||||
const pageId = editor.getAncestorPageId(next)
|
||||
if (!pageId) return
|
||||
// Descendants move with their parent without change events of their own.
|
||||
const movedIds = editor.getShapeAndDescendantIds([next.id])
|
||||
const updates: TLCommentRecord[] = []
|
||||
for (const thread of getCommentThreads(editor)) {
|
||||
const anchor = thread.anchor
|
||||
if (anchor.type !== 'shape') continue
|
||||
if (!movedIds.has(anchor.shapeId)) continue
|
||||
if (thread.pageId === pageId) continue
|
||||
rehomeThread(thread, pageId, updates)
|
||||
}
|
||||
if (updates.length > 0) {
|
||||
commitCommentMutation(editor, ({ put }) => put(updates))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
disposeBeforeDelete()
|
||||
disposeOperationComplete()
|
||||
disposeAfterCreate()
|
||||
disposeAfterChange()
|
||||
}
|
||||
}
|
||||
18
packages/commenting/src/canvas/canvas-events.ts
Normal file
18
packages/commenting/src/canvas/canvas-events.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { type PointerEvent as ReactPointerEvent } from 'react'
|
||||
import { Editor } from 'tldraw'
|
||||
|
||||
/** A pointer-down that belongs to the camera, not the comment UI: any non-primary button
|
||||
* (middle/right-button pans), or a primary press with the spacebar pan key held. */
|
||||
export function isCanvasPanGesture(editor: Editor, e: ReactPointerEvent) {
|
||||
return e.button !== 0 || editor.inputs.keys.has('Space')
|
||||
}
|
||||
|
||||
/** Hand a pointer event to the canvas beneath the comments layer, marked the same way the
|
||||
* pass-through wheel/hover hooks mark their re-dispatched events. */
|
||||
export function forwardPointerEventToCanvas(container: HTMLElement, e: ReactPointerEvent) {
|
||||
const cvs = container.querySelector('.tl-canvas')
|
||||
if (!cvs) return
|
||||
const newEvent = new PointerEvent(e.type, e.nativeEvent as any)
|
||||
;(newEvent as any).isSpecialRedispatchedEvent = true
|
||||
cvs.dispatchEvent(newEvent)
|
||||
}
|
||||
557
packages/commenting/src/canvas/canvas.css
Normal file
557
packages/commenting/src/canvas/canvas.css
Normal file
@@ -0,0 +1,557 @@
|
||||
/* The comments layer renders through `EditorPortal`, so its pieces can pick their own layer rather
|
||||
than the one the component is mounted in. Pins live in the canvas-in-front layer (beneath the UI
|
||||
panels at z-index 300), so the toolbar and style panel draw over them; the open thread's popover
|
||||
goes to the menus layer instead (`.tlui-cmt-canvas-popover`) so it isn't clipped. Click-through
|
||||
except on its own children. */
|
||||
.tlui-cmt-canvas-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/* Just below the collaborator cursor layer, so cursors ride over the pins — and below the
|
||||
in-front content (250), so active tool UI covers both. Derived, not a literal, so the
|
||||
ordering can't silently drift if the cursor slot moves. */
|
||||
z-index: calc(var(--tl-layer-collaborators) - 5);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tlui-cmt-canvas-pin,
|
||||
.tlui-cmt-canvas-cluster,
|
||||
.tlui-cmt-canvas-composer {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.tlui-cmt-canvas-composer {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The pin's only interactive surface is the square hit layer below — the wrapper itself is
|
||||
inert. Its box sits at the anchor point, not over the (translated) visual, so letting it take
|
||||
events would put a phantom hit zone beside the pin. */
|
||||
.tlui-cmt-canvas-pin {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tlui-cmt-button {
|
||||
appearance: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Anchored by its bottom-left corner like a pin, so the badge sits up-and-right of the cluster's
|
||||
centroid — the same visual-to-anchor relationship a single comment pin has. */
|
||||
.tlui-cmt-canvas-cluster {
|
||||
transform: translate(0, -100%);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Keyboard focus has to be visible on the canvas, where there's no surrounding chrome to imply
|
||||
it. The ring goes on the marker visual (the rounded pin/badge), not the button box, so it
|
||||
traces the shape rather than a rectangle around it; the button's own outline is suppressed
|
||||
because its box is offset from what you see. */
|
||||
.tlui-cmt-canvas-pin__marker:focus-visible,
|
||||
.tlui-cmt-canvas-cluster:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.tlui-cmt-canvas-pin__marker:focus-visible .tlui-cmt-marker,
|
||||
.tlui-cmt-canvas-cluster:focus-visible .tlui-cmt-marker {
|
||||
outline: 2px solid var(--tl-color-selected);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
/* Focus should read like hover, so the pin lifts the same way when tabbed to. */
|
||||
.tlui-cmt-canvas-pin__marker:focus-visible .tlui-cmt-marker {
|
||||
transform: scale(var(--tlui-cmt-marker-hover-scale));
|
||||
}
|
||||
|
||||
.tlui-cmt-cluster-fade {
|
||||
opacity: 1;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.tlui-cmt-cluster-fade--entering {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tlui-cmt-cluster-fade--present {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tlui-cmt-cluster-fade--exiting {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tlui-cmt-cluster-fade--entering *,
|
||||
.tlui-cmt-cluster-fade--exiting * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tlui-cmt-cluster-fade {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The open thread (its pin + popover) sits above the other pins, so nearby markers don't
|
||||
overlap its popover — they tuck behind it. */
|
||||
.tlui-cmt-canvas-pin--open {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* The count badge standing in for a stack of coincident pins — same face as a cluster badge,
|
||||
but clicking opens the stack's thread list rather than zooming (no zoom can separate them).
|
||||
Re-enables pointer events: the badge lives inside `.tlui-cmt-canvas-pin`, which is inert
|
||||
(`pointer-events: none`) so its box doesn't shadow the canvas — without this the badge renders
|
||||
but can't be hovered or clicked, like the cluster badge's own `pointer-events: auto`. */
|
||||
.tlui-cmt-canvas-stack-badge {
|
||||
/* Bottom-left anchored like the pins it stands in for, and like the cluster badge. */
|
||||
transform: translate(0, -100%);
|
||||
width: max-content;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tlui-cmt-canvas-stack-badge:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.tlui-cmt-canvas-stack-badge:focus-visible .tlui-cmt-marker {
|
||||
outline: 2px solid var(--tl-color-selected);
|
||||
outline-offset: 2px;
|
||||
transform: scale(var(--tlui-cmt-marker-hover-scale));
|
||||
}
|
||||
|
||||
/* The stack's popover content: one card per thread, the expanded thread inline among them. */
|
||||
.tlui-cmt-stack-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* The expanded thread is the only entry with pills floating above its panel — its header actions,
|
||||
and its first comment's hover actions. Those reach ~15px up, past the list's 10px gap, so give
|
||||
this one entry extra room: without it the pills land on the card above, and against the scroll
|
||||
box's clip edge. Collapsed cards keep their own actions inside, so they need none of this. */
|
||||
.tlui-cmt-stack-list__thread {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Collapsed entries match the expanded thread's surface (same width, panel, elevation) so the
|
||||
list reads as one column of cards with one of them opened up. */
|
||||
.tlui-cmt-stack-list__card {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
width: 300px;
|
||||
background: var(--tl-color-panel);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--tl-shadow-3);
|
||||
padding: 2px 12px 10px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* The card's keyboard affordance, covering the whole surface so it's one target rather than a
|
||||
control tucked in a corner. It's laid under the card's content instead of wrapping it: a comment
|
||||
body renders its links as anchors, which can't be nested inside a button (see the note in
|
||||
`StackThreadCard`). Pointer clicks anywhere still reach the card's own handler by bubbling. */
|
||||
.tlui-cmt-stack-list__card-action {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tlui-cmt-stack-list__card-action:focus-visible {
|
||||
outline: 2px solid var(--tl-color-selected);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* The wrapper is the card's surface — inside it the comment is plain content, mirroring how the
|
||||
thread panel flattens its own cards. Stacked above the action overlay so the body's links stay
|
||||
clickable. */
|
||||
.tlui-cmt-stack-list__card .tlui-cmt-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* The hover preview, shown for any marker — a pin, a coincident stack, or a cluster badge. Live,
|
||||
not a tooltip: the pointer travels into it to hover and click the cards.
|
||||
|
||||
Two elements, deliberately: this root is the hover *region* (it reaches back over the marker via
|
||||
the bridge below), and `__panel` inside it is the visible surface. A pseudo-element's hover
|
||||
counts as its host's, so a surface on this root would light up whenever the pointer was on the
|
||||
marker — the pin would appear pressed just for being hovered. */
|
||||
.tlui-cmt-canvas-preview {
|
||||
position: absolute;
|
||||
z-index: var(--tl-layer-menus);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The bridge over the gap between the marker and the panel. Moving right off the pin would
|
||||
otherwise cross bare canvas, and that leave would retract the panel before the pointer arrived.
|
||||
Invisible, part of the panel's hover region, and only present while the panel is.
|
||||
|
||||
It spans the gap and stops there. Reaching any further would put it over the marker itself, and
|
||||
since it sits in the menus layer — above the pins — it would take the marker's hover and clicks:
|
||||
the pin would go dead exactly where it looks most alive. The marker needs no covering anyway, as
|
||||
it carries the same hover handlers.
|
||||
|
||||
The gap is the panel's own offset (`--tlui-cmt-preview-offset`, set from `POPOVER_OFFSET` so
|
||||
there's one source) less however far the marker reaches toward it, per variant below. */
|
||||
.tlui-cmt-canvas-preview::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 100%;
|
||||
width: max(0px, var(--tlui-cmt-preview-bridge, 0px));
|
||||
}
|
||||
|
||||
/* A pin is anchored at its bottom-left corner, so its full width lies between anchor and panel.
|
||||
The popover and preview share a first-comment position now that the actions float outside the
|
||||
panel rather than reserving a header row, so no vertical shift is needed. */
|
||||
.tlui-cmt-canvas-preview--thread {
|
||||
--tlui-cmt-preview-bridge: calc(var(--tlui-cmt-preview-offset) - var(--tlui-cmt-pin-size));
|
||||
}
|
||||
|
||||
/* A badge is anchored at its bottom-left corner like a pin, so its full width does too. */
|
||||
.tlui-cmt-canvas-preview--list {
|
||||
--tlui-cmt-preview-bridge: calc(var(--tlui-cmt-preview-offset) - var(--tlui-cmt-marker-size));
|
||||
}
|
||||
|
||||
.tlui-cmt-canvas-preview__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Previewing a single pin: the thread panel itself, minus the header — the same surface, width,
|
||||
and flattened comment (see `.tlui-cmt-thread .tlui-cmt-card`), so the hover shows exactly what
|
||||
the click opens. */
|
||||
.tlui-cmt-canvas-preview__panel--thread {
|
||||
box-sizing: border-box;
|
||||
width: 300px;
|
||||
gap: var(--tlui-cmt-thread-gap);
|
||||
/* Top/bottom only — the card pads its own sides now (12px), matching the expanded thread. A
|
||||
little extra below so the (often truncated) last line doesn't sit against the rounded edge. */
|
||||
padding: 4px 0 8px;
|
||||
background: var(--tl-color-panel);
|
||||
border-radius: var(--tl-radius-4);
|
||||
box-shadow: var(--tl-shadow-3);
|
||||
}
|
||||
/* The reply count is a short line that sits closer to the edge than a wrapped comment does, so give
|
||||
it more room below when it's the panel's last line. */
|
||||
.tlui-cmt-canvas-preview__panel--thread:has(.tlui-cmt-card__replies) {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Previewing a stack or cluster: one card per thread, matching the card list a click opens. */
|
||||
.tlui-cmt-canvas-preview__panel--list {
|
||||
gap: 8px;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
/* Matches the stack list's card surface, so a preview and the list it previews read as the same
|
||||
object seen twice — once on hover, once opened. */
|
||||
.tlui-cmt-preview-card {
|
||||
box-sizing: border-box;
|
||||
width: 300px;
|
||||
background: var(--tl-color-panel);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--tl-shadow-3);
|
||||
/* A 2px nudge above the byline's centring — a boxed card reads a touch better with it than the
|
||||
single preview's flat card, which pads 0 vertically and leans on the panel + byline. */
|
||||
padding: 2px 12px 10px;
|
||||
}
|
||||
|
||||
.tlui-cmt-preview-card--selectable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Every card surface that tints on hover — a collapsed thread in a stack list, a preview card, and
|
||||
the single-comment preview panel. One rule, because they're the same object to the eye.
|
||||
|
||||
The opaque low-surface token, not a translucent muted one: these float directly over the canvas,
|
||||
where a translucent fill would let shapes show through.
|
||||
|
||||
The second line is why this isn't a one-liner. `--tl-shadow-3` ends with a 1px *inset* ring in
|
||||
`--tl-color-panel-contrast` — pure white in light mode — and an inset shadow paints over the
|
||||
background. At rest that's invisible (the panel is 99% white under a 100% white ring), but tint
|
||||
the background to grey and the ring stays white, hugging the edge as a pale outline. Re-tinting
|
||||
`--tl-color-panel-contrast` here wouldn't reach it: `--tl-shadow-3` is declared on the container,
|
||||
so its var() was already substituted there and the white is baked into what we inherit. Instead
|
||||
lay a ring of the new surface over the old one — first shadow in the list paints on top. */
|
||||
.tlui-cmt-stack-list__card:hover,
|
||||
.tlui-cmt-preview-card--selectable:hover,
|
||||
.tlui-cmt-canvas-preview__panel--thread.tlui-cmt-canvas-preview__panel--selectable:hover {
|
||||
--tlui-cmt-preview-hover-surface: color-mix(
|
||||
in srgb,
|
||||
var(--tl-color-low) 50%,
|
||||
var(--tl-color-panel)
|
||||
);
|
||||
background: var(--tlui-cmt-preview-hover-surface);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px var(--tlui-cmt-preview-hover-surface),
|
||||
var(--tl-shadow-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tlui-cmt-preview-card .tlui-cmt-card {
|
||||
width: 100%;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* A preview is a glance, not a list — past a few cards it says how many were left out rather than
|
||||
growing to the height of the cluster. */
|
||||
.tlui-cmt-preview-more {
|
||||
box-sizing: border-box;
|
||||
width: 300px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--tl-color-panel);
|
||||
box-shadow: var(--tl-shadow-3);
|
||||
color: var(--tl-color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.tlui-cmt-canvas-preview {
|
||||
animation: tlui-cmt-preview-in 120ms ease;
|
||||
}
|
||||
@keyframes tlui-cmt-preview-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* A region anchor's area: a dashed box under the pins, non-interactive so canvas events pass
|
||||
through. Drawn for the live drag draft and for a region thread while hovered or open. */
|
||||
.tlui-cmt-canvas-region {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 2px dashed var(--tl-color-selected);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--tl-color-selected) 10%, transparent);
|
||||
}
|
||||
|
||||
/* Corner resize handles on a region box (three corners; the pin corner is the move handle). */
|
||||
.tlui-cmt-canvas-region-handle {
|
||||
position: absolute;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--tl-color-panel);
|
||||
border: 1.5px solid var(--tl-color-selected);
|
||||
border-radius: 2px;
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Anchor the pin by its bottom-left corner, so the marker's one sharp corner (the pin shape's
|
||||
3px bottom-left) points at the exact anchor point. */
|
||||
.tlui-cmt-canvas-pin__marker {
|
||||
position: relative;
|
||||
width: max-content;
|
||||
transform: translate(0, -100%);
|
||||
/* Default at rest, like hovering a shape; the editor's move cursor while dragging (below). */
|
||||
cursor: var(--tl-cursor-default);
|
||||
touch-action: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Dragging a pin shows the same move cursor as dragging a shape. */
|
||||
.tlui-cmt-canvas-pin--dragging .tlui-cmt-canvas-pin__marker {
|
||||
cursor: var(--tl-cursor-move);
|
||||
}
|
||||
|
||||
/* The predictable hit area: a fixed square over the marker's box, and the only part of the pin
|
||||
that takes pointer events. Events over it target the marker (a pseudo-element's events report
|
||||
its originating element), so the marker's handlers and :hover keep working — but the hit area
|
||||
no longer stretches with pin content or scales with the hover transform. */
|
||||
.tlui-cmt-canvas-pin__marker::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* With the hit layer targeting the marker, the pin visual is never the hovered element itself
|
||||
(`:hover` matches the target and its ancestors, and the pin is the marker's child) — so the
|
||||
canvas pin's hover has to key off the hit layer instead of the marker's own `:hover`. Only the
|
||||
selector differs: the values come from the same tokens the shared rule uses, so a pin on the
|
||||
canvas and a badge (which is hovered directly) lift by exactly the same amount. */
|
||||
.tlui-cmt-canvas-pin__marker:hover .tlui-cmt-marker {
|
||||
transform: scale(var(--tlui-cmt-marker-hover-scale));
|
||||
}
|
||||
.tlui-cmt-canvas-pin__marker:hover .tlui-cmt-marker:not(.tlui-cmt-marker--open) {
|
||||
box-shadow: var(--tlui-cmt-marker-shadow-hover);
|
||||
}
|
||||
/* The comment pin doesn't scale on hover — only the shadow lifts (badges still scale). */
|
||||
.tlui-cmt-canvas-pin__marker:hover .tlui-cmt-pin {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* The open thread's popover — portaled to the menus layer (above the UI), positioned at the pin. */
|
||||
.tlui-cmt-canvas-popover {
|
||||
position: absolute;
|
||||
z-index: var(--tl-layer-menus);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The placement composer — a distinct floating view (portaled to the menus layer, below the click
|
||||
point): a draft avatar pin beside a pill field that is itself the surface (no wrapping panel).
|
||||
Scoped here so the in-thread reply composer keeps its own squarer look. */
|
||||
.tlui-cmt-canvas-composer {
|
||||
position: absolute;
|
||||
z-index: var(--tl-layer-menus);
|
||||
pointer-events: auto;
|
||||
/* Anchor the draft avatar bottom-left like the pins it becomes: it is a pin wide and sits 4px
|
||||
into the row, so its bottom-left corner is 4px right of and 4px + a pin below the composer's
|
||||
top-left corner. */
|
||||
transform: translate(-4px, calc(-4px - var(--tlui-cmt-pin-size)));
|
||||
}
|
||||
|
||||
/* A region placement centres the draft avatar on the region's pin corner — the pin it becomes
|
||||
straddles that corner. The avatar's centre sits 4px + half a pin into the composer. */
|
||||
.tlui-cmt-canvas-composer--region {
|
||||
--tlui-cmt-draft-avatar-centre: calc(4px + var(--tlui-cmt-pin-size) / 2);
|
||||
transform: translate(
|
||||
calc(-1 * var(--tlui-cmt-draft-avatar-centre)),
|
||||
calc(-1 * var(--tlui-cmt-draft-avatar-centre))
|
||||
);
|
||||
}
|
||||
|
||||
/* Fallback placement (no composer to show): no draft avatar to anchor by, so no offset — a small
|
||||
panel hanging off the click point, sized to its content (e.g. the host's sign-in prompt). */
|
||||
.tlui-cmt-canvas-composer--fallback {
|
||||
transform: none;
|
||||
width: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
background: var(--tl-color-panel);
|
||||
border-radius: var(--tl-radius-4);
|
||||
box-shadow: var(--tl-shadow-3);
|
||||
}
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-composer {
|
||||
gap: 6px;
|
||||
}
|
||||
/* The card: a fixed 300×42 surface whose uniform 3px padding derives the 294×36 focusable input. */
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-composer__field {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 300px;
|
||||
min-height: 42px;
|
||||
padding: 3px;
|
||||
border: none;
|
||||
background: var(--tl-color-panel);
|
||||
border-radius: var(--tl-radius-3);
|
||||
box-shadow: var(--tl-shadow-2);
|
||||
/* The pin is anchored by its bottom corner at the click point and can't move; raise the taller
|
||||
card onto the shorter pin's centre instead. */
|
||||
margin-top: -6px;
|
||||
}
|
||||
/* Focused (the placement composer autofocuses on mount): a selection-blue ring inset 3px from the
|
||||
card edge, so the card frames a bordered input rather than the border sitting on the card edge. */
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-composer__field:focus-within::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
border: 1px solid var(--tl-color-selected);
|
||||
border-radius: var(--tl-radius-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* The text sits 12px in from the focus ring rather than against it. The placeholder is absolutely
|
||||
positioned (it ignores the wrap's padding), so it carries the same offset itself. */
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-composer__input-wrap {
|
||||
padding: 4.5px 12px;
|
||||
}
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-composer__field--expanded .tlui-cmt-composer__input-wrap {
|
||||
/* The base expanded rule re-pads the right edge for the squarer composer's geometry; here the
|
||||
12px is already symmetric. */
|
||||
padding-right: 12px;
|
||||
}
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-input__placeholder {
|
||||
left: 12px;
|
||||
}
|
||||
/* The draft avatar previews the pin it becomes — a white 28px pin holding the author's 22px avatar
|
||||
circle — sharing the composer's lift. Not interactive: it drops the pin's pointer cursor and
|
||||
hover affordances (scale + shadow bump), but keeps a 40×40 hit area (the ::after below). */
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-pin {
|
||||
position: relative;
|
||||
box-shadow: var(--tl-shadow-2);
|
||||
cursor: default;
|
||||
}
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-pin::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
}
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-pin:hover {
|
||||
transform: none;
|
||||
box-shadow: var(--tl-shadow-2);
|
||||
}
|
||||
/* The placement composer's send button — a bare arrow, no pill: grey while empty, black once
|
||||
there's something to send. A 28px square centred in the 36px input height, so its hover wash
|
||||
clears the focus ring rather than butting against it; the ::after keeps a 40×40 hit area. */
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-send {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: 4px;
|
||||
background: transparent;
|
||||
border-radius: var(--tl-radius-2);
|
||||
color: var(--tl-color-text-3);
|
||||
}
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-send::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -6px;
|
||||
}
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-send:not(:disabled) {
|
||||
color: var(--tl-color-text-1);
|
||||
}
|
||||
/* Hover and press wash grey behind the bare arrow (the same treatment as the thread actions),
|
||||
overriding the base button's blue fill. */
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-send:hover:not(:disabled) {
|
||||
background: var(--tl-color-muted-2);
|
||||
}
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-send:active:not(:disabled) {
|
||||
background: var(--tl-color-muted-1);
|
||||
}
|
||||
/* In the expanded (multi-line) layout the send drops to the bottom-right corner; give it the same
|
||||
4px gap from the focus ring on the bottom that it already keeps on the right. */
|
||||
.tlui-cmt-canvas-composer .tlui-cmt-composer__field--expanded .tlui-cmt-send {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* The comments list panel — a right-side surface shown while the comments sidebar is open. Below
|
||||
the popover layer, so an open thread still draws over it. Its top aligns flush with the style
|
||||
panel (48px from the top, clearing the share panel) and it sizes to its content, scrolling past
|
||||
max-height rather than stretching the full canvas height. */
|
||||
.tlui-cmt-canvas-sidebar {
|
||||
position: absolute;
|
||||
top: 48px;
|
||||
right: 8px;
|
||||
width: 280px;
|
||||
max-height: calc(100% - 56px);
|
||||
z-index: var(--tl-layer-panels);
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
background: var(--tl-color-panel);
|
||||
border-radius: var(--tl-radius-4);
|
||||
box-shadow: var(--tl-shadow-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
133
packages/commenting/src/canvas/cluster-badge.tsx
Normal file
133
packages/commenting/src/canvas/cluster-badge.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import {
|
||||
Editor,
|
||||
TLCommentThread,
|
||||
useContainer,
|
||||
usePassThroughWheelEvents,
|
||||
useTranslation,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import type { ClusterNode } from '../clustering/types'
|
||||
import { CountBadge } from '../ui/count-badge'
|
||||
import { forwardPointerEventToCanvas, isCanvasPanGesture } from './canvas-events'
|
||||
import { type CommentingContext } from './context'
|
||||
import { sortThreadsForPreview, ThreadPreview, useMarkerPreview } from './thread-preview'
|
||||
import { impreciseShapePinInset, isInInflatedViewport } from './thread-state'
|
||||
|
||||
/**
|
||||
* The count badge standing in for several threads folded together at the current zoom. Hovering it
|
||||
* previews its threads as a list; clicking zooms to the zoom level at which the cluster splits.
|
||||
*
|
||||
* Memoized: cluster nodes and thread records are identity-stable while unchanged, so pins and
|
||||
* badges skip re-rendering when the parent re-renders for reasons that don't concern them
|
||||
* (leaf recomputes during shape drags, partition changes elsewhere). Camera tracking still
|
||||
* works — each component subscribes to its own viewport position via signals, not via props.
|
||||
*/
|
||||
export const ClusterBadge = memo(function ClusterBadge({
|
||||
editor,
|
||||
node,
|
||||
onExpand,
|
||||
onSelectThread,
|
||||
threadsById,
|
||||
...props
|
||||
}: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'> & {
|
||||
editor: Editor
|
||||
node: ClusterNode
|
||||
onExpand(node: ClusterNode): void
|
||||
onSelectThread(thread: TLCommentThread): void
|
||||
threadsById: ReadonlyMap<string, TLCommentThread>
|
||||
}) {
|
||||
const container = useContainer()
|
||||
const msg = useTranslation()
|
||||
const badgeRef = useRef<HTMLButtonElement>(null)
|
||||
const { previewShown, previewHandlers } = useMarkerPreview(editor, `cluster:${node.id}`)
|
||||
// Wheel pass-through sits on the badge (never scrollable), not the layer root — see the
|
||||
// note on the layer.
|
||||
usePassThroughWheelEvents(badgeRef)
|
||||
// The mean of the members' render offsets, in screen px. Imprecise members' pins draw tucked
|
||||
// into their shape (see impreciseShapePinInset), and the merge thresholds are priced at those
|
||||
// visual positions — so the badge draws at the centroid of the pins as drawn, which (screen
|
||||
// mapping being affine) is the anchor centroid plus this mean. Camera-independent: its own
|
||||
// computed so the per-frame point below only adds two numbers, and a cluster of precise
|
||||
// members tracks nothing and adds zero.
|
||||
const meanInset = useValue(
|
||||
'cluster badge inset',
|
||||
() => {
|
||||
let x = 0
|
||||
let y = 0
|
||||
for (const id of node.members) {
|
||||
const thread = threadsById.get(id)
|
||||
if (!thread) continue
|
||||
const inset = impreciseShapePinInset(editor, thread.anchor)
|
||||
if (inset) {
|
||||
x += inset.x
|
||||
y += inset.y
|
||||
}
|
||||
}
|
||||
return { x: x / node.count, y: y / node.count }
|
||||
},
|
||||
[editor, node, threadsById]
|
||||
)
|
||||
const point = useValue(
|
||||
'cluster badge point',
|
||||
() => {
|
||||
const pagePoint = editor.pageToViewport(node.centroid)
|
||||
if (!isInInflatedViewport(editor, pagePoint)) return null
|
||||
return { x: pagePoint.x + meanInset.x, y: pagePoint.y + meanInset.y }
|
||||
},
|
||||
[editor, node, meanInset]
|
||||
)
|
||||
|
||||
// `node.members` is sorted by id (the clustering table's ordering); the preview wants them in
|
||||
// the order a reader would expect. Only computed while the preview is up.
|
||||
const previewThreads = useMemo(() => {
|
||||
if (!previewShown) return []
|
||||
const threads: TLCommentThread[] = []
|
||||
for (const id of node.members) {
|
||||
const thread = threadsById.get(id)
|
||||
if (thread) threads.push(thread)
|
||||
}
|
||||
return sortThreadsForPreview(threads)
|
||||
}, [previewShown, node.members, threadsById])
|
||||
|
||||
if (!point) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={badgeRef}
|
||||
type="button"
|
||||
className="tlui-cmt-button tlui-cmt-canvas-cluster"
|
||||
style={{ left: point.x, top: point.y }}
|
||||
aria-label={msg('comments.cluster-label').replace('{count}', String(node.count))}
|
||||
onPointerDown={(e) => {
|
||||
if (isCanvasPanGesture(editor, e)) {
|
||||
forwardPointerEventToCanvas(container, e)
|
||||
return
|
||||
}
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onExpand(node)
|
||||
}}
|
||||
{...previewHandlers}
|
||||
onFocus={previewHandlers.onPointerEnter}
|
||||
onBlur={previewHandlers.onPointerLeave}
|
||||
>
|
||||
<CountBadge count={node.count} />
|
||||
</button>
|
||||
{previewShown && previewThreads.length > 0 && (
|
||||
<ThreadPreview
|
||||
editor={editor}
|
||||
threads={previewThreads}
|
||||
variant="list"
|
||||
point={point}
|
||||
onSelectThread={onSelectThread}
|
||||
{...previewHandlers}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
102
packages/commenting/src/canvas/cluster-fade.ts
Normal file
102
packages/commenting/src/canvas/cluster-fade.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ClusterNode } from '../clustering/types'
|
||||
|
||||
const CLUSTER_FADE_MS = 150
|
||||
|
||||
export type ClusterFadePhase = 'entering' | 'present' | 'exiting'
|
||||
|
||||
export interface ClusterFadeNode {
|
||||
node: ClusterNode
|
||||
phase: ClusterFadePhase
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-fades the visible cluster nodes as the partition changes: a node that appears starts
|
||||
* `entering` and flips to `present` on the next frame, and one that leaves is kept as `exiting`
|
||||
* until the CSS transition has run. `resetKey` is compared by identity — a new cluster model
|
||||
* replaces the whole world, so its nodes snap to `present` instead of animating.
|
||||
*/
|
||||
export function useFadeVisibleNodes(
|
||||
nodes: readonly ClusterNode[],
|
||||
resetKey: object
|
||||
): ClusterFadeNode[] {
|
||||
const resetKeyRef = useRef(resetKey)
|
||||
const didReset = resetKeyRef.current !== resetKey
|
||||
if (didReset) {
|
||||
resetKeyRef.current = resetKey
|
||||
}
|
||||
|
||||
const [fadeNodes, setFadeNodes] = useState<ClusterFadeNode[]>(() => toPresentFadeNodes(nodes))
|
||||
const renderedNodes = didReset ? toPresentFadeNodes(nodes) : fadeNodes
|
||||
|
||||
useEffect(() => {
|
||||
setFadeNodes(toPresentFadeNodes(nodes))
|
||||
// Resets only on a new model (resetKey); node-list changes within the same model are
|
||||
// handled by the reconcile effect below, which fades entries in/out instead of snapping.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resetKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (didReset) return
|
||||
setFadeNodes((previous) => reconcileFadeNodes(previous, nodes))
|
||||
}, [didReset, nodes])
|
||||
|
||||
const hasEntering = renderedNodes.some((item) => item.phase === 'entering')
|
||||
useEffect(() => {
|
||||
if (!hasEntering) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
setFadeNodes((previous) =>
|
||||
previous.map((item) => (item.phase === 'entering' ? { ...item, phase: 'present' } : item))
|
||||
)
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [hasEntering, renderedNodes])
|
||||
|
||||
const hasExiting = renderedNodes.some((item) => item.phase === 'exiting')
|
||||
useEffect(() => {
|
||||
if (!hasExiting) return
|
||||
const timeout = window.setTimeout(() => {
|
||||
setFadeNodes((previous) => previous.filter((item) => item.phase !== 'exiting'))
|
||||
}, CLUSTER_FADE_MS)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [hasExiting, renderedNodes])
|
||||
|
||||
return renderedNodes
|
||||
}
|
||||
|
||||
function toPresentFadeNodes(nodes: readonly ClusterNode[]): ClusterFadeNode[] {
|
||||
return nodes.map((node) => ({ node, phase: 'present' }))
|
||||
}
|
||||
|
||||
function reconcileFadeNodes(
|
||||
previous: readonly ClusterFadeNode[],
|
||||
nextNodes: readonly ClusterNode[]
|
||||
): ClusterFadeNode[] {
|
||||
const previousById = new Map(previous.map((item) => [item.node.id, item]))
|
||||
const nextIds = new Set(nextNodes.map((node) => node.id))
|
||||
const next: ClusterFadeNode[] = []
|
||||
|
||||
for (const node of nextNodes) {
|
||||
const previousItem = previousById.get(node.id)
|
||||
// A node that was fading out and came back snaps to present rather than re-entering.
|
||||
next.push({
|
||||
node,
|
||||
phase: !previousItem
|
||||
? 'entering'
|
||||
: previousItem.phase === 'exiting'
|
||||
? 'present'
|
||||
: previousItem.phase,
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of previous) {
|
||||
if (nextIds.has(item.node.id)) continue
|
||||
next.push(item.phase === 'exiting' ? item : { ...item, phase: 'exiting' })
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export function clusterFadeClassName(phase: ClusterFadePhase): string {
|
||||
return `tlui-cmt-cluster-fade tlui-cmt-cluster-fade--${phase}`
|
||||
}
|
||||
365
packages/commenting/src/canvas/cluster-input.test.ts
Normal file
365
packages/commenting/src/canvas/cluster-input.test.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { Box, Mat, type Editor, type TLCommentAnchor, type TLCommentThread } from 'tldraw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// This import is red until step 6's filter module is implemented — that is
|
||||
// intentional. Implement `cluster-input.ts` per CLUSTERING-STEPS.md step 6
|
||||
// until this suite passes, without modifying this file.
|
||||
import type { LeafInput, LeafScreenOffsets } from '../clustering/types'
|
||||
import {
|
||||
type ClusterInput,
|
||||
clusterInputEqual,
|
||||
clusterInputIdsEqual,
|
||||
collectClusterLeaves,
|
||||
} from './cluster-input'
|
||||
import { defaultCommentingOptions, type CommentingOptions } from './options'
|
||||
|
||||
const CURRENT_PAGE = 'page:one'
|
||||
const OTHER_PAGE = 'page:two'
|
||||
|
||||
/** Minimal thread record for filter tests. */
|
||||
function thread(
|
||||
id: string,
|
||||
anchor: TLCommentAnchor,
|
||||
opts: { pageId?: string; resolved?: boolean } = {}
|
||||
): TLCommentThread {
|
||||
return {
|
||||
id,
|
||||
typeName: 'comment-thread',
|
||||
pageId: opts.pageId ?? CURRENT_PAGE,
|
||||
anchor,
|
||||
createdBy: 'user:1',
|
||||
createdAt: 0,
|
||||
resolved: opts.resolved ? { at: 1, by: 'user:1' } : null,
|
||||
meta: {},
|
||||
} as unknown as TLCommentThread
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub editor: the filter's editor dependencies are the current page id, shape
|
||||
* geometry and page transform, and the commenting options (via anchorPagePoint,
|
||||
* read off the registered comment tool). `shapes` maps shape id → page bounds for
|
||||
* unrotated shapes that exist, modeled as local geometry sized by the box plus a
|
||||
* translate-only page transform; anything else resolves to undefined (deleted
|
||||
* shape). `options` stands in for `CommentTool.configure({ ... })`; omit it for a
|
||||
* editor with no comment tool registered, which falls back to the defaults.
|
||||
*/
|
||||
function stubEditor(
|
||||
shapes: Record<string, { minX: number; minY: number; maxX: number; maxY: number }> = {},
|
||||
options?: Partial<CommentingOptions>
|
||||
): Editor {
|
||||
const shapeId = (shape: string | { id: string }) => (typeof shape === 'string' ? shape : shape.id)
|
||||
return {
|
||||
getCurrentPageId: () => CURRENT_PAGE,
|
||||
getStateDescendant: () =>
|
||||
options ? { options: { ...defaultCommentingOptions, ...options } } : undefined,
|
||||
getShape: (id: string) => (shapes[id] ? { id } : undefined),
|
||||
getShapeGeometry: (shape: string | { id: string }) => {
|
||||
const bounds = shapes[shapeId(shape)]
|
||||
return { bounds: new Box(0, 0, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY) }
|
||||
},
|
||||
getShapePageTransform: (shape: string | { id: string }) => {
|
||||
const bounds = shapes[shapeId(shape)]
|
||||
return bounds ? Mat.Translate(bounds.minX, bounds.minY) : undefined
|
||||
},
|
||||
} as unknown as Editor
|
||||
}
|
||||
|
||||
function leafIds(leaves: { id: string }[]): string[] {
|
||||
return leaves.map((l) => l.id).sort()
|
||||
}
|
||||
|
||||
describe('collectClusterLeaves anchor resolution', () => {
|
||||
it('maps point anchors to their page coordinates', () => {
|
||||
const leaves = collectClusterLeaves(
|
||||
stubEditor(),
|
||||
[thread('t1', { type: 'point', x: 12, y: 34 })],
|
||||
null
|
||||
).leaves
|
||||
expect(leaves).toEqual([{ id: 't1', point: { x: 12, y: 34 } }])
|
||||
})
|
||||
|
||||
it('maps region anchors to their bottom-right corner (x + w, y + h)', () => {
|
||||
const leaves = collectClusterLeaves(
|
||||
stubEditor(),
|
||||
[thread('t1', { type: 'region', x: 10, y: 20, w: 30, h: 40 })],
|
||||
null
|
||||
).leaves
|
||||
expect(leaves).toEqual([{ id: 't1', point: { x: 40, y: 60 } }])
|
||||
})
|
||||
|
||||
it('maps shape anchors to the shape bounds top-right corner', () => {
|
||||
const editor = stubEditor({
|
||||
'shape:a': { minX: 0, minY: 5, maxX: 100, maxY: 50 },
|
||||
})
|
||||
const leaves = collectClusterLeaves(
|
||||
editor,
|
||||
[thread('t1', { type: 'shape', shapeId: 'shape:a' as any, x: 0, y: 0, isPrecise: false })],
|
||||
null
|
||||
).leaves
|
||||
expect(leaves).toEqual([{ id: 't1', point: { x: 100, y: 5 } }])
|
||||
})
|
||||
|
||||
it('places imprecise shape leaves at a custom impreciseShapeAnchor, matching pin rendering', () => {
|
||||
// bottom-center instead of the default top-right
|
||||
const editor = stubEditor(
|
||||
{ 'shape:a': { minX: 0, minY: 0, maxX: 100, maxY: 50 } },
|
||||
{ impreciseShapeAnchor: { x: 0.5, y: 1 } }
|
||||
)
|
||||
const threads = [
|
||||
thread('imprecise', {
|
||||
type: 'shape',
|
||||
shapeId: 'shape:a' as any,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isPrecise: false,
|
||||
}),
|
||||
thread('precise', {
|
||||
type: 'shape',
|
||||
shapeId: 'shape:a' as any,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
isPrecise: true,
|
||||
}),
|
||||
]
|
||||
const leaves = collectClusterLeaves(editor, threads, null).leaves
|
||||
expect(leaves).toEqual([
|
||||
{ id: 'imprecise', point: { x: 50, y: 50 } },
|
||||
{ id: 'precise', point: { x: 50, y: 25 } },
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes shape anchors whose shape no longer resolves', () => {
|
||||
const leaves = collectClusterLeaves(
|
||||
stubEditor({}), // no shapes exist
|
||||
[
|
||||
thread('gone', {
|
||||
type: 'shape',
|
||||
shapeId: 'shape:deleted' as any,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isPrecise: false,
|
||||
}),
|
||||
thread('kept', { type: 'point', x: 1, y: 2 }),
|
||||
],
|
||||
null
|
||||
).leaves
|
||||
expect(leafIds(leaves)).toEqual(['kept'])
|
||||
})
|
||||
|
||||
it('excludes page anchors (no spatial position)', () => {
|
||||
const leaves = collectClusterLeaves(
|
||||
stubEditor(),
|
||||
[thread('pageThread', { type: 'page' }), thread('kept', { type: 'point', x: 1, y: 2 })],
|
||||
null
|
||||
).leaves
|
||||
expect(leafIds(leaves)).toEqual(['kept'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectClusterLeaves filtering', () => {
|
||||
it('excludes threads on other pages', () => {
|
||||
const leaves = collectClusterLeaves(
|
||||
stubEditor(),
|
||||
[
|
||||
thread('here', { type: 'point', x: 0, y: 0 }),
|
||||
thread('elsewhere', { type: 'point', x: 0, y: 0 }, { pageId: OTHER_PAGE }),
|
||||
],
|
||||
null
|
||||
).leaves
|
||||
expect(leafIds(leaves)).toEqual(['here'])
|
||||
})
|
||||
|
||||
it('excludes the open thread, and only the open thread', () => {
|
||||
const threads = [
|
||||
thread('open', { type: 'point', x: 0, y: 0 }),
|
||||
thread('closed', { type: 'point', x: 10, y: 0 }),
|
||||
]
|
||||
expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'open').leaves)).toEqual(['closed'])
|
||||
expect(leafIds(collectClusterLeaves(stubEditor(), threads, null).leaves)).toEqual([
|
||||
'closed',
|
||||
'open',
|
||||
])
|
||||
// an id that matches no thread excludes nothing
|
||||
expect(leafIds(collectClusterLeaves(stubEditor(), threads, 'unknown').leaves)).toEqual([
|
||||
'closed',
|
||||
'open',
|
||||
])
|
||||
})
|
||||
|
||||
it('includes resolved threads (v1 decision — resolve is appearance-only)', () => {
|
||||
const leaves = collectClusterLeaves(
|
||||
stubEditor(),
|
||||
[
|
||||
thread('resolved', { type: 'point', x: 0, y: 0 }, { resolved: true }),
|
||||
thread('unresolved', { type: 'point', x: 10, y: 0 }),
|
||||
],
|
||||
null
|
||||
).leaves
|
||||
expect(leafIds(leaves)).toEqual(['resolved', 'unresolved'])
|
||||
})
|
||||
|
||||
it('returns [] for no threads', () => {
|
||||
expect(collectClusterLeaves(stubEditor(), [], null).leaves).toEqual([])
|
||||
})
|
||||
|
||||
it('applies every rule at once on a mixed set', () => {
|
||||
const editor = stubEditor({
|
||||
'shape:live': { minX: 0, minY: 0, maxX: 10, maxY: 10 },
|
||||
})
|
||||
const threads = [
|
||||
thread('point', { type: 'point', x: 1, y: 2 }),
|
||||
thread('region', { type: 'region', x: 0, y: 0, w: 5, h: 5 }),
|
||||
thread('onShape', {
|
||||
type: 'shape',
|
||||
shapeId: 'shape:live' as any,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isPrecise: false,
|
||||
}),
|
||||
thread('orphaned', {
|
||||
type: 'shape',
|
||||
shapeId: 'shape:gone' as any,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isPrecise: false,
|
||||
}),
|
||||
thread('pageLevel', { type: 'page' }),
|
||||
thread('otherPage', { type: 'point', x: 9, y: 9 }, { pageId: OTHER_PAGE }),
|
||||
thread('openOne', { type: 'point', x: 3, y: 3 }),
|
||||
thread('resolvedOne', { type: 'point', x: 4, y: 4 }, { resolved: true }),
|
||||
]
|
||||
const leaves = collectClusterLeaves(editor, threads, 'openOne').leaves
|
||||
expect(leafIds(leaves)).toEqual(['onShape', 'point', 'region', 'resolvedOne'])
|
||||
})
|
||||
|
||||
it('does not mutate the threads array', () => {
|
||||
const threads = [thread('a', { type: 'point', x: 0, y: 0 }), thread('b', { type: 'page' })]
|
||||
const snapshot = JSON.parse(JSON.stringify(threads))
|
||||
collectClusterLeaves(stubEditor(), threads, 'a')
|
||||
expect(JSON.parse(JSON.stringify(threads))).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectClusterLeaves screen offsets', () => {
|
||||
it('is undefined when every pin renders on its anchor', () => {
|
||||
const editor = stubEditor({ 'shape:a': { minX: 0, minY: 0, maxX: 100, maxY: 50 } })
|
||||
const threads = [
|
||||
thread('point', { type: 'point', x: 1, y: 2 }),
|
||||
thread('region', { type: 'region', x: 0, y: 0, w: 5, h: 5 }),
|
||||
thread('precise', {
|
||||
type: 'shape',
|
||||
shapeId: 'shape:a' as any,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
isPrecise: true,
|
||||
}),
|
||||
]
|
||||
expect(collectClusterLeaves(editor, threads, null).screenOffsets).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps imprecise leaves to their pin inset, and only those', () => {
|
||||
const editor = stubEditor({ 'shape:a': { minX: 0, minY: 0, maxX: 100, maxY: 50 } })
|
||||
const threads = [
|
||||
thread('imprecise', {
|
||||
type: 'shape',
|
||||
shapeId: 'shape:a' as any,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isPrecise: false,
|
||||
}),
|
||||
thread('point', { type: 'point', x: 1, y: 2 }),
|
||||
]
|
||||
const { screenOffsets } = collectClusterLeaves(editor, threads, null)
|
||||
// Default top-right anchor spot: the pin tucks left and down into the shape.
|
||||
expect(screenOffsets).toEqual(new Map([['imprecise', { x: -20, y: 20 }]]))
|
||||
})
|
||||
|
||||
it('excludes threads that produced no leaf', () => {
|
||||
const editor = stubEditor({}) // shape does not resolve
|
||||
const threads = [
|
||||
thread('gone', { type: 'shape', shapeId: 'shape:x' as any, x: 0, y: 0, isPrecise: false }),
|
||||
]
|
||||
const input = collectClusterLeaves(editor, threads, null)
|
||||
expect(input.leaves).toEqual([])
|
||||
expect(input.screenOffsets).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// These two gate the O(N²) rebuild in `useClusterModel`. Both compare the leaves positionally, so
|
||||
// the order cases below are what pin the gate to `collectClusterLeaves` walking threads in order.
|
||||
const leaf = (id: string, x: number, y: number) => ({ id, point: { x, y } })
|
||||
const input = (leaves: LeafInput[], screenOffsets?: LeafScreenOffsets): ClusterInput => ({
|
||||
leaves,
|
||||
screenOffsets,
|
||||
})
|
||||
|
||||
describe('clusterInputEqual', () => {
|
||||
it('accepts the same leaves at the same positions, whatever the identity', () => {
|
||||
const a = input([leaf('t1', 0, 0), leaf('t2', 10, 20)])
|
||||
expect(clusterInputEqual(a, a)).toBe(true)
|
||||
expect(clusterInputEqual(a, input([leaf('t1', 0, 0), leaf('t2', 10, 20)]))).toBe(true)
|
||||
expect(clusterInputEqual(input([]), input([]))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a position change — a moved pin must rebuild the table', () => {
|
||||
expect(clusterInputEqual(input([leaf('t1', 0, 0)]), input([leaf('t1', 0, 0.5)]))).toBe(false)
|
||||
expect(clusterInputEqual(input([leaf('t1', 0, 0)]), input([leaf('t1', 0.5, 0)]))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an added, removed, renamed, or reordered leaf', () => {
|
||||
const one = input([leaf('t1', 0, 0)])
|
||||
const two = input([leaf('t1', 0, 0), leaf('t2', 1, 1)])
|
||||
expect(clusterInputEqual(one, two)).toBe(false)
|
||||
expect(clusterInputEqual(two, one)).toBe(false)
|
||||
expect(clusterInputEqual(one, input([leaf('t2', 0, 0)]))).toBe(false)
|
||||
expect(clusterInputEqual(two, input([leaf('t2', 1, 1), leaf('t1', 0, 0)]))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an offset change, which prices the same leaves differently', () => {
|
||||
const leaves = [leaf('t1', 0, 0)]
|
||||
const offsets = (x: number) => new Map([['t1', { x, y: 20 }]])
|
||||
expect(clusterInputEqual(input(leaves, offsets(-20)), input(leaves, offsets(-20)))).toBe(true)
|
||||
expect(clusterInputEqual(input(leaves, offsets(-20)), input(leaves, offsets(20)))).toBe(false)
|
||||
// gaining or losing offsets entirely (a pin's precision changed)
|
||||
expect(clusterInputEqual(input(leaves), input(leaves, offsets(-20)))).toBe(false)
|
||||
expect(clusterInputEqual(input(leaves, offsets(-20)), input(leaves))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clusterInputIdsEqual', () => {
|
||||
it('ignores positions and offset vectors — this is what defers the rebuild mid-drag', () => {
|
||||
expect(clusterInputIdsEqual(input([leaf('t1', 0, 0)]), input([leaf('t1', 999, 999)]))).toBe(
|
||||
true
|
||||
)
|
||||
// rotating mid-drag turns the inset; deferred like the anchor move it comes with
|
||||
expect(
|
||||
clusterInputIdsEqual(
|
||||
input([leaf('t1', 0, 0)], new Map([['t1', { x: -20, y: 20 }]])),
|
||||
input([leaf('t1', 5, 5)], new Map([['t1', { x: 20, y: -20 }]]))
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('still rejects an added or removed thread, so those rebuild even mid-drag', () => {
|
||||
const one = input([leaf('t1', 0, 0)])
|
||||
const two = input([leaf('t1', 0, 0), leaf('t2', 1, 1)])
|
||||
expect(clusterInputIdsEqual(one, two)).toBe(false)
|
||||
expect(clusterInputIdsEqual(two, one)).toBe(false)
|
||||
expect(clusterInputIdsEqual(one, input([leaf('t2', 0, 0)]))).toBe(false)
|
||||
})
|
||||
|
||||
it('still rejects a leaf that gained or lost its offset — a precision change, not a gesture', () => {
|
||||
const leaves = [leaf('t1', 0, 0)]
|
||||
expect(
|
||||
clusterInputIdsEqual(input(leaves), input(leaves, new Map([['t1', { x: -20, y: 20 }]])))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a reorder', () => {
|
||||
expect(
|
||||
clusterInputIdsEqual(
|
||||
input([leaf('t1', 0, 0), leaf('t2', 1, 1)]),
|
||||
input([leaf('t2', 1, 1), leaf('t1', 0, 0)])
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
115
packages/commenting/src/canvas/cluster-input.ts
Normal file
115
packages/commenting/src/canvas/cluster-input.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { Editor, TLCommentThread, VecLike } from 'tldraw'
|
||||
import type { LeafInput, LeafScreenOffsets } from '../clustering/types'
|
||||
import { anchorPagePoint, impreciseShapePinInset } from './thread-state'
|
||||
|
||||
/** The clustering input for the current page's pinned threads. @internal */
|
||||
export interface ClusterInput {
|
||||
/** One leaf per pinned thread, at its anchor page point. */
|
||||
leaves: LeafInput[]
|
||||
/**
|
||||
* The render offsets of the imprecise pins among the leaves — those draw tucked into their
|
||||
* shape by a constant screen-px inset rather than on their anchor, and the clustering prices
|
||||
* their merges at the visual positions. Undefined when every pin renders on its anchor,
|
||||
* which guarantees a byte-identical offset-unaware clustering run.
|
||||
*/
|
||||
screenOffsets: LeafScreenOffsets | undefined
|
||||
}
|
||||
|
||||
/** The cluster leaves for the threads pinned on the current page. @internal */
|
||||
export function collectClusterLeaves(
|
||||
editor: Editor,
|
||||
threads: readonly TLCommentThread[],
|
||||
openThreadId: string | null
|
||||
): ClusterInput {
|
||||
const pageId = editor.getCurrentPageId()
|
||||
const leaves: LeafInput[] = []
|
||||
let screenOffsets: Map<string, VecLike> | undefined
|
||||
|
||||
for (const thread of threads) {
|
||||
if (thread.id === openThreadId) continue
|
||||
if (thread.pageId !== pageId) continue
|
||||
|
||||
const point = anchorPagePoint(editor, thread.anchor)
|
||||
if (!point) continue
|
||||
|
||||
leaves.push({
|
||||
id: thread.id,
|
||||
point: { x: point.x, y: point.y },
|
||||
})
|
||||
|
||||
const inset = impreciseShapePinInset(editor, thread.anchor)
|
||||
if (inset) (screenOffsets ??= new Map()).set(thread.id, inset)
|
||||
}
|
||||
|
||||
return { leaves, screenOffsets }
|
||||
}
|
||||
|
||||
/**
|
||||
* Value equality over everything `computeClusterTable` reads. Keeps the input's identity stable
|
||||
* across recomputes that changed nothing, so the O(N²) rebuild only runs when it has to.
|
||||
* @internal
|
||||
*/
|
||||
export function clusterInputEqual(a: ClusterInput, b: ClusterInput): boolean {
|
||||
if (a === b) return true
|
||||
return (
|
||||
clusterLeavesEqual(a.leaves, b.leaves) && screenOffsetsEqual(a.screenOffsets, b.screenOffsets)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same, ignoring everything positional — anchor points and offset vectors alike. The mid-drag
|
||||
* gate: a move defers, but a thread added, removed, or changed precision still rebuilds.
|
||||
* @internal
|
||||
*/
|
||||
export function clusterInputIdsEqual(a: ClusterInput, b: ClusterInput): boolean {
|
||||
if (a === b) return true
|
||||
return (
|
||||
clusterLeafIdsEqual(a.leaves, b.leaves) &&
|
||||
screenOffsetIdsEqual(a.screenOffsets, b.screenOffsets)
|
||||
)
|
||||
}
|
||||
|
||||
function screenOffsetsEqual(a: LeafScreenOffsets | undefined, b: LeafScreenOffsets | undefined) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
if (a.size !== b.size) return false
|
||||
for (const [id, offset] of b) {
|
||||
const prev = a.get(id)
|
||||
if (!prev || prev.x !== offset.x || prev.y !== offset.y) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Which leaves carry an offset, ignoring the vectors: a leaf gains or loses one by changing
|
||||
* precision, which is a record change rather than a gesture. */
|
||||
function screenOffsetIdsEqual(a: LeafScreenOffsets | undefined, b: LeafScreenOffsets | undefined) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
if (a.size !== b.size) return false
|
||||
for (const id of b.keys()) {
|
||||
if (!a.has(id)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Whether two leaf lists have the same threads at the same positions, in the same order. */
|
||||
function clusterLeavesEqual(a: readonly LeafInput[], b: readonly LeafInput[]): boolean {
|
||||
if (a === b) return true
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const la = a[i]
|
||||
const lb = b[i]
|
||||
if (la.id !== lb.id || la.point.x !== lb.point.x || la.point.y !== lb.point.y) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Whether two leaf lists contain the same thread ids in the same order, ignoring positions. */
|
||||
function clusterLeafIdsEqual(a: readonly LeafInput[], b: readonly LeafInput[]): boolean {
|
||||
if (a === b) return true
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i].id !== b[i].id) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
151
packages/commenting/src/canvas/cluster-model.test.ts
Normal file
151
packages/commenting/src/canvas/cluster-model.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
commentSchemaRecords,
|
||||
createCommentThread,
|
||||
createShapeId,
|
||||
createTLSchema,
|
||||
createTLStore,
|
||||
defaultBindingUtils,
|
||||
defaultShapeUtils,
|
||||
defaultTools,
|
||||
Editor,
|
||||
PageRecordType,
|
||||
} from 'tldraw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createClusterRuntime } from '../clustering/runtime'
|
||||
import type { ClusterNode, ClusterTable, LeafInput } from '../clustering/types'
|
||||
import type { ClusterInput } from './cluster-input'
|
||||
import { anyFoldedLeafMoved, revealThreadPin, type ClusterModel } from './cluster-model'
|
||||
import { defaultCommentingOptions } from './options'
|
||||
|
||||
function node(ids: string[], x = 0, y = 0): ClusterNode {
|
||||
const members = ids.slice().sort()
|
||||
return {
|
||||
id: members.length === 1 ? members[0] : `cluster:${members.length}:${members[0]}`,
|
||||
centroid: { x, y },
|
||||
count: members.length,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
/** t1 and t2 fold into one badge below zoom 1; t3 sits far away and never merges. */
|
||||
const TABLE: ClusterTable = {
|
||||
leaves: [node(['t1'], 0, 0), node(['t2'], 10, 0), node(['t3'], 5000, 0)],
|
||||
events: [
|
||||
{
|
||||
zMerge: 1,
|
||||
zSplit: 2,
|
||||
children: [node(['t1']), node(['t2'])],
|
||||
result: node(['t1', 't2'], 5, 0),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/** The rendered partition at `zoom`: below 1 the badge is up, above 2 every pin stands alone. */
|
||||
function rendered(zoom: number): ClusterModel {
|
||||
const runtime = createClusterRuntime(TABLE)
|
||||
runtime.seed(zoom)
|
||||
return { runtime, table: TABLE }
|
||||
}
|
||||
|
||||
const leaf = (id: string, x: number, y: number): LeafInput => ({ id, point: { x, y } })
|
||||
const input = (leaves: LeafInput[]): ClusterInput => ({ leaves, screenOffsets: undefined })
|
||||
|
||||
const AT_REST = [leaf('t1', 0, 0), leaf('t2', 10, 0), leaf('t3', 5000, 0)]
|
||||
|
||||
describe('anyFoldedLeafMoved', () => {
|
||||
it('breaks the freeze when a leaf folded into a badge moves', () => {
|
||||
const model = rendered(0.5)
|
||||
expect([...model.runtime.getVisible().keys()].sort()).toEqual(['cluster:2:t1', 't3'])
|
||||
const next = input([leaf('t1', 40, 0), leaf('t2', 10, 0), leaf('t3', 5000, 0)])
|
||||
expect(anyFoldedLeafMoved(input(AT_REST), next, model)).toBe(true)
|
||||
})
|
||||
|
||||
it('holds the freeze when a visible leaf moves — its pin already rides its anchor', () => {
|
||||
const next = input([leaf('t1', 0, 0), leaf('t2', 10, 0), leaf('t3', 5040, 0)])
|
||||
expect(anyFoldedLeafMoved(input(AT_REST), next, rendered(0.5))).toBe(false)
|
||||
// and with the badge split, the same move of a former badge member is visible too
|
||||
const split = input([leaf('t1', 40, 0), leaf('t2', 10, 0), leaf('t3', 5000, 0)])
|
||||
expect(anyFoldedLeafMoved(input(AT_REST), split, rendered(3))).toBe(false)
|
||||
})
|
||||
|
||||
it('holds the freeze when an orphan moves — it is not in the rendered partition to pop out of', () => {
|
||||
// t4 was added since the last adopted rebuild: it is in the input but not in the rendered
|
||||
// table, so it renders as a plain live pin and no pop-out can ever hold it. Counting it as
|
||||
// folded would rebuild on every pointermove for the rest of the drag.
|
||||
const atRest = [...AT_REST, leaf('t4', 20, 0)]
|
||||
const next = input([...AT_REST, leaf('t4', 60, 0)])
|
||||
expect(anyFoldedLeafMoved(input(atRest), next, rendered(0.5))).toBe(false)
|
||||
})
|
||||
|
||||
it('holds the freeze when a detached leaf moves — it left the badge already', () => {
|
||||
const model = rendered(0.5)
|
||||
model.runtime.detachLeaves(['t1'])
|
||||
expect(
|
||||
anyFoldedLeafMoved(input(AT_REST), input([leaf('t1', 40, 0), ...AT_REST.slice(1)]), model)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('holds the freeze when nothing moved', () => {
|
||||
expect(anyFoldedLeafMoved(input(AT_REST), input([...AT_REST]), rendered(0.5))).toBe(false)
|
||||
})
|
||||
|
||||
it('holds the freeze with no rendered model yet', () => {
|
||||
const next = input([leaf('t1', 40, 0), leaf('t2', 10, 0), leaf('t3', 5000, 0)])
|
||||
expect(anyFoldedLeafMoved(input(AT_REST), next, null)).toBe(false)
|
||||
})
|
||||
|
||||
it('holds the freeze on mismatched inputs rather than reading past the end', () => {
|
||||
const next = input([leaf('t1', 40, 0), leaf('t2', 10, 0)])
|
||||
expect(anyFoldedLeafMoved(input(AT_REST), next, rendered(0.5))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('revealThreadPin', () => {
|
||||
it('records a cross-page jump as its own undo step', () => {
|
||||
const editor = new Editor({
|
||||
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
tools: defaultTools,
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
try {
|
||||
const page1Id = editor.getCurrentPageId()
|
||||
const page2Id = PageRecordType.createId()
|
||||
const boxId = createShapeId()
|
||||
|
||||
// Set up the second page outside of history so the only undoable steps are the ones
|
||||
// the test creates: one edit on page 1, then the jump to the pin on page 2.
|
||||
editor.run(() => editor.createPage({ id: page2Id, name: 'Page 2' }), { history: 'ignore' })
|
||||
editor.clearHistory()
|
||||
|
||||
editor.markHistoryStoppingPoint('create box')
|
||||
editor.createShape({ id: boxId, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } })
|
||||
|
||||
const thread = createCommentThread({
|
||||
pageId: page2Id,
|
||||
anchor: { type: 'point', x: 50, y: 50 },
|
||||
createdBy: 'me',
|
||||
})
|
||||
revealThreadPin(
|
||||
editor,
|
||||
thread,
|
||||
{ leaves: [], events: [] },
|
||||
{ minZoom: 0.1, maxZoom: 8 },
|
||||
{ ...defaultCommentingOptions, enableClustering: false },
|
||||
0
|
||||
)
|
||||
expect(editor.getCurrentPageId()).toBe(page2Id)
|
||||
|
||||
// The first undo only takes us back to page 1; the edit made there is still intact.
|
||||
editor.undo()
|
||||
expect(editor.getCurrentPageId()).toBe(page1Id)
|
||||
expect(editor.getShape(boxId)).toBeDefined()
|
||||
|
||||
editor.undo()
|
||||
expect(editor.getShape(boxId)).toBeUndefined()
|
||||
} finally {
|
||||
editor.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
416
packages/commenting/src/canvas/cluster-model.ts
Normal file
416
packages/commenting/src/canvas/cluster-model.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { clamp, Editor, react, TLCommentThread, useValue } from 'tldraw'
|
||||
import { computeClusterTable } from '../clustering/computeClusterTable'
|
||||
import { type ClusterRuntime, createClusterRuntime } from '../clustering/runtime'
|
||||
import type { ClusterNode, ClusterTable, MergeEvent } from '../clustering/types'
|
||||
import { type ClusterFadeNode, useFadeVisibleNodes } from './cluster-fade'
|
||||
import {
|
||||
type ClusterInput,
|
||||
clusterInputEqual,
|
||||
clusterInputIdsEqual,
|
||||
collectClusterLeaves,
|
||||
} from './cluster-input'
|
||||
import { type CommentingOptions } from './options'
|
||||
import { openThreadId } from './state'
|
||||
import { anchorPagePoint, commentCenterScreenOffset } from './thread-state'
|
||||
|
||||
/** Duration of the click-a-badge zoom-to-split animation. */
|
||||
export const CLUSTER_EXPAND_ZOOM_MS = 450
|
||||
/** How far past a cluster's split zoom to land when expanding it — a 5% overshoot, so the badge
|
||||
* lands clear of the threshold it just crossed rather than flickering on it. */
|
||||
const CLUSTER_SPLIT_ZOOM_FACTOR = 1.05
|
||||
|
||||
const EMPTY_SET: ReadonlySet<string> = new Set()
|
||||
const MOVED_LEAF_EPSILON = 1e-6
|
||||
|
||||
/** Default select-tool states that move shapes continuously, one store write per pointermove. A
|
||||
* custom tool in their place just never matches, falling back to a rebuild per frame. */
|
||||
const SHAPE_DRAG_STATE_PATHS = [
|
||||
'select.translating',
|
||||
'select.resizing',
|
||||
'select.rotating',
|
||||
'select.dragging_handle',
|
||||
'select.crop.cropping',
|
||||
] as const
|
||||
|
||||
/** Reactive: `isInAny` reads the tool state path, so a computed reading this re-evaluates when the
|
||||
* gesture starts or settles. */
|
||||
function isShapeDragInProgress(editor: Editor): boolean {
|
||||
return editor.isInAny(...SHAPE_DRAG_STATE_PATHS)
|
||||
}
|
||||
|
||||
/** The clustering table for the current scene, plus the runtime walking its merge events. */
|
||||
export interface ClusterModel {
|
||||
runtime: ClusterRuntime
|
||||
table: ClusterTable
|
||||
}
|
||||
|
||||
export interface ClusterZoomBounds {
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
}
|
||||
|
||||
export interface ClusterModelState {
|
||||
/** The partition actually on screen. See {@link useClusterModel} for why it can lag the input. */
|
||||
model: ClusterModel
|
||||
zoomBounds: ClusterZoomBounds
|
||||
/** The displayed nodes, each tagged with its cross-fade phase. */
|
||||
fadeNodes: ClusterFadeNode[]
|
||||
/** Threads in the input that the displayed partition doesn't show — render them as plain pins. */
|
||||
orphanThreads: TLCommentThread[]
|
||||
/** Threads held out of clustering because their anchor moved while folded into a badge. */
|
||||
heldThreads: TLCommentThread[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The clustering state machine behind the comments layer.
|
||||
*
|
||||
* The core invariant: the only thing that re-flows clustering doc-wide is zoom. Every rebuild is
|
||||
* computed immediately as `latestModel`, but the on-screen partition is `renderedModel`, which only
|
||||
* changes via (a) the cursor walking on zoom, (b) adoption of the pending rebuild on zoom-out, or
|
||||
* (c) LOCAL detach patches, where a leaf that left the input is detached from its own badge in
|
||||
* place and nothing else on the canvas moves.
|
||||
*
|
||||
* Threads the displayed partition can't represent are returned separately (`orphanThreads`,
|
||||
* `heldThreads`) for the layer to draw as ordinary pins.
|
||||
*/
|
||||
export function useClusterModel(
|
||||
editor: Editor,
|
||||
threads: readonly TLCommentThread[],
|
||||
openId: string | null
|
||||
): ClusterModelState {
|
||||
// Threads held out of clustering because their anchor moved while folded inside a badge
|
||||
// (drag, nudge, align, undo, a collaborator — detected by position, not gesture). They render
|
||||
// as live pins riding their anchor and rejoin clustering on the next zoom-out.
|
||||
const [heldThreadIds, setHeldThreadIds] = useState<ReadonlySet<string>>(EMPTY_SET)
|
||||
const adoptOnRebuild = useRef(false)
|
||||
// This input's identity keys the O(N²) table rebuild below, so it's gated on value equality: a
|
||||
// reply, a reaction, a resolve — anything that touches comment records without moving a pin —
|
||||
// returns the previous input and rebuilds nothing.
|
||||
//
|
||||
// Mid-drag the gate ignores positions too — except a folded leaf's, which a badge can't
|
||||
// follow. Its first move passes through, the pop-out below holds the pin out as a live pin,
|
||||
// and the input is static again for the rest of the drag.
|
||||
const clusterInputRef = useRef<ClusterInput>({ leaves: [], screenOffsets: undefined })
|
||||
const renderedModelRef = useRef<ClusterModel | null>(null)
|
||||
const clusterInput = useValue(
|
||||
'comment cluster leaves',
|
||||
() => {
|
||||
const next = collectClusterLeaves(
|
||||
editor,
|
||||
threads.filter((thread) => !heldThreadIds.has(thread.id)),
|
||||
openThreadId.get(editor)
|
||||
)
|
||||
const prev = clusterInputRef.current
|
||||
if (
|
||||
isShapeDragInProgress(editor) &&
|
||||
clusterInputIdsEqual(prev, next) &&
|
||||
!anyFoldedLeafMoved(prev, next, renderedModelRef.current)
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
if (clusterInputEqual(prev, next)) return prev
|
||||
clusterInputRef.current = next
|
||||
return next
|
||||
},
|
||||
[editor, threads, heldThreadIds]
|
||||
)
|
||||
const clusterZoomBounds = useValue(
|
||||
'comment cluster zoom bounds',
|
||||
() => getClusterZoomBounds(editor),
|
||||
[editor]
|
||||
)
|
||||
const latestModel = useMemo(() => {
|
||||
const table = computeClusterTable(
|
||||
clusterInput.leaves,
|
||||
clusterZoomBounds,
|
||||
clusterInput.screenOffsets
|
||||
)
|
||||
const runtime = createClusterRuntime(table)
|
||||
runtime.seed(editor.getZoomLevel())
|
||||
return { runtime, table }
|
||||
}, [clusterInput, clusterZoomBounds, editor])
|
||||
const [renderedModel, setRenderedModel] = useState(latestModel)
|
||||
let clusterModel = renderedModel
|
||||
// A page switch replaces the whole scene: hard-reset rather than detach the world.
|
||||
const pageId = useValue('comment cluster page', () => editor.getCurrentPageId(), [editor])
|
||||
const pageRef = useRef(pageId)
|
||||
if (pageRef.current !== pageId) {
|
||||
pageRef.current = pageId
|
||||
adoptOnRebuild.current = false
|
||||
latestModel.runtime.seed(editor.getZoomLevel())
|
||||
if (heldThreadIds.size > 0) setHeldThreadIds(EMPTY_SET)
|
||||
setRenderedModel(latestModel)
|
||||
clusterModel = latestModel
|
||||
}
|
||||
// adoptOnRebuild is set outside React's render cycle, paired with clearing heldThreadIds. Only trust
|
||||
// it once that pairing is visible here, or an unrelated re-render can land in the gap.
|
||||
const rejoinPending = heldThreadIds.size === 0 && adoptOnRebuild.current
|
||||
if (renderedModel !== latestModel && rejoinPending) {
|
||||
adoptOnRebuild.current = false
|
||||
// Carryover seed: band events inherit the outgoing partition's merged/unmerged state, so
|
||||
// nothing changes state because of the swap alone. Idempotent, so safe during render.
|
||||
latestModel.runtime.seedFrom(editor.getZoomLevel(), renderedModel.runtime.getVisible())
|
||||
setRenderedModel(latestModel)
|
||||
clusterModel = latestModel
|
||||
} else if (heldThreadIds.size === 0 && renderedModel === latestModel) {
|
||||
// Nothing pending and nothing to adopt: clear any leftover force-adopt intent so it can't
|
||||
// survive to force-adopt a later, unrelated rebuild.
|
||||
adoptOnRebuild.current = false
|
||||
}
|
||||
// For the input gate above: folded-vs-visible is judged against what's on screen.
|
||||
renderedModelRef.current = clusterModel
|
||||
// Pop-out detection: a leaf folded inside a badge can't follow its anchor (the badge position
|
||||
// is baked into the model), so when its live position drifts from the baked one, hold it out.
|
||||
// It renders as a live pin riding the anchor; the detach loop below shrinks its badge locally.
|
||||
const newlyMovedIds = findMovedClusteredLeafIds(clusterModel, latestModel)
|
||||
if (newlyMovedIds.length > 0) {
|
||||
const next = new Set(heldThreadIds)
|
||||
for (const id of newlyMovedIds) next.add(id)
|
||||
setHeldThreadIds(next)
|
||||
}
|
||||
const latestLeafIds = useMemo(
|
||||
() => new Set(latestModel.table.leaves.map((leaf) => leaf.id)),
|
||||
[latestModel]
|
||||
)
|
||||
// Local partition maintenance — the only non-zoom visual change. Any displayed leaf that has left the
|
||||
// cluster input is detached from its badge in place; the corrected rebuild already sits in latestModel
|
||||
// awaiting the next zoom-out. When the two models match, the leaf sets are identical, so skip the scan.
|
||||
if (clusterModel !== latestModel) {
|
||||
const removedLeafIds: string[] = []
|
||||
for (const leaf of clusterModel.table.leaves) {
|
||||
if (!latestLeafIds.has(leaf.id)) {
|
||||
removedLeafIds.push(leaf.id)
|
||||
}
|
||||
}
|
||||
// Batched: one patch rebuild and one version bump for the whole set.
|
||||
if (removedLeafIds.length > 0) clusterModel.runtime.detachLeaves(removedLeafIds)
|
||||
}
|
||||
// Moved pins rejoin clustering on the next zoom-out motion: clear the set (so the rebuild
|
||||
// includes them again) and adopt that rebuild immediately instead of deferring it. Zooming in
|
||||
// never folds pins into clusters — merging is a zoom-out-only move, matching the runtime.
|
||||
useEffect(() => {
|
||||
if (heldThreadIds.size === 0) return
|
||||
return reactOnZoomOut(editor, 'rejoin moved comment pins on zoom out', () => {
|
||||
adoptOnRebuild.current = true
|
||||
setHeldThreadIds(EMPTY_SET)
|
||||
})
|
||||
}, [heldThreadIds, editor])
|
||||
// Adopt a pending rebuild only on zoom-out motion: folding deferred additions into clusters is
|
||||
// a merge, and merging only happens while zooming out. While zooming in, the stale table still
|
||||
// splits correctly on its own (split thresholds are direction-safe by the hysteresis invariant).
|
||||
useEffect(() => {
|
||||
if (clusterModel === latestModel) return
|
||||
return reactOnZoomOut(editor, 'adopt pending cluster model on zoom out', (zoom) => {
|
||||
latestModel.runtime.seedFrom(zoom, clusterModel.runtime.getVisible())
|
||||
setRenderedModel(latestModel)
|
||||
})
|
||||
}, [clusterModel, latestModel, editor])
|
||||
// Threads the displayed partition doesn't show anywhere (new comments, reopened threads, undone
|
||||
// deletions) render as plain pins until the next zoom-out folds them in. Judged against the
|
||||
// displayed partition, so a detached-then-restored leaf reappears.
|
||||
const partitionVersion = clusterModel.runtime.version
|
||||
const orphanThreads = useMemo(() => {
|
||||
if (clusterModel === latestModel) return []
|
||||
const displayed = new Set<string>()
|
||||
for (const node of clusterModel.runtime.getVisible().values()) {
|
||||
for (const member of node.members) displayed.add(member)
|
||||
}
|
||||
return threads.filter((thread) => latestLeafIds.has(thread.id) && !displayed.has(thread.id))
|
||||
// The runtime mutates its partition in place; partitionVersion is its change stamp.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [clusterModel, latestModel, latestLeafIds, threads, partitionVersion])
|
||||
const heldThreads = useMemo(
|
||||
() => threads.filter((thread) => heldThreadIds.has(thread.id) && thread.id !== openId),
|
||||
[threads, heldThreadIds, openId]
|
||||
)
|
||||
// Subscribe to the runtime's partition version, not the raw zoom, so this only re-renders on cluster
|
||||
// changes rather than every camera frame. The memo below re-reads the version inline because
|
||||
// render-time detaches bump it after the subscription's computed already evaluated.
|
||||
useValue(
|
||||
'comment cluster version',
|
||||
() => {
|
||||
clusterModel.runtime.onCamera(editor.getZoomLevel())
|
||||
return clusterModel.runtime.version
|
||||
},
|
||||
[clusterModel, editor]
|
||||
)
|
||||
const visibleNodes = useMemo(() => {
|
||||
return Array.from(clusterModel.runtime.getVisible().values())
|
||||
// The runtime mutates its partition in place; partitionVersion is its change stamp.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [clusterModel, partitionVersion])
|
||||
const fadeNodes = useFadeVisibleNodes(visibleNodes, clusterModel)
|
||||
|
||||
return {
|
||||
model: clusterModel,
|
||||
zoomBounds: clusterZoomBounds,
|
||||
fadeNodes,
|
||||
orphanThreads,
|
||||
heldThreads,
|
||||
}
|
||||
}
|
||||
|
||||
/** Run `onZoomOut` on each zoom change that decreased the zoom, starting from the current level. */
|
||||
function reactOnZoomOut(editor: Editor, name: string, onZoomOut: (zoom: number) => void) {
|
||||
let lastZoom = editor.getZoomLevel()
|
||||
return react(name, () => {
|
||||
const zoom = editor.getZoomLevel()
|
||||
const prevZoom = lastZoom
|
||||
lastZoom = zoom
|
||||
if (zoom < prevZoom) onZoomOut(zoom)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a position-only input change (ids equal, same order) moved a leaf folded inside a badge
|
||||
* of the rendered partition — the one move the mid-drag freeze must let through.
|
||||
*
|
||||
* Folded means a member of a displayed badge, not merely "absent from the displayed partition":
|
||||
* the input also carries orphans (threads the rendered partition has never seen) and detached
|
||||
* leaves, which already ride their anchors as plain pins. Neither is in the rendered table, so the
|
||||
* pop-out below can never hold one — counting them here would break the freeze on every
|
||||
* pointermove for the rest of the drag.
|
||||
* @internal
|
||||
*/
|
||||
export function anyFoldedLeafMoved(
|
||||
prev: ClusterInput,
|
||||
next: ClusterInput,
|
||||
rendered: ClusterModel | null
|
||||
): boolean {
|
||||
if (!rendered) return false
|
||||
if (prev.leaves.length !== next.leaves.length) return false
|
||||
const folded = new Set<string>()
|
||||
for (const node of rendered.runtime.getVisible().values()) {
|
||||
if (node.count < 2) continue
|
||||
for (const member of node.members) folded.add(member)
|
||||
}
|
||||
// No badges on screen, so nothing can be folded — the common case, and the cheap way out of it.
|
||||
if (folded.size === 0) return false
|
||||
for (let i = 0; i < next.leaves.length; i++) {
|
||||
if (!folded.has(next.leaves[i].id)) continue
|
||||
const a = prev.leaves[i].point
|
||||
const b = next.leaves[i].point
|
||||
if (Math.abs(a.x - b.x) > MOVED_LEAF_EPSILON || Math.abs(a.y - b.y) > MOVED_LEAF_EPSILON) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Leaves folded inside a badge whose live anchor no longer matches the position the rendered
|
||||
* model was built with. Visible (unclustered) leaf pins track their anchor live, so they can
|
||||
* stay deferred; a badge can't follow a member, so these must pop out of clustering.
|
||||
*/
|
||||
function findMovedClusteredLeafIds(rendered: ClusterModel, latest: { table: ClusterTable }) {
|
||||
if (rendered.table === latest.table) return []
|
||||
const visible = rendered.runtime.getVisible()
|
||||
const latestById = new Map(latest.table.leaves.map((leaf) => [leaf.id, leaf]))
|
||||
const moved: string[] = []
|
||||
for (const leaf of rendered.table.leaves) {
|
||||
if (visible.has(leaf.id)) continue
|
||||
const current = latestById.get(leaf.id)
|
||||
if (!current) continue
|
||||
if (
|
||||
Math.abs(current.centroid.x - leaf.centroid.x) > MOVED_LEAF_EPSILON ||
|
||||
Math.abs(current.centroid.y - leaf.centroid.y) > MOVED_LEAF_EPSILON
|
||||
) {
|
||||
moved.push(leaf.id)
|
||||
}
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
function getClusterZoomBounds(editor: Editor): ClusterZoomBounds {
|
||||
const cameraOptions = editor.getCameraOptions()
|
||||
const baseZoom = cameraOptions.constraints ? editor.getBaseZoom() : 1
|
||||
const zoomSteps = cameraOptions.zoomSteps
|
||||
return {
|
||||
minZoom: zoomSteps[0] * baseZoom,
|
||||
maxZoom: zoomSteps[zoomSteps.length - 1] * baseZoom,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a thread's pin into view: switch pages if needed, then zoom to the first cluster split
|
||||
* that unfolds it from its badge (or just centre on it when it isn't clustered).
|
||||
*/
|
||||
export function revealThreadPin(
|
||||
editor: Editor,
|
||||
thread: TLCommentThread,
|
||||
table: ClusterTable,
|
||||
zoomBounds: ClusterZoomBounds,
|
||||
options: CommentingOptions,
|
||||
duration = 200
|
||||
) {
|
||||
if (thread.pageId !== editor.getCurrentPageId()) {
|
||||
editor.markHistoryStoppingPoint('change-page')
|
||||
editor.setCurrentPage(thread.pageId as any)
|
||||
}
|
||||
|
||||
const point = anchorPagePoint(editor, thread.anchor)
|
||||
if (!point) return
|
||||
|
||||
// With clustering off the pin always renders individually, so skip the zoom-to-split (its cluster
|
||||
// badge never exists) and just center on the pin.
|
||||
if (options.enableClustering) {
|
||||
const parentEvent = findDirectParentEvent(table, thread.id)
|
||||
if (
|
||||
parentEvent &&
|
||||
Number.isFinite(parentEvent.zSplit) &&
|
||||
parentEvent.zSplit <= zoomBounds.maxZoom
|
||||
) {
|
||||
centerOnPointAtZoom(editor, point, splitZoom(parentEvent, zoomBounds), duration)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()
|
||||
editor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom to just past the zoom at which a cluster first unclusters, centered on its centroid. The
|
||||
* event that created a visible cluster is the event that splits it, and has the smallest zSplit of
|
||||
* everything applied inside it — so its zSplit is exactly the first split within those comments.
|
||||
* The animated zoom drives the runtime cursor like any manual zoom. A no-op with no split event.
|
||||
*/
|
||||
export function zoomToClusterSplit(
|
||||
editor: Editor,
|
||||
table: ClusterTable,
|
||||
zoomBounds: ClusterZoomBounds,
|
||||
node: ClusterNode
|
||||
) {
|
||||
const event = table.events.find((e) => e.result.id === node.id)
|
||||
if (!event || !Number.isFinite(event.zSplit)) return
|
||||
centerOnPointAtZoom(editor, node.centroid, splitZoom(event, zoomBounds), CLUSTER_EXPAND_ZOOM_MS)
|
||||
}
|
||||
|
||||
/** The zoom to land on to see a merge event's cluster just after it splits. */
|
||||
function splitZoom(event: MergeEvent, zoomBounds: ClusterZoomBounds): number {
|
||||
return clamp(event.zSplit * CLUSTER_SPLIT_ZOOM_FACTOR, zoomBounds.minZoom, zoomBounds.maxZoom)
|
||||
}
|
||||
|
||||
function findDirectParentEvent(table: ClusterTable, threadId: string): MergeEvent | undefined {
|
||||
return table.events.find((event) => event.children.some((child) => child.id === threadId))
|
||||
}
|
||||
|
||||
function centerOnPointAtZoom(
|
||||
editor: Editor,
|
||||
point: { x: number; y: number },
|
||||
zoom: number,
|
||||
duration = 200
|
||||
) {
|
||||
const viewport = editor.getViewportScreenBounds()
|
||||
// The open sidebar shifts the target left so the pin lands mid-uncovered-area, not under it.
|
||||
const offset = commentCenterScreenOffset(editor)
|
||||
editor.setCamera(
|
||||
{
|
||||
x: (viewport.w / 2 - offset) / zoom - point.x,
|
||||
y: viewport.h / (2 * zoom) - point.y,
|
||||
z: zoom,
|
||||
},
|
||||
{ animation: { duration } }
|
||||
)
|
||||
}
|
||||
21
packages/commenting/src/canvas/comment-body.tsx
Normal file
21
packages/commenting/src/canvas/comment-body.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useMemo } from 'react'
|
||||
import { TLRichText } from 'tldraw'
|
||||
import { renderCommentHtml } from './comment-render'
|
||||
|
||||
/** @public */
|
||||
export interface CommentBodyProps {
|
||||
richText: TLRichText
|
||||
/** Maps a member id to its current display name, so \@mentions show the live name. */
|
||||
resolveName?(id: string): string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a comment's rich-text body read-only through the limited comment extension set (no
|
||||
* headings), so formatting (bold, links, lists, highlight) is preserved rather than flattened, and
|
||||
* headings can never render. Use this as the `body` of a `CommentCard` on a canvas.
|
||||
* @public @react
|
||||
*/
|
||||
export function CommentBody({ richText, resolveName }: CommentBodyProps) {
|
||||
const html = useMemo(() => renderCommentHtml(richText, resolveName), [richText, resolveName])
|
||||
return <div className="tlui-cmt-text" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
}
|
||||
55
packages/commenting/src/canvas/comment-drafts.test.ts
Normal file
55
packages/commenting/src/canvas/comment-drafts.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/* eslint-disable tldraw/no-direct-storage */
|
||||
import type { TLRichText } from 'tldraw'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clearCommentDraft,
|
||||
getCommentDraft,
|
||||
NEW_COMMENT_DRAFT,
|
||||
replyDraftSlot,
|
||||
saveCommentDraft,
|
||||
} from './comment-drafts'
|
||||
|
||||
function richText(text: string): TLRichText {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
||||
} as unknown as TLRichText
|
||||
}
|
||||
|
||||
const EMPTY = { type: 'doc', content: [{ type: 'paragraph' }] } as unknown as TLRichText
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('comment drafts', () => {
|
||||
it('round-trips a draft through its slot', () => {
|
||||
saveCommentDraft(NEW_COMMENT_DRAFT, richText('hello'))
|
||||
expect(getCommentDraft(NEW_COMMENT_DRAFT)).toEqual(richText('hello'))
|
||||
})
|
||||
|
||||
it('keeps reply drafts apart by thread id', () => {
|
||||
saveCommentDraft(replyDraftSlot('comment-thread:a'), richText('for a'))
|
||||
saveCommentDraft(replyDraftSlot('comment-thread:b'), richText('for b'))
|
||||
expect(getCommentDraft(replyDraftSlot('comment-thread:a'))).toEqual(richText('for a'))
|
||||
expect(getCommentDraft(replyDraftSlot('comment-thread:b'))).toEqual(richText('for b'))
|
||||
expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('saving empty content clears the slot instead', () => {
|
||||
saveCommentDraft(NEW_COMMENT_DRAFT, richText('typed then deleted'))
|
||||
saveCommentDraft(NEW_COMMENT_DRAFT, EMPTY)
|
||||
expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears a slot explicitly', () => {
|
||||
saveCommentDraft(NEW_COMMENT_DRAFT, richText('posted'))
|
||||
clearCommentDraft(NEW_COMMENT_DRAFT)
|
||||
expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for a corrupt stored value', () => {
|
||||
localStorage.setItem('tldraw-comment-draft:new', '{not json')
|
||||
expect(getCommentDraft(NEW_COMMENT_DRAFT)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
46
packages/commenting/src/canvas/comment-drafts.ts
Normal file
46
packages/commenting/src/canvas/comment-drafts.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { deleteFromLocalStorage, getFromLocalStorage, setInLocalStorage } from '@tldraw/utils'
|
||||
import type { TLRichText } from 'tldraw'
|
||||
import { isCommentEmpty } from '../ui/comment-extensions'
|
||||
|
||||
const DRAFT_KEY_PREFIX = 'tldraw-comment-draft'
|
||||
|
||||
/** The single slot for an unsent new-comment (placement composer) draft. The anchor is re-picked
|
||||
* by clicking when the composer reopens, so only the text is worth keeping — and without a
|
||||
* universal document id in the SDK, one slot is the honest scope. */
|
||||
export const NEW_COMMENT_DRAFT = 'new'
|
||||
|
||||
/** The draft slot for an unsent reply on a thread. Thread record ids are globally unique and
|
||||
* stable across reloads, so replies never need a document key. @public */
|
||||
export function replyDraftSlot(threadId: string): string {
|
||||
return `reply:${threadId}`
|
||||
}
|
||||
|
||||
/** Read a saved unsent draft, or undefined when there is none (or it fails to parse). @public */
|
||||
export function getCommentDraft(slot: string): TLRichText | undefined {
|
||||
const raw = getFromLocalStorage(`${DRAFT_KEY_PREFIX}:${slot}`)
|
||||
if (!raw) return undefined
|
||||
try {
|
||||
return JSON.parse(raw) as TLRichText
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an unsent draft so click-away and reload don't lose it — the flip side of not warning
|
||||
* before discard. Saving empty content clears the slot instead, so deleting your text and
|
||||
* closing doesn't resurrect an empty draft later.
|
||||
* @public
|
||||
*/
|
||||
export function saveCommentDraft(slot: string, value: TLRichText) {
|
||||
if (isCommentEmpty(value)) {
|
||||
clearCommentDraft(slot)
|
||||
return
|
||||
}
|
||||
setInLocalStorage(`${DRAFT_KEY_PREFIX}:${slot}`, JSON.stringify(value))
|
||||
}
|
||||
|
||||
/** Remove a saved draft (after posting, or when its content was emptied). @public */
|
||||
export function clearCommentDraft(slot: string) {
|
||||
deleteFromLocalStorage(`${DRAFT_KEY_PREFIX}:${slot}`)
|
||||
}
|
||||
452
packages/commenting/src/canvas/comment-mutations.test.ts
Normal file
452
packages/commenting/src/canvas/comment-mutations.test.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import {
|
||||
commentSchemaRecords,
|
||||
createCommentThread,
|
||||
createShapeId,
|
||||
createTLSchema,
|
||||
createTLStore,
|
||||
defaultBindingUtils,
|
||||
defaultShapeUtils,
|
||||
defaultTools,
|
||||
Editor,
|
||||
TLComment,
|
||||
TLCommentThread,
|
||||
toRichText,
|
||||
createComment as tlCreateComment,
|
||||
} from 'tldraw'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
commitCommentMutation,
|
||||
deleteComment,
|
||||
deleteThread,
|
||||
editComment,
|
||||
putCommentRecords,
|
||||
removeCommentRecords,
|
||||
reopenThread,
|
||||
resolveThread,
|
||||
} from './comment-mutations'
|
||||
import {
|
||||
getCommentRecord,
|
||||
getComments,
|
||||
getCommentThreads,
|
||||
getLiveComments,
|
||||
getLiveCommentThreads,
|
||||
} from './comment-store'
|
||||
import { CommentTool } from './comment-tool'
|
||||
import { openThreadId } from './state'
|
||||
|
||||
/**
|
||||
* These need a real editor: what's under test is how each write interacts with the store and the
|
||||
* undo stack, which is exactly what the history option governs.
|
||||
*/
|
||||
|
||||
let editors: Editor[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const editor of editors) editor.dispose()
|
||||
editors = []
|
||||
})
|
||||
|
||||
function makeEditor(tool?: ReturnType<typeof CommentTool.configure>): Editor {
|
||||
const editor = new Editor({
|
||||
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
tools: tool ? [...defaultTools, tool] : defaultTools,
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
editors.push(editor)
|
||||
return editor
|
||||
}
|
||||
|
||||
function makeThread(editor: Editor, createdBy = 'me') {
|
||||
const thread = createCommentThread({
|
||||
pageId: editor.getCurrentPageId(),
|
||||
anchor: { type: 'point', x: 0, y: 0 },
|
||||
createdBy,
|
||||
})
|
||||
const comment = tlCreateComment({
|
||||
threadId: thread.id,
|
||||
pageId: thread.pageId,
|
||||
authorId: createdBy,
|
||||
body: toRichText('hello'),
|
||||
})
|
||||
putCommentRecords(editor, [thread, comment])
|
||||
return { thread, comment }
|
||||
}
|
||||
|
||||
function addComment(editor: Editor, thread: TLCommentThread, authorId = 'me') {
|
||||
const comment = tlCreateComment({
|
||||
threadId: thread.id,
|
||||
pageId: thread.pageId,
|
||||
authorId,
|
||||
body: toRichText('reply'),
|
||||
})
|
||||
putCommentRecords(editor, [comment])
|
||||
return comment
|
||||
}
|
||||
|
||||
function readComment(editor: Editor, comment: TLComment) {
|
||||
return getCommentRecord(editor, comment.id) as TLComment | undefined
|
||||
}
|
||||
|
||||
function readThread(editor: Editor, thread: TLCommentThread) {
|
||||
return getCommentRecord(editor, thread.id) as TLCommentThread | undefined
|
||||
}
|
||||
|
||||
describe('editComment', () => {
|
||||
it('replaces the body and stamps editedAt', () => {
|
||||
const editor = makeEditor()
|
||||
const { comment } = makeThread(editor)
|
||||
expect(comment.editedAt).toBe(null)
|
||||
|
||||
editComment(editor, comment, toRichText('changed'))
|
||||
|
||||
const updated = readComment(editor, comment)!
|
||||
expect(updated.body).toEqual(toRichText('changed'))
|
||||
expect(typeof updated.editedAt).toBe('number')
|
||||
})
|
||||
|
||||
// The caller's record is a snapshot. Writing it back would revert every field it doesn't mean
|
||||
// to touch — here `meta`, set after the caller took its copy.
|
||||
it('edits the version in the store, not the copy it was handed', () => {
|
||||
const editor = makeEditor()
|
||||
const { comment } = makeThread(editor)
|
||||
putCommentRecords(editor, [{ ...comment, meta: { pinned: true } }])
|
||||
|
||||
editComment(editor, comment, toRichText('changed'))
|
||||
|
||||
expect(readComment(editor, comment)).toMatchObject({
|
||||
body: toRichText('changed'),
|
||||
meta: { pinned: true },
|
||||
})
|
||||
})
|
||||
|
||||
// `put` is an upsert, so writing a snapshot back would re-create the record — with
|
||||
// `isDeleted: false` — after someone else's delete had already taken it away.
|
||||
it('does not resurrect a comment that has been removed since', () => {
|
||||
const editor = makeEditor()
|
||||
const { comment } = makeThread(editor)
|
||||
removeCommentRecords(editor, [comment.id])
|
||||
|
||||
editComment(editor, comment, toRichText('changed'))
|
||||
|
||||
expect(readComment(editor, comment)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveThread and reopenThread', () => {
|
||||
it('stamps who resolved the thread and when', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
|
||||
resolveThread(editor, thread, 'ada')
|
||||
|
||||
const resolved = readThread(editor, thread)!.resolved!
|
||||
expect(resolved.by).toBe('ada')
|
||||
expect(typeof resolved.at).toBe('number')
|
||||
})
|
||||
|
||||
it('clears the resolution when reopened', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
resolveThread(editor, thread, 'ada')
|
||||
|
||||
reopenThread(editor, readThread(editor, thread)!)
|
||||
|
||||
expect(readThread(editor, thread)!.resolved).toBe(null)
|
||||
})
|
||||
|
||||
// A thread's anchor moves without anyone touching the thread — a pin drag, or a pinned shape's
|
||||
// delete converting it to a point. Resolving from a copy taken before that must not undo it.
|
||||
it('resolves the version in the store, leaving an anchor moved since where it is', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
putCommentRecords(editor, [{ ...thread, anchor: { type: 'point', x: 50, y: 50 } }])
|
||||
|
||||
resolveThread(editor, thread, 'ada')
|
||||
|
||||
const updated = readThread(editor, thread)!
|
||||
expect(updated.anchor).toEqual({ type: 'point', x: 50, y: 50 })
|
||||
expect(updated.resolved!.by).toBe('ada')
|
||||
})
|
||||
|
||||
it('reopens the version in the store, leaving an anchor moved since where it is', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
resolveThread(editor, thread, 'ada')
|
||||
putCommentRecords(editor, [
|
||||
{ ...readThread(editor, thread)!, anchor: { type: 'point', x: 50, y: 50 } },
|
||||
])
|
||||
|
||||
reopenThread(editor, thread)
|
||||
|
||||
const updated = readThread(editor, thread)!
|
||||
expect(updated.anchor).toEqual({ type: 'point', x: 50, y: 50 })
|
||||
expect(updated.resolved).toBe(null)
|
||||
})
|
||||
|
||||
it('does nothing to a thread that has been removed since', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
removeCommentRecords(editor, [thread.id])
|
||||
|
||||
resolveThread(editor, thread, 'ada')
|
||||
reopenThread(editor, thread)
|
||||
|
||||
expect(readThread(editor, thread)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteComment', () => {
|
||||
it('soft-deletes: the record stays put with its flag set, for the server to prune', () => {
|
||||
const editor = makeEditor()
|
||||
const { comment } = makeThread(editor)
|
||||
|
||||
deleteComment(editor, comment)
|
||||
|
||||
expect(readComment(editor, comment)).toMatchObject({ isDeleted: true })
|
||||
expect(getComments(editor)).toHaveLength(1)
|
||||
expect(getLiveComments(editor)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('closes the thread when its last comment goes', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread, comment } = makeThread(editor)
|
||||
openThreadId.set(editor, thread.id)
|
||||
|
||||
deleteComment(editor, comment)
|
||||
|
||||
expect(openThreadId.get(editor)).toBe(null)
|
||||
})
|
||||
|
||||
it('leaves the thread open while it still has comments', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread, comment } = makeThread(editor)
|
||||
addComment(editor, thread)
|
||||
openThreadId.set(editor, thread.id)
|
||||
|
||||
deleteComment(editor, comment)
|
||||
|
||||
expect(openThreadId.get(editor)).toBe(thread.id)
|
||||
})
|
||||
|
||||
// Deleting the same comment twice — a double activation, a handler holding a copy taken before
|
||||
// the first delete — shouldn't read as "the last comment went" while a sibling is still live.
|
||||
it('leaves the thread open when the comment was already deleted', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread, comment } = makeThread(editor)
|
||||
addComment(editor, thread)
|
||||
deleteComment(editor, comment)
|
||||
openThreadId.set(editor, thread.id)
|
||||
|
||||
deleteComment(editor, comment)
|
||||
|
||||
expect(openThreadId.get(editor)).toBe(thread.id)
|
||||
})
|
||||
|
||||
it('does not resurrect a comment that has been removed since', () => {
|
||||
const editor = makeEditor()
|
||||
const { comment } = makeThread(editor)
|
||||
removeCommentRecords(editor, [comment.id])
|
||||
|
||||
deleteComment(editor, comment)
|
||||
|
||||
expect(readComment(editor, comment)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves a different open thread alone', () => {
|
||||
const editor = makeEditor()
|
||||
const { comment } = makeThread(editor)
|
||||
const other = makeThread(editor)
|
||||
openThreadId.set(editor, other.thread.id)
|
||||
|
||||
deleteComment(editor, comment)
|
||||
|
||||
expect(openThreadId.get(editor)).toBe(other.thread.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteThread', () => {
|
||||
it('soft-deletes the thread record', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
|
||||
deleteThread(editor, thread)
|
||||
|
||||
expect(readThread(editor, thread)).toMatchObject({ isDeleted: true })
|
||||
expect(getCommentThreads(editor)).toHaveLength(1)
|
||||
expect(getLiveCommentThreads(editor)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('closes the thread when it is the open one', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
openThreadId.set(editor, thread.id)
|
||||
|
||||
deleteThread(editor, thread)
|
||||
|
||||
expect(openThreadId.get(editor)).toBe(null)
|
||||
})
|
||||
|
||||
it('flags the version in the store, leaving an anchor moved since where it is', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
putCommentRecords(editor, [{ ...thread, anchor: { type: 'point', x: 50, y: 50 } }])
|
||||
|
||||
deleteThread(editor, thread)
|
||||
|
||||
expect(readThread(editor, thread)).toMatchObject({
|
||||
isDeleted: true,
|
||||
anchor: { type: 'point', x: 50, y: 50 },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not resurrect a thread that has been removed since', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
removeCommentRecords(editor, [thread.id])
|
||||
|
||||
deleteThread(editor, thread)
|
||||
|
||||
expect(readThread(editor, thread)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('history', () => {
|
||||
it('does not put comment writes on the undo stack by default', () => {
|
||||
const editor = makeEditor()
|
||||
editor.markHistoryStoppingPoint()
|
||||
const { comment } = makeThread(editor)
|
||||
|
||||
editor.undo()
|
||||
|
||||
expect(readComment(editor, comment)).toBeDefined()
|
||||
})
|
||||
|
||||
it('puts them on the undo stack when the history option asks for it', () => {
|
||||
const editor = makeEditor(CommentTool.configure({ history: 'record' }))
|
||||
editor.markHistoryStoppingPoint()
|
||||
const { comment } = makeThread(editor)
|
||||
|
||||
editor.undo()
|
||||
|
||||
expect(readComment(editor, comment)).toBeUndefined()
|
||||
})
|
||||
|
||||
// The flag is write-once server-side, so an undo clearing it would be vetoed and rebased —
|
||||
// leaving the UI showing a comment the server has already pruned. The shape gives the undo
|
||||
// something recorded to rewind, so what's under test is that the delete isn't on the stack
|
||||
// rather than that the stack is empty.
|
||||
it('never makes a delete undoable, even with history: record', () => {
|
||||
const editor = makeEditor(CommentTool.configure({ history: 'record' }))
|
||||
const { comment } = makeThread(editor)
|
||||
editor.markHistoryStoppingPoint()
|
||||
const shapeId = createShapeId()
|
||||
editor.createShape({ id: shapeId, type: 'geo', x: 0, y: 0 })
|
||||
|
||||
deleteComment(editor, comment)
|
||||
editor.undo()
|
||||
|
||||
expect(editor.getShape(shapeId)).toBeUndefined()
|
||||
expect(readComment(editor, comment)).toMatchObject({ isDeleted: true })
|
||||
})
|
||||
|
||||
it('never makes a thread delete undoable, even with history: record', () => {
|
||||
const editor = makeEditor(CommentTool.configure({ history: 'record' }))
|
||||
const { thread } = makeThread(editor)
|
||||
editor.markHistoryStoppingPoint()
|
||||
const shapeId = createShapeId()
|
||||
editor.createShape({ id: shapeId, type: 'geo', x: 0, y: 0 })
|
||||
|
||||
deleteThread(editor, thread)
|
||||
editor.undo()
|
||||
|
||||
expect(editor.getShape(shapeId)).toBeUndefined()
|
||||
expect(readThread(editor, thread)).toMatchObject({ isDeleted: true })
|
||||
})
|
||||
|
||||
it('rejects a nested mutation that resolves to a different history mode', () => {
|
||||
const editor = makeEditor(CommentTool.configure({ history: 'record' }))
|
||||
const { comment } = makeThread(editor)
|
||||
|
||||
// A delete always ignores history, so it can't run inside a `record` commit.
|
||||
expect(() => commitCommentMutation(editor, () => deleteComment(editor, comment))).toThrow(
|
||||
"records history as 'ignore' can't run inside one recording it as 'record'"
|
||||
)
|
||||
|
||||
deleteComment(editor, comment)
|
||||
expect(readComment(editor, comment)).toMatchObject({ isDeleted: true })
|
||||
})
|
||||
|
||||
// Store history flushes synchronously under test, so this listener writes from inside the commit
|
||||
// that triggered it — where a side effect always sits, with no "after the mutation" to defer to.
|
||||
// Matching modes have nothing to disagree about, so its write goes through.
|
||||
it('lets a store listener write comments during a commit when the modes match', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread, comment } = makeThread(editor)
|
||||
let hasReacted = false
|
||||
editor.store.listen(() => {
|
||||
if (hasReacted) return
|
||||
hasReacted = true
|
||||
putCommentRecords(editor, [{ ...thread, meta: { lastEditedComment: comment.id } }])
|
||||
})
|
||||
|
||||
editComment(editor, comment, toRichText('edited'))
|
||||
|
||||
expect(readThread(editor, thread)!.meta).toEqual({ lastEditedComment: comment.id })
|
||||
})
|
||||
|
||||
it('rejects a writer used after its commit', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread } = makeThread(editor)
|
||||
let writeAfterCommit: () => void
|
||||
|
||||
commitCommentMutation(editor, ({ put }) => {
|
||||
writeAfterCommit = () => put([{ ...thread, resolved: { at: 1, by: 'ada' } }])
|
||||
})
|
||||
|
||||
expect(() => writeAfterCommit!()).toThrow('cannot be used after its commit has finished')
|
||||
})
|
||||
|
||||
// A host can want pin drags undoable while posts and edits aren't. The drag owns its commit, and
|
||||
// its records go through the writer, which keeps `dragHistory` in charge of them.
|
||||
it('lets dragHistory govern a drag on its own', () => {
|
||||
const editor = makeEditor(CommentTool.configure({ history: 'ignore', dragHistory: 'record' }))
|
||||
const { thread } = makeThread(editor)
|
||||
editor.markHistoryStoppingPoint()
|
||||
|
||||
commitCommentMutation(
|
||||
editor,
|
||||
({ put }) => put([{ ...thread, anchor: { type: 'point', x: 50, y: 50 } }]),
|
||||
'drag'
|
||||
)
|
||||
editor.undo()
|
||||
|
||||
expect(readThread(editor, thread)!.anchor).toEqual({ type: 'point', x: 0, y: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLiveComments and getLiveCommentThreads', () => {
|
||||
it('hides a thread whose comments have all been deleted', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread, comment } = makeThread(editor)
|
||||
|
||||
deleteComment(editor, comment)
|
||||
|
||||
// The thread record itself is untouched — the deleter may not be its creator — but with no
|
||||
// live comment left it has no surface.
|
||||
expect(readThread(editor, thread)!.isDeleted).toBe(false)
|
||||
expect(getLiveCommentThreads(editor)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps a thread that still has a live comment', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread, comment } = makeThread(editor)
|
||||
addComment(editor, thread)
|
||||
|
||||
deleteComment(editor, comment)
|
||||
|
||||
expect(getLiveComments(editor)).toHaveLength(1)
|
||||
expect(getLiveCommentThreads(editor)).toEqual([readThread(editor, thread)])
|
||||
})
|
||||
})
|
||||
299
packages/commenting/src/canvas/comment-mutations.ts
Normal file
299
packages/commenting/src/canvas/comment-mutations.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
Editor,
|
||||
TLComment,
|
||||
TLCommentId,
|
||||
TLCommentReactionId,
|
||||
TLCommentThread,
|
||||
TLCommentThreadId,
|
||||
TLHistoryBatchOptions,
|
||||
TLRecord,
|
||||
TLRichText,
|
||||
} from 'tldraw'
|
||||
import { getCommentRecord, getLiveComments, type TLCommentRecord } from './comment-store'
|
||||
import { getCommentingOptions, type CommentingOptions } from './options'
|
||||
import { openThreadId } from './state'
|
||||
|
||||
/**
|
||||
* Every write to a comment record, and the undo/redo policy governing them.
|
||||
*
|
||||
* The file layers bottom-up: {@link commitCommentMutation} resolves the history mode and hands its
|
||||
* callback a writer, {@link putCommentRecords} and {@link removeCommentRecords} are the typed
|
||||
* writes that run under it, and the verbs below are those writes plus the one rule each carries.
|
||||
* Every verb takes the record it acts on as the identity of what to change, not the value to write
|
||||
* back — see {@link readLatest}.
|
||||
*
|
||||
* Posting carries no such rule, so it isn't a verb here: build the records with
|
||||
* `createCommentThread`/`createComment` and write them with {@link putCommentRecords}.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Which history policy a comment write follows:
|
||||
*
|
||||
* - `mutation` — {@link CommentingOptions.history}: posts, replies, edits, resolves.
|
||||
* - `drag` — {@link CommentingOptions.dragHistory}, falling back to `history`: pin and region
|
||||
* re-anchors, which are spatial edits a host may reasonably want undoable alongside a shape move.
|
||||
* - `delete` — always `'ignore'`, whatever the options say. A soft-delete flag is write-once
|
||||
* server-side, so an undo clearing it would be vetoed and rebased rather than restore anything.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export type CommentMutationKind = 'delete' | 'drag' | 'mutation'
|
||||
|
||||
interface CommentMutationWriter {
|
||||
put(records: TLCommentRecord[]): void
|
||||
remove(ids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]): void
|
||||
}
|
||||
|
||||
const activeCommentMutations = new WeakMap<Editor, { history: TLHistoryBatchOptions['history'] }>()
|
||||
|
||||
/** The undo/redo mode a write of the given kind runs under. See {@link CommentMutationKind}. */
|
||||
function historyModeFor(
|
||||
options: CommentingOptions,
|
||||
kind: CommentMutationKind
|
||||
): TLHistoryBatchOptions['history'] {
|
||||
switch (kind) {
|
||||
case 'delete':
|
||||
return 'ignore'
|
||||
case 'drag':
|
||||
return options.dragHistory ?? options.history
|
||||
case 'mutation':
|
||||
return options.history
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a comment mutation with the configured undo/redo behavior, so the
|
||||
* {@link CommentingOptions.history} option governs whether it lands on the undo stack. Defaults to
|
||||
* `'ignore'`. See {@link CommentMutationKind} for what each kind resolves to.
|
||||
*
|
||||
* `editor.run`'s history option isn't additive — a nested run overwrites the enclosing mode — so
|
||||
* constituent records go through the callback's writer instead of opening a commit of their own,
|
||||
* which would quietly make a `drag` write non-undoable.
|
||||
*
|
||||
* Commits that nest are only a problem when they resolve to different modes, and then neither is
|
||||
* the right one to keep, so it throws. Matching modes have to nest: a store side effect runs inside
|
||||
* the write that triggered it, with no "after the commit" to defer to. (A `store.listen` handler
|
||||
* normally flushes on a later frame, so its writes open a commit of their own, but a synchronous
|
||||
* flush — as under test — lands it inside too.)
|
||||
* @internal
|
||||
*/
|
||||
export function commitCommentMutation<T>(
|
||||
editor: Editor,
|
||||
fn: (writer: CommentMutationWriter) => T,
|
||||
kind: CommentMutationKind = 'mutation'
|
||||
): T {
|
||||
const history = historyModeFor(getCommentingOptions(editor), kind)
|
||||
const enclosing = activeCommentMutations.get(editor)
|
||||
if (enclosing && enclosing.history !== history) {
|
||||
throw new Error(
|
||||
`A comment mutation that records history as '${history}' can't run inside one recording it as '${enclosing.history}': one of the two modes would be silently discarded. Use the provided writer for constituent records, or run this operation after the enclosing one has committed.`
|
||||
)
|
||||
}
|
||||
|
||||
activeCommentMutations.set(editor, { history })
|
||||
try {
|
||||
let result: T
|
||||
editor.run(
|
||||
() => {
|
||||
let isWriterActive = true
|
||||
const assertWriterActive = () => {
|
||||
if (!isWriterActive) {
|
||||
throw new Error(
|
||||
'A comment mutation writer cannot be used after its commit has finished.'
|
||||
)
|
||||
}
|
||||
}
|
||||
try {
|
||||
result = fn({
|
||||
put: (records) => {
|
||||
assertWriterActive()
|
||||
editor.store.put(records as unknown as TLRecord[])
|
||||
},
|
||||
remove: (ids) => {
|
||||
assertWriterActive()
|
||||
editor.store.remove(ids as unknown as TLRecord['id'][])
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
isWriterActive = false
|
||||
}
|
||||
},
|
||||
{ history }
|
||||
)
|
||||
return result!
|
||||
} finally {
|
||||
if (enclosing) {
|
||||
activeCommentMutations.set(editor, enclosing)
|
||||
} else {
|
||||
activeCommentMutations.delete(editor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write comment records to the store, under the configured {@link CommentingOptions.history}
|
||||
* behavior. Defaults to `'ignore'`.
|
||||
*
|
||||
* Use it to seed or import threads, and to save an edit. To delete, prefer {@link deleteComment}
|
||||
* and {@link deleteThread}: comments are soft-deleted, and a synced server rejects the hard delete.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function putCommentRecords(editor: Editor, records: TLCommentRecord[]): void {
|
||||
commitCommentMutation(editor, ({ put }) => put(records))
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove comment records from the store by id, under the configured
|
||||
* {@link CommentingOptions.history} behavior.
|
||||
*
|
||||
* This is a hard delete, which is rarely what you want: the built-in UI soft-deletes
|
||||
* ({@link deleteComment}, {@link deleteThread}) so the server can prune the records, and a server
|
||||
* enforcing per-record permissions vetoes a hard delete outright. Reach for this on a local,
|
||||
* unsynced store, or to drop a reaction (see {@link toggleCommentReaction}).
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function removeCommentRecords(
|
||||
editor: Editor,
|
||||
ids: (TLCommentId | TLCommentReactionId | TLCommentThreadId)[]
|
||||
): void {
|
||||
commitCommentMutation(editor, ({ remove }) => remove(ids))
|
||||
}
|
||||
|
||||
/**
|
||||
* The record as the store currently holds it, or `undefined` if it isn't there any more.
|
||||
*
|
||||
* A verb is handed a record, but that record is a snapshot and comment records move underneath it:
|
||||
* deleting a pinned shape converts the anchor to a point, reparenting rehomes the thread, a drag
|
||||
* re-anchors it. Writing the caller's snapshot back would revert those fields for everyone. `put`
|
||||
* is also an upsert, so a record a remote delete already removed would come back.
|
||||
*
|
||||
* So a verb reads what it's changing rather than trusting what it was given, and a record that's
|
||||
* gone is a no-op.
|
||||
*/
|
||||
function readLatest<T extends TLComment | TLCommentThread>(
|
||||
editor: Editor,
|
||||
record: T
|
||||
): T | undefined {
|
||||
const current = getCommentRecord(editor, record.id)
|
||||
// Record ids carry their type, so a matching `typeName` means a record of exactly T.
|
||||
return current?.typeName === record.typeName ? (current as T) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a comment's body and stamp it as edited, which renders the "(edited)" marker on its
|
||||
* byline. Editing is the author's to do by default ({@link CommentingOptions.canModifyComment}),
|
||||
* and a server enforcing per-record permissions rejects anyone else's. Widening one end without the
|
||||
* other leaves an edit that's offered and then rejected, so widen both.
|
||||
*
|
||||
* The body lands on the version the store currently holds, so a stale copy can't revert a later
|
||||
* change or re-create a removed comment — editing one of those does nothing.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* editComment(editor, comment, toRichText('Actually, make it dashed'))
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function editComment(editor: Editor, comment: TLComment, body: TLRichText): void {
|
||||
commitCommentMutation(editor, ({ put }) => {
|
||||
const current = readLatest(editor, comment)
|
||||
if (!current) return
|
||||
put([{ ...current, body, editedAt: Date.now() }])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a thread resolved, stamping who resolved it and when. Resolved threads keep their pin (a
|
||||
* checked one) and are hidden from the sidebar until its "show resolved" filter is on.
|
||||
*
|
||||
* Only the resolution is written — the rest of the thread is read fresh, so a stale copy can't drag
|
||||
* a pin back. A no-op on a thread that's gone.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function resolveThread(editor: Editor, thread: TLCommentThread, userId: string): void {
|
||||
commitCommentMutation(editor, ({ put }) => {
|
||||
const current = readLatest(editor, thread)
|
||||
if (!current) return
|
||||
put([{ ...current, resolved: { at: Date.now(), by: userId } }])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopen a resolved thread, clearing the resolution. A no-op on a thread that isn't resolved, and
|
||||
* on one that's gone. Like {@link resolveThread}, it touches only the resolution.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function reopenThread(editor: Editor, thread: TLCommentThread): void {
|
||||
commitCommentMutation(editor, ({ put }) => {
|
||||
const current = readLatest(editor, thread)
|
||||
if (!current) return
|
||||
put([{ ...current, resolved: null }])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a comment.
|
||||
*
|
||||
* This is a soft delete: it sets `isDeleted` rather than removing the record, and the server prunes
|
||||
* the comment and its reactions once the flag is persisted — so no client removes records it
|
||||
* doesn't own, and a server enforcing per-record permissions has a write it can check.
|
||||
*
|
||||
* Deleting is the author's to do by default; {@link CommentingOptions.canModifyComment} widens
|
||||
* that, as does its counterpart on the server.
|
||||
*
|
||||
* Never undoable, whatever {@link CommentingOptions.history} says: the flag is write-once
|
||||
* server-side, so an undo clearing it would be vetoed rather than bring the comment back.
|
||||
*
|
||||
* Deleting a thread's last comment closes it and leaves the thread record for the server to prune,
|
||||
* since the deleter may not be its creator. An already-deleted comment is a no-op.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function deleteComment(editor: Editor, comment: TLComment): void {
|
||||
commitCommentMutation(
|
||||
editor,
|
||||
({ put }) => {
|
||||
const current = readLatest(editor, comment)
|
||||
// Deleting twice is nothing to do rather than something to redo. The check below counts this
|
||||
// comment among the live ones, so it only reads as "the last one" while this delete takes it away.
|
||||
if (!current || current.isDeleted) return
|
||||
const isLastInThread =
|
||||
getLiveComments(editor).filter((c) => c.threadId === current.threadId).length <= 1
|
||||
if (isLastInThread && openThreadId.get(editor) === current.threadId) {
|
||||
openThreadId.set(editor, null)
|
||||
}
|
||||
put([{ ...current, isDeleted: true }])
|
||||
},
|
||||
'delete'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a thread and, with it, the whole conversation.
|
||||
*
|
||||
* A soft delete on the same model as {@link deleteComment}: the server prunes the thread, its
|
||||
* comments, and their reactions once the flag is persisted. Deleting a thread is its creator's to
|
||||
* do by default ({@link CommentingOptions.canModifyComment}), and the write is never undoable.
|
||||
* Closes the thread if it's the open one; a pruned thread is a no-op.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function deleteThread(editor: Editor, thread: TLCommentThread): void {
|
||||
commitCommentMutation(
|
||||
editor,
|
||||
({ put }) => {
|
||||
const current = readLatest(editor, thread)
|
||||
if (!current) return
|
||||
if (openThreadId.get(editor) === current.id) {
|
||||
openThreadId.set(editor, null)
|
||||
}
|
||||
put([{ ...current, isDeleted: true }])
|
||||
},
|
||||
'delete'
|
||||
)
|
||||
}
|
||||
154
packages/commenting/src/canvas/comment-reactions.test.ts
Normal file
154
packages/commenting/src/canvas/comment-reactions.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { TLComment, TLCommentId, TLCommentReaction, createCommentReactionId } from 'tldraw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { summarizeReactions } from './comment-reactions'
|
||||
|
||||
const COMMENT_A = 'comment:a' as TLCommentId
|
||||
const COMMENT_B = 'comment:b' as TLCommentId
|
||||
|
||||
function reaction(userId: string, emoji: string, createdAt: number): TLCommentReaction {
|
||||
return {
|
||||
id: createCommentReactionId(COMMENT_A, userId, emoji),
|
||||
typeName: 'comment-reaction',
|
||||
commentId: COMMENT_A,
|
||||
threadId: 'comment-thread:t' as TLCommentReaction['threadId'],
|
||||
pageId: 'page:page' as TLCommentReaction['pageId'],
|
||||
userId,
|
||||
emoji,
|
||||
createdAt,
|
||||
meta: {},
|
||||
}
|
||||
}
|
||||
|
||||
describe('summarizeReactions', () => {
|
||||
it('returns nothing when there are no reactions', () => {
|
||||
expect(summarizeReactions([], 'user1')).toEqual([])
|
||||
})
|
||||
|
||||
it('counts each emoji, marks the current user’s active, and lists reactors', () => {
|
||||
expect(
|
||||
summarizeReactions(
|
||||
[reaction('user1', '👍', 100), reaction('user2', '👍', 200), reaction('user3', '🎉', 300)],
|
||||
'user1'
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
emoji: '👍',
|
||||
count: 2,
|
||||
active: true,
|
||||
// no resolveName here, so names fall back to the generic 'Someone'
|
||||
reactors: [
|
||||
{ name: 'Someone', you: true },
|
||||
{ name: 'Someone', you: false },
|
||||
],
|
||||
},
|
||||
{ emoji: '🎉', count: 1, active: false, reactors: [{ name: 'Someone', you: false }] },
|
||||
])
|
||||
})
|
||||
|
||||
// the row shouldn't reshuffle as later reactions land, so groups sort by first use
|
||||
it('orders emoji by when each was first used', () => {
|
||||
expect(
|
||||
summarizeReactions(
|
||||
[reaction('user1', '🎉', 300), reaction('user2', '👍', 100), reaction('user3', '👍', 200)],
|
||||
undefined
|
||||
).map((r) => r.emoji)
|
||||
).toEqual(['👍', '🎉'])
|
||||
})
|
||||
|
||||
it('marks nothing active when there is no current user', () => {
|
||||
expect(summarizeReactions([reaction('user1', '👍', 100)], undefined)[0].active).toBe(false)
|
||||
})
|
||||
|
||||
it('names reactors via resolveName, falling back to a generic name', () => {
|
||||
const resolveName = (id: string) => (id === 'user1' ? 'Ada' : undefined)
|
||||
expect(
|
||||
summarizeReactions(
|
||||
[reaction('user1', '👍', 100), reaction('user2', '👍', 200)],
|
||||
'user1',
|
||||
resolveName
|
||||
)[0].reactors
|
||||
).toEqual([
|
||||
{ name: 'Ada', you: true },
|
||||
// user2 can't be resolved — a generic name, never the raw id
|
||||
{ name: 'Someone', you: false },
|
||||
])
|
||||
})
|
||||
|
||||
// reactors keep the order they're passed in (the caller sorts by reaction time), so the hover
|
||||
// list is stable
|
||||
it('lists reactors in input order', () => {
|
||||
expect(
|
||||
summarizeReactions(
|
||||
[reaction('user2', '👍', 100), reaction('user1', '👍', 200)],
|
||||
undefined,
|
||||
(id) => id.toUpperCase()
|
||||
)[0].reactors.map((r) => r.name)
|
||||
).toEqual(['USER2', 'USER1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeReactions input', () => {
|
||||
// the notifications panel tallies rows synced from the app database, which carry only the
|
||||
// fields the tally needs — full TLCommentReaction records must not be required
|
||||
it('accepts minimal rows with only userId, emoji, and createdAt', () => {
|
||||
const rows = [
|
||||
{ userId: 'user1', emoji: '👍', createdAt: 100 },
|
||||
{ userId: 'user2', emoji: '👍', createdAt: 200 },
|
||||
]
|
||||
expect(summarizeReactions(rows, 'user1')).toEqual([
|
||||
{
|
||||
emoji: '👍',
|
||||
count: 2,
|
||||
active: true,
|
||||
reactors: [
|
||||
{ name: 'Someone', you: true },
|
||||
{ name: 'Someone', you: false },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('createCommentReactionId', () => {
|
||||
// the id is what makes reaction identity structural: re-picking the same emoji addresses the
|
||||
// same record (toggle off), while a different emoji is its own record (independent)
|
||||
it('is stable for the same comment, user, and emoji', () => {
|
||||
expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).toBe(
|
||||
createCommentReactionId(COMMENT_A, 'user1', '👍')
|
||||
)
|
||||
})
|
||||
|
||||
it('differs per user, per comment, and per emoji', () => {
|
||||
expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).not.toBe(
|
||||
createCommentReactionId(COMMENT_A, 'user2', '👍')
|
||||
)
|
||||
expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).not.toBe(
|
||||
createCommentReactionId(COMMENT_B, 'user1', '👍')
|
||||
)
|
||||
// same user + comment, different emoji → a distinct record (this is what enables multi-react)
|
||||
expect(createCommentReactionId(COMMENT_A, 'user1', '👍')).not.toBe(
|
||||
createCommentReactionId(COMMENT_A, 'user1', '🎉')
|
||||
)
|
||||
})
|
||||
|
||||
it('is a comment-reaction id, not a comment id', () => {
|
||||
const id: string = createCommentReactionId(COMMENT_A, 'user1', '👍')
|
||||
expect(id.startsWith('comment-reaction:')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reaction records', () => {
|
||||
// a comment's reactions are found by commentId, so a reaction to another comment must not
|
||||
// leak into this one's tally
|
||||
it('are scoped to one comment by commentId', () => {
|
||||
const forOtherComment: TLCommentReaction = {
|
||||
...reaction('user9', '💩', 400),
|
||||
commentId: COMMENT_B,
|
||||
}
|
||||
const all = [reaction('user1', '👍', 100), forOtherComment]
|
||||
const mine = all.filter((r) => r.commentId === (COMMENT_A as TLComment['id']))
|
||||
expect(summarizeReactions(mine, 'user1')).toEqual([
|
||||
{ emoji: '👍', count: 1, active: true, reactors: [{ name: 'Someone', you: true }] },
|
||||
])
|
||||
})
|
||||
})
|
||||
246
packages/commenting/src/canvas/comment-reactions.tsx
Normal file
246
packages/commenting/src/canvas/comment-reactions.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Editor,
|
||||
TLComment,
|
||||
TLCommentReaction,
|
||||
createCommentReaction,
|
||||
createCommentReactionId,
|
||||
useEditor,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { RenderReaction } from '../ui/reaction'
|
||||
import { ReactionPicker } from '../ui/reaction-picker'
|
||||
import { Reactions, ReactionSummary } from '../ui/reactions'
|
||||
import { commitCommentMutation } from './comment-mutations'
|
||||
import { UNKNOWN_AUTHOR } from './comment-render'
|
||||
import { getCommentReactions } from './comment-store'
|
||||
import { getCommentingOptions, useCommentingOptions } from './options'
|
||||
|
||||
/**
|
||||
* One comment's reactions, oldest first, reactively.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useCommentReactions(
|
||||
editor: Editor,
|
||||
commentId: TLComment['id']
|
||||
): TLCommentReaction[] {
|
||||
return useValue(
|
||||
'comment reactions',
|
||||
() =>
|
||||
getCommentReactions(editor)
|
||||
.filter((reaction) => reaction.commentId === commentId)
|
||||
.sort((a, b) => a.createdAt - b.createdAt),
|
||||
[editor, commentId]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The reaction fields {@link summarizeReactions} needs — a structural subset of
|
||||
* {@link tldraw#TLCommentReaction}, so tallies can also be built from rows synced outside the
|
||||
* editor store.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ReactionSummaryInput {
|
||||
userId: string
|
||||
emoji: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Tally a comment's reactions into an entry per emoji, ordered by when that emoji was first used so
|
||||
* the row stays stable as later reactions arrive. `active` marks the current user's emoji and
|
||||
* `reactors` lists who reacted, in reaction order. `resolveName` names each reactor; an id it can't
|
||||
* name falls back to a generic "Someone", never the raw user id.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function summarizeReactions(
|
||||
reactions: readonly ReactionSummaryInput[],
|
||||
currentUserId?: string | null,
|
||||
resolveName?: (userId: string) => string | undefined
|
||||
): ReactionSummary[] {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{ count: number; active: boolean; firstAt: number; reactors: ReactionSummary['reactors'] }
|
||||
>()
|
||||
for (const reaction of reactions) {
|
||||
const mine = currentUserId != null && reaction.userId === currentUserId
|
||||
// Fall back to a generic name, never the raw user id, when the id can't be resolved.
|
||||
const reactor = { name: resolveName?.(reaction.userId) ?? UNKNOWN_AUTHOR, you: mine }
|
||||
const group = groups.get(reaction.emoji)
|
||||
if (group) {
|
||||
group.count++
|
||||
group.active ||= mine
|
||||
group.firstAt = Math.min(group.firstAt, reaction.createdAt)
|
||||
group.reactors.push(reactor)
|
||||
} else {
|
||||
groups.set(reaction.emoji, {
|
||||
count: 1,
|
||||
active: mine,
|
||||
firstAt: reaction.createdAt,
|
||||
reactors: [reactor],
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...groups]
|
||||
.sort(([, a], [, b]) => a.firstAt - b.firstAt)
|
||||
.map(([emoji, group]) => ({
|
||||
emoji,
|
||||
count: group.count,
|
||||
active: group.active,
|
||||
reactors: group.reactors,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle one user's reaction with a given emoji on a comment.
|
||||
*
|
||||
* Each reaction is its own record keyed by (comment, user, emoji), so this only touches that user's
|
||||
* own records and two people reacting at once never conflict. Behaviour depends on
|
||||
* `allowMultipleReactions`:
|
||||
*
|
||||
* - **multiple** (default): the emoji toggles independently, leaving other reactions alone.
|
||||
* - **single**: a new emoji replaces the user's existing reaction; the same one removes it.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function toggleCommentReaction(
|
||||
editor: Editor,
|
||||
comment: TLComment,
|
||||
userId: string,
|
||||
emoji: string,
|
||||
now = Date.now()
|
||||
): void {
|
||||
const { allowMultipleReactions, isAllowedReaction } = getCommentingOptions(editor)
|
||||
const targetId = createCommentReactionId(comment.id, userId, emoji)
|
||||
const mine = getCommentReactions(editor).filter(
|
||||
(reaction) => reaction.commentId === comment.id && reaction.userId === userId
|
||||
)
|
||||
const removing = mine.some((reaction) => reaction.id === targetId)
|
||||
// Only a token the configured palette allows may be added. Removals always go through — if a
|
||||
// reaction somehow carries an off-palette token, the user must still be able to clear it.
|
||||
if (!removing && !isAllowedReaction(emoji)) return
|
||||
commitCommentMutation(editor, ({ put, remove }) => {
|
||||
if (removing) {
|
||||
remove([targetId])
|
||||
return
|
||||
}
|
||||
// Single-select: a new emoji replaces the user's existing reaction(s) on this comment.
|
||||
if (!allowMultipleReactions && mine.length > 0) {
|
||||
remove(mine.map((reaction) => reaction.id))
|
||||
}
|
||||
put([
|
||||
createCommentReaction({
|
||||
commentId: comment.id,
|
||||
threadId: comment.threadId,
|
||||
pageId: comment.pageId,
|
||||
userId,
|
||||
emoji,
|
||||
now,
|
||||
}),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface CommentReactionsProps {
|
||||
comment: TLComment
|
||||
/** The reacting user. Null/omitted gives a read-only row (signed out): counts show, but the
|
||||
* pills don't toggle. */
|
||||
currentUserId?: string | null
|
||||
/** Names a reactor id for the hover list. Ids it can't name fall back to a generic "Someone". */
|
||||
resolveName?(userId: string): string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts the `ReactionContent` override into a `renderReaction` function for the presentational
|
||||
* components. Undefined when no override is set, so they fall back to drawing the token string.
|
||||
*/
|
||||
function useReactionRenderer(): RenderReaction | undefined {
|
||||
const { components } = useCommentingOptions()
|
||||
const ReactionContent = components.ReactionContent
|
||||
return useMemo(
|
||||
() => (ReactionContent ? (token: string) => <ReactionContent token={token} /> : undefined),
|
||||
[ReactionContent]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The tallied reaction row under one comment. Pair with `CommentReactionPicker`, which is what
|
||||
* adds a reaction.
|
||||
* @public @react
|
||||
*/
|
||||
export function CommentReactions({ comment, currentUserId, resolveName }: CommentReactionsProps) {
|
||||
const editor = useEditor()
|
||||
const renderReaction = useReactionRenderer()
|
||||
const { components } = useCommentingOptions()
|
||||
const reactions = useCommentReactions(editor, comment.id)
|
||||
const summaries = useMemo(
|
||||
() => summarizeReactions(reactions, currentUserId, resolveName),
|
||||
[reactions, currentUserId, resolveName]
|
||||
)
|
||||
// Suppress the hover list while any menu is open (the reaction picker, an overflow menu…) so it
|
||||
// doesn't compete with the menu the user is actually working in. Edit mode already hides the
|
||||
// pills entirely (the card becomes a composer), so it needs no special case here.
|
||||
const anyMenuOpen = useValue('any menu open', () => editor.menus.getOpenMenus().length > 0, [
|
||||
editor,
|
||||
])
|
||||
return (
|
||||
<Reactions
|
||||
reactions={summaries}
|
||||
canReact={currentUserId != null}
|
||||
enableHoverList={!anyMenuOpen}
|
||||
renderReaction={renderReaction}
|
||||
ReactionTooltip={components.ReactionTooltip}
|
||||
onToggle={(value) => {
|
||||
if (currentUserId == null) return
|
||||
toggleCommentReaction(editor, comment, currentUserId, value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface CommentReactionPickerProps {
|
||||
comment: TLComment
|
||||
/** The reacting user. Null/omitted renders nothing — there's nobody to react as. */
|
||||
currentUserId?: string | null
|
||||
/** The emoji the picker offers. Defaults to `DEFAULT_REACTION_EMOJI`. */
|
||||
emoji?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The add-reaction button for one comment. Belongs with the comment card's hover actions rather
|
||||
* than in the reaction row, so opening it doesn't chase the row as reactions are added.
|
||||
* @public @react
|
||||
*/
|
||||
export function CommentReactionPicker({
|
||||
comment,
|
||||
currentUserId,
|
||||
emoji,
|
||||
}: CommentReactionPickerProps) {
|
||||
const editor = useEditor()
|
||||
const { components } = useCommentingOptions()
|
||||
const renderReaction = useReactionRenderer()
|
||||
const reactions = useCommentReactions(editor, comment.id)
|
||||
const selected = useMemo(
|
||||
() =>
|
||||
reactions
|
||||
.filter((reaction) => currentUserId != null && reaction.userId === currentUserId)
|
||||
.map((reaction) => reaction.emoji),
|
||||
[reactions, currentUserId]
|
||||
)
|
||||
if (currentUserId == null) return null
|
||||
return (
|
||||
<ReactionPicker
|
||||
emoji={emoji}
|
||||
selected={selected}
|
||||
renderReaction={renderReaction}
|
||||
palette={components.ReactionPalette}
|
||||
menuId={`comment-reactions-${comment.id}`}
|
||||
onSelect={(value) => toggleCommentReaction(editor, comment, currentUserId, value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
90
packages/commenting/src/canvas/comment-render.test.ts
Normal file
90
packages/commenting/src/canvas/comment-render.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
|
||||
import { renderCommentHtml, renderCommentPlaintext } from './comment-render'
|
||||
|
||||
const doc = (...content: any[]) => ({ type: 'doc', content }) as any
|
||||
const para = (...content: any[]) => ({ type: 'paragraph', content })
|
||||
const text = (value: string, marks?: any[]) => ({
|
||||
type: 'text',
|
||||
text: value,
|
||||
...(marks ? { marks } : {}),
|
||||
})
|
||||
const mention = (id: string, label?: string) => ({ type: 'mention', attrs: { id, label } })
|
||||
|
||||
describe('renderCommentHtml', () => {
|
||||
it('preserves bold, links, and lists from the limited set', () => {
|
||||
const html = renderCommentHtml(doc(para(text('hi', [{ type: 'bold' }]))))
|
||||
expect(html).toContain('<strong>hi</strong>')
|
||||
|
||||
const list = renderCommentHtml(
|
||||
doc({
|
||||
type: 'bulletList',
|
||||
content: [{ type: 'listItem', content: [para(text('one'))] }],
|
||||
})
|
||||
)
|
||||
expect(list).toContain('<ul')
|
||||
expect(list).toContain('one')
|
||||
})
|
||||
|
||||
it('renders a heading node as a paragraph, never a heading', () => {
|
||||
const html = renderCommentHtml(
|
||||
doc({ type: 'heading', attrs: { level: 1 }, content: [text('Title')] })
|
||||
)
|
||||
expect(html).not.toMatch(/<h[1-6]/)
|
||||
expect(html).toContain('<p')
|
||||
expect(html).toContain('Title')
|
||||
})
|
||||
|
||||
it('renders a mention as a pill and resolves its id to the current name', () => {
|
||||
const body = doc(para(text('hey '), mention('u1', 'Ada')))
|
||||
const html = renderCommentHtml(body, (id) => (id === 'u1' ? 'Ada Lovelace' : '?'))
|
||||
expect(html).toContain('tlui-cmt-mention')
|
||||
// the live name from the resolver, not the label stored at insert time
|
||||
expect(html).toContain('@Ada Lovelace')
|
||||
expect(html).not.toContain('@Ada<')
|
||||
})
|
||||
|
||||
it('falls back to the stored label when no resolver is given', () => {
|
||||
const html = renderCommentHtml(doc(para(mention('u1', 'Ada'))))
|
||||
expect(html).toContain('@Ada')
|
||||
})
|
||||
|
||||
it('escapes a resolved name, so it can never inject markup', () => {
|
||||
const html = renderCommentHtml(doc(para(mention('u1'))), () => '<img src=x onerror=alert(1)>')
|
||||
expect(html).not.toContain('<img')
|
||||
expect(html).toContain('<img')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderCommentPlaintext', () => {
|
||||
it('flattens paragraphs and demotes headings without throwing', () => {
|
||||
const flat = renderCommentPlaintext(
|
||||
doc({ type: 'heading', attrs: { level: 2 }, content: [text('Title')] }, para(text('body')))
|
||||
)
|
||||
expect(flat).toBe('Title\nbody')
|
||||
})
|
||||
|
||||
it('returns an empty string for an empty body', () => {
|
||||
expect(renderCommentPlaintext(EMPTY_COMMENT)).toBe('')
|
||||
})
|
||||
|
||||
it('renders a mention as @name, resolved to the current name', () => {
|
||||
const result = renderCommentPlaintext(
|
||||
doc(para(text('hi '), mention('u1', 'Ada'))),
|
||||
() => 'Ada Lovelace'
|
||||
)
|
||||
expect(result).toBe('hi @Ada Lovelace')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCommentEmpty', () => {
|
||||
it('treats the empty seed and empty encodings as empty', () => {
|
||||
expect(isCommentEmpty(EMPTY_COMMENT)).toBe(true)
|
||||
expect(isCommentEmpty(doc())).toBe(true)
|
||||
expect(isCommentEmpty(doc(para()))).toBe(true)
|
||||
})
|
||||
|
||||
it('treats a body with text as non-empty', () => {
|
||||
expect(isCommentEmpty(doc(para(text('hi'))))).toBe(false)
|
||||
})
|
||||
})
|
||||
98
packages/commenting/src/canvas/comment-render.ts
Normal file
98
packages/commenting/src/canvas/comment-render.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { type Extensions, generateText, JSONContent } from '@tiptap/core'
|
||||
import { type CommentAuthor, createMentionExtension } from '@tldraw/mentions'
|
||||
import { renderHtmlFromRichTextWithExtensions, TLRichText } from 'tldraw'
|
||||
import { commentTipTapExtensions, isCommentEmpty } from '../ui/comment-extensions'
|
||||
|
||||
/**
|
||||
* The author name shown in a byline when no source can name an id (e.g. a deleted account, or a
|
||||
* member with no comment and no live presence). The toolkit's one generic default, applied where a
|
||||
* byline needs a name; hosts pre-empt it by resolving the id from `resolveAuthor`. Not a
|
||||
* translation key — a single English literal.
|
||||
*/
|
||||
export const UNKNOWN_AUTHOR = 'Someone'
|
||||
|
||||
/** The `CommentAuthor` fallback for an unresolvable id — just the name, no color or image.
|
||||
* Frozen: the one shared instance is handed to host render slots. */
|
||||
export const UNKNOWN_COMMENT_AUTHOR: CommentAuthor = Object.freeze({ name: UNKNOWN_AUTHOR })
|
||||
|
||||
/**
|
||||
* The comment extension set has no heading node, so a body that nonetheless contains one (e.g. a
|
||||
* record created programmatically or synced from a client with a fuller set) would make TipTap
|
||||
* throw on an unknown node type. Demote any heading to a paragraph first, so headings can never
|
||||
* render as headings and rendering never crashes.
|
||||
*/
|
||||
function demoteHeadings(node: JSONContent): JSONContent {
|
||||
const content = Array.isArray(node.content) ? node.content.map(demoteHeadings) : node.content
|
||||
if (node.type === 'heading') {
|
||||
const { attrs: _attrs, ...rest } = node
|
||||
return { ...rest, type: 'paragraph', content }
|
||||
}
|
||||
return content === node.content ? node : { ...node, content }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a body contains a mention node. Mention text is resolved from a member id at render time,
|
||||
* so a body with one can't be cached by identity alone (the same body renders differently as names
|
||||
* change) — those bodies skip the cache and re-render each call.
|
||||
*/
|
||||
function hasMention(node: JSONContent): boolean {
|
||||
if (node.type === 'mention') return true
|
||||
return Array.isArray(node.content) ? node.content.some(hasMention) : false
|
||||
}
|
||||
|
||||
const htmlCache = new WeakMap<TLRichText, string>()
|
||||
const textCache = new WeakMap<TLRichText, string>()
|
||||
|
||||
/**
|
||||
* Render a body through the comment extension set, memoized by body identity in `cache` unless the
|
||||
* body carries a mention (see {@link hasMention}), in which case the mention extension resolving
|
||||
* `resolveName` is added and nothing is cached.
|
||||
*/
|
||||
function renderWithCommentExtensions(
|
||||
cache: WeakMap<TLRichText, string>,
|
||||
richText: TLRichText,
|
||||
resolveName: ((id: string) => string | undefined) | undefined,
|
||||
render: (doc: JSONContent, extensions: Extensions) => string
|
||||
): string {
|
||||
const mentions = hasMention(richText as JSONContent)
|
||||
if (!mentions) {
|
||||
const cached = cache.get(richText)
|
||||
if (cached !== undefined) return cached
|
||||
}
|
||||
const extensions = mentions
|
||||
? [...commentTipTapExtensions, createMentionExtension({ resolveName })]
|
||||
: commentTipTapExtensions
|
||||
const result = render(demoteHeadings(richText as JSONContent), extensions)
|
||||
if (!mentions) cache.set(richText, result)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a comment body to HTML through the limited comment extension set (no headings), so a body
|
||||
* always renders with comment formatting regardless of the host editor's rich-text config. Renders
|
||||
* through tldraw's shared helper, so it gets the same empty-paragraph fix that keeps blank lines
|
||||
* from collapsing. `resolveName` maps a member id to its current name for any @mentions.
|
||||
*/
|
||||
export function renderCommentHtml(
|
||||
richText: TLRichText,
|
||||
resolveName?: (id: string) => string | undefined
|
||||
): string {
|
||||
return renderWithCommentExtensions(htmlCache, richText, resolveName, (doc, extensions) =>
|
||||
renderHtmlFromRichTextWithExtensions(doc as TLRichText, extensions)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a comment body to plaintext through the limited comment extension set — paragraphs and
|
||||
* list items separated by newlines. Used for previews (e.g. the sidebar) where formatting is dropped.
|
||||
* `resolveName` maps a member id to its current name for any @mentions.
|
||||
*/
|
||||
export function renderCommentPlaintext(
|
||||
richText: TLRichText,
|
||||
resolveName?: (id: string) => string | undefined
|
||||
): string {
|
||||
if (isCommentEmpty(richText)) return ''
|
||||
return renderWithCommentExtensions(textCache, richText, resolveName, (doc, extensions) =>
|
||||
generateText(doc, extensions, { blockSeparator: '\n' })
|
||||
)
|
||||
}
|
||||
100
packages/commenting/src/canvas/comment-store.ts
Normal file
100
packages/commenting/src/canvas/comment-store.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Editor, TLComment, TLCommentReaction, TLCommentThread, TLRecord } from 'tldraw'
|
||||
|
||||
/**
|
||||
* Typed reads of comment records on the editor store.
|
||||
*
|
||||
* Comment records live on the editor's local store so the canvas can render them reactively, but
|
||||
* they're opt-in and aren't part of the `TLRecord` union — so `editor.store` is statically typed
|
||||
* `Store<TLRecord>` and every access has to reinterpret the type. These helpers own that
|
||||
* reinterpretation behind one boundary and keep call sites typed.
|
||||
*
|
||||
* Writes do the same, but also answer to the undo/redo policy, so they live in
|
||||
* `comment-mutations.ts`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A record that lives in a comment thread: the thread itself, one of its messages, or a reaction
|
||||
* to one of those messages.
|
||||
* @public
|
||||
*/
|
||||
export type TLCommentRecord = TLComment | TLCommentThread | TLCommentReaction
|
||||
|
||||
/** Read one comment record by id, or `undefined` if the id isn't a present comment record. @public */
|
||||
export function getCommentRecord(editor: Editor, id: string): TLCommentRecord | undefined {
|
||||
const record = editor.store.get(id as TLRecord['id']) as unknown as TLCommentRecord | undefined
|
||||
if (!record) return undefined
|
||||
if (
|
||||
record.typeName === 'comment' ||
|
||||
record.typeName === 'comment-thread' ||
|
||||
record.typeName === 'comment-reaction'
|
||||
) {
|
||||
return record
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Read one comment thread by id, or `undefined` if the id isn't a present thread. @internal */
|
||||
export function getCommentThread(editor: Editor, id: string): TLCommentThread | undefined {
|
||||
const record = getCommentRecord(editor, id)
|
||||
return record?.typeName === 'comment-thread' ? record : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Every comment thread in the store, **including soft-deleted and emptied ones** awaiting the
|
||||
* server's prune, which nothing renders. For the set the UI shows, use {@link getLiveCommentThreads}.
|
||||
*
|
||||
* Non-reactive; wrap in `useValue`, or use `useCommentThreads`, to react.
|
||||
* @public
|
||||
*/
|
||||
export function getCommentThreads(editor: Editor): TLCommentThread[] {
|
||||
return queryCommentRecords<TLCommentThread>(editor, 'comment-thread')
|
||||
}
|
||||
|
||||
/**
|
||||
* Every comment in the store, **including soft-deleted ones** awaiting the server's prune. For the
|
||||
* set the UI shows, use {@link getLiveComments}.
|
||||
*
|
||||
* Non-reactive; wrap in `useValue`, or use `useComments`, to react.
|
||||
* @public
|
||||
*/
|
||||
export function getComments(editor: Editor): TLComment[] {
|
||||
return queryCommentRecords<TLComment>(editor, 'comment')
|
||||
}
|
||||
|
||||
/**
|
||||
* The comments that should render: not soft-deleted. A deleted record lingers in the store until
|
||||
* the server prunes it, so build counts and lists from this rather than {@link getComments}.
|
||||
*
|
||||
* Non-reactive; the reactive equivalent is `useComments` (which also sorts oldest first).
|
||||
* @public
|
||||
*/
|
||||
export function getLiveComments(editor: Editor): TLComment[] {
|
||||
return getComments(editor).filter((comment) => !comment.isDeleted)
|
||||
}
|
||||
|
||||
/**
|
||||
* The comment threads that should render (pins, sidebar): not soft-deleted, and still holding at
|
||||
* least one live comment. A thread emptied by its last comment's delete lingers with no surface
|
||||
* until the server's prune lands.
|
||||
*
|
||||
* Non-reactive; the reactive equivalent is `useCommentThreads`.
|
||||
* @public
|
||||
*/
|
||||
export function getLiveCommentThreads(editor: Editor): TLCommentThread[] {
|
||||
const threadIdsWithComments = new Set(getLiveComments(editor).map((comment) => comment.threadId))
|
||||
return getCommentThreads(editor).filter(
|
||||
(thread) => !thread.isDeleted && threadIdsWithComments.has(thread.id)
|
||||
)
|
||||
}
|
||||
|
||||
/** All comment reactions currently in the store (non-reactive; wrap in `useValue` to react). @public */
|
||||
export function getCommentReactions(editor: Editor): TLCommentReaction[] {
|
||||
return queryCommentRecords<TLCommentReaction>(editor, 'comment-reaction')
|
||||
}
|
||||
|
||||
function queryCommentRecords<T extends TLCommentRecord>(
|
||||
editor: Editor,
|
||||
typeName: T['typeName']
|
||||
): T[] {
|
||||
return editor.store.query.records(typeName as TLRecord['typeName']).get() as unknown as T[]
|
||||
}
|
||||
267
packages/commenting/src/canvas/comment-tool.test.ts
Normal file
267
packages/commenting/src/canvas/comment-tool.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { Driver } from '@tldraw/driver'
|
||||
import {
|
||||
commentSchemaRecords,
|
||||
createShapeId,
|
||||
createTLSchema,
|
||||
createTLStore,
|
||||
defaultBindingUtils,
|
||||
defaultShapeUtils,
|
||||
defaultTools,
|
||||
Editor,
|
||||
TLShapeId,
|
||||
TLStateNodeConstructor,
|
||||
} from 'tldraw'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { CommentTool } from './comment-tool'
|
||||
import { commentsSidebarOpen, pendingComment, regionDraft } from './state'
|
||||
|
||||
/**
|
||||
* Pointer-driven tests for the comment tool's placement affordances. These need a real editor
|
||||
* and real pointer dispatch: the behavior under test is how the tool's state transitions
|
||||
* (idle → pointing → dragging) maintain the editor's hinting state.
|
||||
*/
|
||||
|
||||
let editor: Editor
|
||||
|
||||
function makeEditor(tool: TLStateNodeConstructor = CommentTool) {
|
||||
editor = new Editor({
|
||||
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
tools: [...defaultTools, tool],
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
return new Driver(editor)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
editor.dispose()
|
||||
})
|
||||
|
||||
function makeShape(x = 100, y = 100, w = 100, h = 50): TLShapeId {
|
||||
const id = createShapeId()
|
||||
editor.createShape({ id, type: 'geo', x, y, props: { w, h } })
|
||||
return id
|
||||
}
|
||||
|
||||
function makeFrame(x = 300, y = 100, w = 200, h = 200): TLShapeId {
|
||||
const id = createShapeId()
|
||||
editor.createShape({ id, type: 'frame', x, y, props: { w, h } })
|
||||
return id
|
||||
}
|
||||
|
||||
describe('comment tool placement hints', () => {
|
||||
let driver: Driver
|
||||
|
||||
beforeEach(() => {
|
||||
driver = makeEditor()
|
||||
})
|
||||
|
||||
it('hints the shape under the pointer while hovering', () => {
|
||||
const id = makeShape()
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerMove(150, 125)
|
||||
expect(editor.getHintingShapeIds()).toEqual([id])
|
||||
|
||||
driver.pointerMove(400, 400)
|
||||
expect(editor.getHintingShapeIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps hinting the anchor target while the composer follows the pointer', () => {
|
||||
const id = makeShape()
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerMove(400, 400)
|
||||
expect(editor.getHintingShapeIds()).toEqual([])
|
||||
|
||||
// Press away from the shape, then drag the follow composer over it: the shape a release
|
||||
// would anchor to stays hinted, exactly like the idle hover.
|
||||
driver.pointerDown(400, 400)
|
||||
driver.pointerMove(150, 125)
|
||||
expect(editor.isIn('comment.pointing')).toBe(true)
|
||||
expect(editor.getHintingShapeIds()).toEqual([id])
|
||||
|
||||
// ...and dragging back off the shape drops the hint.
|
||||
driver.pointerMove(400, 400)
|
||||
expect(editor.getHintingShapeIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('clears the hint when the tool exits', () => {
|
||||
const id = makeShape()
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerMove(150, 125)
|
||||
expect(editor.getHintingShapeIds()).toEqual([id])
|
||||
|
||||
// Select owns its own hover state, so the comment hint leaves with the tool.
|
||||
editor.setCurrentTool('select')
|
||||
expect(editor.getHintingShapeIds()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('comment tool placement lifecycle', () => {
|
||||
let driver: Driver
|
||||
|
||||
beforeEach(() => {
|
||||
driver = makeEditor()
|
||||
})
|
||||
|
||||
it('stays in the tool with the composer open after placing', () => {
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerDown(400, 300)
|
||||
driver.pointerUp(400, 300)
|
||||
// Placement opens the composer but the interaction isn't over — the tool holds on until
|
||||
// the comment is posted or dismissed, so the surrounding UI stays stable.
|
||||
expect(editor.isIn('comment.idle')).toBe(true)
|
||||
expect(pendingComment.get(editor)).toEqual({
|
||||
anchor: { type: 'point', x: 400, y: 300 },
|
||||
point: { x: 400, y: 300 },
|
||||
})
|
||||
})
|
||||
|
||||
it('re-places the composer on a second click', () => {
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerDown(400, 300)
|
||||
driver.pointerUp(400, 300)
|
||||
driver.pointerDown(200, 200)
|
||||
driver.pointerUp(200, 200)
|
||||
expect(editor.isIn('comment.idle')).toBe(true)
|
||||
expect(pendingComment.get(editor)).toEqual({
|
||||
anchor: { type: 'point', x: 200, y: 200 },
|
||||
point: { x: 200, y: 200 },
|
||||
})
|
||||
})
|
||||
|
||||
it('drops the pending composer when the tool exits', () => {
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerDown(400, 300)
|
||||
driver.pointerUp(400, 300)
|
||||
expect(pendingComment.get(editor)).not.toBeNull()
|
||||
|
||||
// A direct tool switch (toolbar, shortcut) abandons the draft composer — it belongs to
|
||||
// the tool. The text itself survives in the comment draft store.
|
||||
editor.setCurrentTool('select')
|
||||
expect(pendingComment.get(editor)).toBeNull()
|
||||
})
|
||||
|
||||
it('closes the comments sidebar for the whole interaction', () => {
|
||||
commentsSidebarOpen.set(editor, true)
|
||||
editor.setCurrentTool('comment')
|
||||
expect(commentsSidebarOpen.get(editor)).toBe(false)
|
||||
|
||||
// ...and placing doesn't reopen it: the tool (and the closed sidebar) hold through the
|
||||
// composer. Reopening is the host's button, never a side effect of leaving the tool.
|
||||
driver.pointerDown(400, 300)
|
||||
driver.pointerUp(400, 300)
|
||||
expect(editor.isIn('comment.idle')).toBe(true)
|
||||
expect(commentsSidebarOpen.get(editor)).toBe(false)
|
||||
})
|
||||
|
||||
it('escape leaves the tool for select and drops the draft', () => {
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerDown(400, 300)
|
||||
driver.pointerUp(400, 300)
|
||||
editor.cancel()
|
||||
expect(editor.isIn('select')).toBe(true)
|
||||
expect(pendingComment.get(editor)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('comment tool placement hints with regions enabled', () => {
|
||||
it('clears the hint when a drag becomes a region', () => {
|
||||
const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
|
||||
const id = makeShape()
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerMove(150, 125)
|
||||
expect(editor.getHintingShapeIds()).toEqual([id])
|
||||
|
||||
// Past the drag threshold this is a region draw — a single-shape outline under the dashed
|
||||
// box would be stale, so the hint clears even with the pointer still over the shape.
|
||||
driver.pointerDown(150, 125)
|
||||
driver.pointerMove(180, 140)
|
||||
expect(editor.isIn('comment.dragging')).toBe(true)
|
||||
expect(editor.getHintingShapeIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('stays in the tool with the composer open after a region drag', () => {
|
||||
const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerDown(100, 100)
|
||||
driver.pointerMove(200, 180)
|
||||
driver.pointerUp(200, 180)
|
||||
expect(editor.isIn('comment.idle')).toBe(true)
|
||||
expect(pendingComment.get(editor)).toEqual({
|
||||
anchor: { type: 'region', x: 100, y: 100, w: 100, h: 80, pinX: 1, pinY: 1 },
|
||||
point: { x: 200, y: 180 },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not hint a shape under the pin corner of a placed region', () => {
|
||||
const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
|
||||
makeShape(100, 100)
|
||||
editor.setCurrentTool('comment')
|
||||
// Release on top of the shape: a region anchors to its rectangle, so the outline that
|
||||
// normally previews a shape anchor would be a lie here.
|
||||
driver.pointerDown(50, 50)
|
||||
driver.pointerMove(150, 125)
|
||||
driver.pointerUp(150, 125)
|
||||
expect(editor.isIn('comment.idle')).toBe(true)
|
||||
expect(editor.getHintingShapeIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('drops the region draft when the tool exits mid-drag', () => {
|
||||
const driver = makeEditor(CommentTool.configure({ enableRegions: true }))
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerDown(100, 100)
|
||||
driver.pointerMove(200, 180)
|
||||
expect(regionDraft.get(editor)).not.toBeNull()
|
||||
|
||||
// A direct tool switch (shortcut, toolbar) mid-drag must not strand the drawn rectangle.
|
||||
editor.setCurrentTool('select')
|
||||
expect(regionDraft.get(editor)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('comment tool anchoring to frames', () => {
|
||||
let driver: Driver
|
||||
|
||||
beforeEach(() => {
|
||||
driver = makeEditor()
|
||||
})
|
||||
|
||||
it('hints a frame when hovering its empty interior', () => {
|
||||
// A frame is hit only on its edge/label by default; the comment tool passes hitFrameInside
|
||||
// so a click in its body anchors the frame. (300,100)–(500,300): (400,200) is well inside,
|
||||
// clear of the edges and the (above-frame) label.
|
||||
const frame = makeFrame(300, 100, 200, 200)
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerMove(400, 200)
|
||||
expect(editor.getHintingShapeIds()).toEqual([frame])
|
||||
})
|
||||
|
||||
it('a shape over the frame still wins the hit', () => {
|
||||
const frame = makeFrame(300, 100, 200, 200)
|
||||
const child = makeShape(340, 140, 60, 40) // sits inside the frame, drawn above it
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerMove(370, 160)
|
||||
expect(editor.getHintingShapeIds()).toEqual([child])
|
||||
// ...and the frame's empty interior beside it still anchors the frame.
|
||||
driver.pointerMove(470, 260)
|
||||
expect(editor.getHintingShapeIds()).toEqual([frame])
|
||||
})
|
||||
|
||||
it('keeps hinting a frame while the composer follows the pointer into it', () => {
|
||||
// The click-drag path, not just idle hover: press away from the frame, then drag the follow
|
||||
// composer into its body. The pointing state hints the anchor target too, so the frame stays
|
||||
// hinted while dragging over it (regions are off by default, so this doesn't become a region).
|
||||
const frame = makeFrame(300, 100, 200, 200)
|
||||
editor.setCurrentTool('comment')
|
||||
driver.pointerMove(50, 50)
|
||||
driver.pointerDown(50, 50)
|
||||
driver.pointerMove(400, 200)
|
||||
expect(editor.isIn('comment.pointing')).toBe(true)
|
||||
expect(editor.getHintingShapeIds()).toEqual([frame])
|
||||
|
||||
// ...and dragging back off the frame clears it.
|
||||
driver.pointerMove(50, 400)
|
||||
expect(editor.getHintingShapeIds()).toEqual([])
|
||||
})
|
||||
})
|
||||
294
packages/commenting/src/canvas/comment-tool.tsx
Normal file
294
packages/commenting/src/canvas/comment-tool.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
BoxModel,
|
||||
Editor,
|
||||
StateNode,
|
||||
TLCommentAnchor,
|
||||
TLStateNodeConstructor,
|
||||
TLUiOverrides,
|
||||
VecLike,
|
||||
} from 'tldraw'
|
||||
import { type CommentingOptions, defaultCommentingOptions, getCommentingOptions } from './options'
|
||||
import { commentsSidebarOpen, pendingComment, regionDraft } from './state'
|
||||
import { commentTargetShapeAt, regionPinPoint, shapeAnchorAt } from './thread-state'
|
||||
|
||||
/** A comment being placed but not yet posted: where its composer sits and what it will anchor
|
||||
* to. Shared between the tool (which sets it on click) and the overlay (which renders the
|
||||
* composer) through the internal `pendingComment` atom.
|
||||
* @internal */
|
||||
export interface PendingComment {
|
||||
anchor: TLCommentAnchor
|
||||
/** Page point where the composer opens (the click location, or a region's pin corner). */
|
||||
point: VecLike
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge configure options over a base. Shallow for scalars; `components` is merged rather than
|
||||
* replaced so chained `configure` calls layer their slots — a later `{ components: { PinContent } }`
|
||||
* doesn't drop an earlier `{ components: { CommentBody } }`.
|
||||
*/
|
||||
function mergeCommentingOptions(
|
||||
base: CommentingOptions,
|
||||
overrides: Partial<CommentingOptions>
|
||||
): CommentingOptions {
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
components: { ...base.components, ...overrides.components },
|
||||
}
|
||||
}
|
||||
|
||||
/** The page-space rectangle spanned by two points, normalized so w/h are non-negative. Shared by
|
||||
* region creation (pointer-down → cursor) and corner resize (fixed corner → cursor). */
|
||||
export function regionBetween(a: VecLike, b: VecLike): BoxModel {
|
||||
return {
|
||||
x: Math.min(a.x, b.x),
|
||||
y: Math.min(a.y, b.y),
|
||||
w: Math.abs(a.x - b.x),
|
||||
h: Math.abs(a.y - b.y),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The comment tool. Pressing down opens the comment composer at the pointer and it follows until
|
||||
* release — like placing a sticky note — settling on a point, or on a shape when released over one.
|
||||
* With region comments enabled, dragging past the threshold draws a region rectangle instead.
|
||||
* Placement only opens a composer; the records are created when the comment is posted. The tool
|
||||
* stays active while the composer is open — posting returns to select, and clicking elsewhere
|
||||
* re-places the composer.
|
||||
* @public
|
||||
*/
|
||||
export class CommentTool extends StateNode {
|
||||
static override id = 'comment'
|
||||
static override initial = 'idle'
|
||||
static override children(): TLStateNodeConstructor[] {
|
||||
return [CommentIdle, CommentPointing, CommentDragging]
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure this tool's {@link CommentTool.options | `options`}, returning a configured subclass
|
||||
* to register via `tools`. Mirrors `ShapeUtil.configure`. Layers over any prior `configure`, so
|
||||
* calls can be chained.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Tldraw tools={[CommentTool.configure({ history: 'ignore', enableClustering: false })]} />
|
||||
* ```
|
||||
*/
|
||||
static configure<T extends TLStateNodeConstructor>(
|
||||
this: T,
|
||||
options: T extends new (...args: any[]) => { options: infer Options } ? Partial<Options> : never
|
||||
): T {
|
||||
// @ts-expect-error -- mirrors ShapeUtil.configure; extending `this` is sound at runtime.
|
||||
return class extends this {
|
||||
// @ts-expect-error
|
||||
options = mergeCommentingOptions(this.options, options)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The merged commenting options for this editor. Read from anywhere via
|
||||
* {@link getCommentingOptions}. Override with {@link CommentTool.configure}.
|
||||
*/
|
||||
options: CommentingOptions = defaultCommentingOptions
|
||||
|
||||
override onEnter() {
|
||||
this.editor.setCursor({ type: 'comment', rotation: 0 })
|
||||
// Placing comments is canvas-focused — the thread list gets out of the way while the tool is
|
||||
// active. Reopened via its own control (a button), never by leaving the tool.
|
||||
commentsSidebarOpen.set(this.editor, false)
|
||||
}
|
||||
|
||||
override onExit() {
|
||||
// Drop the hover hint painted while pointing at shapes (see CommentIdle). The cursor resets
|
||||
// when the next tool takes over. The draft composer and region draft belong to the tool, so
|
||||
// they leave with it; the draft's text survives in the comment draft store.
|
||||
this.editor.setHintingShapes([])
|
||||
pendingComment.set(this.editor, null)
|
||||
regionDraft.set(this.editor, null)
|
||||
}
|
||||
|
||||
// Escape leaves the tool, like the built-in tools (the editor dispatches `cancel` on Escape).
|
||||
override onCancel() {
|
||||
this.editor.setCurrentTool('select')
|
||||
}
|
||||
}
|
||||
|
||||
/** Hint the shape a comment placed at the pointer would anchor to, using the same hit-test as the
|
||||
* anchor resolution on release. Hinting shapes render an indicator ungated by the active tool, so
|
||||
* this shows the select-style outline while the comment tool — not select — is active. */
|
||||
function updateAnchorHint(editor: Editor) {
|
||||
const hit = commentTargetShapeAt(editor, editor.inputs.getCurrentPagePoint())
|
||||
editor.setHintingShapes(hit ? [hit.id] : [])
|
||||
}
|
||||
|
||||
class CommentIdle extends StateNode {
|
||||
static override id = 'idle'
|
||||
|
||||
override onEnter() {
|
||||
// Back to hovering: restore the pin cursor (a prior placing state may have hidden it).
|
||||
this.editor.setCursor({ type: 'comment', rotation: 0 })
|
||||
// With a region composer open, a shape outline under its pin corner would imply a shape
|
||||
// anchor the region will never use — and it would stick, since moves over the composer
|
||||
// never reach the canvas. Point placements keep the hint: it previews the next click.
|
||||
if (pendingComment.get(this.editor)?.anchor.type === 'region') {
|
||||
this.editor.setHintingShapes([])
|
||||
} else {
|
||||
updateAnchorHint(this.editor)
|
||||
}
|
||||
}
|
||||
|
||||
override onPointerMove() {
|
||||
updateAnchorHint(this.editor)
|
||||
}
|
||||
|
||||
override onPointerDown() {
|
||||
this.parent.transition('pointing')
|
||||
}
|
||||
}
|
||||
|
||||
class CommentPointing extends StateNode {
|
||||
static override id = 'pointing'
|
||||
|
||||
override onEnter() {
|
||||
// Open the composer immediately at the press point so it's visible from pointer-down (not just
|
||||
// on release), and let it trail the pointer while dragging — like placing a sticky note. The
|
||||
// anchor is a bare point for now; it's resolved (shape or point) on pointer up.
|
||||
const { editor } = this
|
||||
// Hide the cursor while placing: the draft composer is the pointer's stand-in now, so the pin
|
||||
// cursor sitting over it just reads as clutter.
|
||||
editor.setCursor({ type: 'none', rotation: 0 })
|
||||
const point = editor.inputs.getCurrentPagePoint()
|
||||
pendingComment.set(editor, {
|
||||
anchor: { type: 'point', x: point.x, y: point.y },
|
||||
point: { x: point.x, y: point.y },
|
||||
})
|
||||
}
|
||||
|
||||
override onPointerMove() {
|
||||
const { editor } = this
|
||||
// Once the pointer passes the drag threshold with region comments enabled, this is a region,
|
||||
// not a follow — hand off to the region drag (which clears this composer and draws the box).
|
||||
if (getCommentingOptions(editor).enableRegions && editor.inputs.getIsDragging()) {
|
||||
this.parent.transition('dragging')
|
||||
return
|
||||
}
|
||||
// Otherwise the composer trails the pointer — keep hinting the shape a release here would
|
||||
// anchor to, like the idle hover does.
|
||||
updateAnchorHint(editor)
|
||||
const point = editor.inputs.getCurrentPagePoint()
|
||||
pendingComment.update(editor, (p) => (p ? { ...p, point: { x: point.x, y: point.y } } : p))
|
||||
}
|
||||
|
||||
// Settle where the pointer is released: anchor to the shape under it, or drop a point.
|
||||
override onPointerUp() {
|
||||
const { editor } = this
|
||||
const point = editor.inputs.getCurrentPagePoint()
|
||||
const hit = commentTargetShapeAt(editor, point)
|
||||
const anchor: TLCommentAnchor = hit
|
||||
? shapeAnchorAt(
|
||||
editor,
|
||||
hit.id,
|
||||
point,
|
||||
getCommentingOptions(editor).shouldBePrecise(editor, {
|
||||
shapeId: hit.id,
|
||||
point,
|
||||
altKey: editor.inputs.getAltKey(),
|
||||
})
|
||||
)
|
||||
: { type: 'point', x: point.x, y: point.y }
|
||||
pendingComment.set(editor, { anchor, point: { x: point.x, y: point.y } })
|
||||
// Stay in the tool while the composer is open — the interaction isn't over until the
|
||||
// comment is posted or dismissed, and staying keeps the surrounding UI (style panel,
|
||||
// sidebar) from churning mid-placement.
|
||||
this.parent.transition('idle')
|
||||
}
|
||||
|
||||
override onCancel() {
|
||||
this.cancel()
|
||||
}
|
||||
|
||||
override onInterrupt() {
|
||||
this.cancel()
|
||||
}
|
||||
|
||||
// Abandon the follow composer if placement is interrupted (Escape, focus loss, etc.). The
|
||||
// tool's onExit drops the draft.
|
||||
private cancel() {
|
||||
this.editor.setCurrentTool('select')
|
||||
}
|
||||
}
|
||||
|
||||
class CommentDragging extends StateNode {
|
||||
static override id = 'dragging'
|
||||
|
||||
override onEnter() {
|
||||
// A region drag supersedes the point-follow composer opened in `pointing`. Show a crosshair again,
|
||||
// since the composer no longer stands in for the pointer, and drop the placement hint — a region
|
||||
// anchors to its rectangle, so a single-shape outline would be stale.
|
||||
pendingComment.set(this.editor, null)
|
||||
this.editor.setHintingShapes([])
|
||||
this.editor.setCursor({ type: 'cross', rotation: 0 })
|
||||
this.updateDraft()
|
||||
}
|
||||
|
||||
override onPointerMove() {
|
||||
this.updateDraft()
|
||||
}
|
||||
|
||||
// Commit the dragged rectangle as a region anchor; the composer opens at its pin corner.
|
||||
override onPointerUp() {
|
||||
const { editor } = this
|
||||
const origin = editor.inputs.getOriginPagePoint()
|
||||
const current = editor.inputs.getCurrentPagePoint()
|
||||
const region = regionBetween(origin, current)
|
||||
// The pin lives on the corner the drag released on — drag up-left, pin top-left.
|
||||
const pin = { x: current.x >= origin.x ? 1 : 0, y: current.y >= origin.y ? 1 : 0 }
|
||||
regionDraft.set(editor, null)
|
||||
pendingComment.set(editor, {
|
||||
anchor: { type: 'region', ...region, pinX: pin.x, pinY: pin.y },
|
||||
point: regionPinPoint(region, pin),
|
||||
})
|
||||
// Same as the point placement: the tool stays active while the composer is open.
|
||||
this.parent.transition('idle')
|
||||
}
|
||||
|
||||
override onCancel() {
|
||||
this.cancel()
|
||||
}
|
||||
|
||||
override onInterrupt() {
|
||||
this.cancel()
|
||||
}
|
||||
|
||||
private updateDraft() {
|
||||
const { editor } = this
|
||||
regionDraft.set(
|
||||
editor,
|
||||
regionBetween(editor.inputs.getOriginPagePoint(), editor.inputs.getCurrentPagePoint())
|
||||
)
|
||||
}
|
||||
|
||||
// The tool's onExit drops the region draft.
|
||||
private cancel() {
|
||||
this.editor.setCurrentTool('select')
|
||||
}
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export const commentTools = [CommentTool]
|
||||
|
||||
/** Registers the comment tool in the UI (icon, label, shortcut). Compose into your overrides.
|
||||
* Once registered, tldraw's `DefaultQuickActionsContent` shows the comment button.
|
||||
* @public */
|
||||
export const commentToolOverrides: TLUiOverrides = {
|
||||
tools(editor, tools) {
|
||||
tools.comment = {
|
||||
id: 'comment',
|
||||
icon: 'comment',
|
||||
label: 'Comment',
|
||||
kbd: 'c',
|
||||
onSelect: () => editor.setCurrentTool('comment'),
|
||||
}
|
||||
return tools
|
||||
},
|
||||
}
|
||||
88
packages/commenting/src/canvas/comments-filter-menu.tsx
Normal file
88
packages/commenting/src/canvas/comments-filter-menu.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
TldrawUiButton,
|
||||
TldrawUiDropdownMenuContent,
|
||||
TldrawUiDropdownMenuRoot,
|
||||
TldrawUiDropdownMenuTrigger,
|
||||
TldrawUiMenuCheckboxItem,
|
||||
TldrawUiMenuContextProvider,
|
||||
TldrawUiMenuGroup,
|
||||
useEditor,
|
||||
useTranslation,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { MoreMenuIcon } from '../ui/icons'
|
||||
import { SidebarFilters } from './sidebar-filters'
|
||||
import { sidebarFilters } from './state'
|
||||
|
||||
/** @public */
|
||||
export interface CommentsFilterMenuProps {
|
||||
/** Whether to offer the "only your threads" toggle (needs a known current user). */
|
||||
canFilterByAuthor?: boolean
|
||||
/** Whether to offer the "only unread" toggle (needs a read-status source). */
|
||||
canFilterByUnread?: boolean
|
||||
}
|
||||
|
||||
/** The funnel dropdown in the sidebar header: toggles for which threads the list shows.
|
||||
* @public @react */
|
||||
export function CommentsFilterMenu({
|
||||
canFilterByAuthor,
|
||||
canFilterByUnread,
|
||||
}: CommentsFilterMenuProps) {
|
||||
const editor = useEditor()
|
||||
const msg = useTranslation()
|
||||
const filters = useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
|
||||
const toggle = (key: keyof SidebarFilters) => {
|
||||
sidebarFilters.update(editor, (f) => ({ ...f, [key]: !f[key] }))
|
||||
}
|
||||
|
||||
return (
|
||||
<TldrawUiDropdownMenuRoot id="comments-filter">
|
||||
<TldrawUiDropdownMenuTrigger>
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
tooltip={msg('comments.filter')}
|
||||
title={msg('comments.filter')}
|
||||
className="tlui-cmt-header-btn"
|
||||
>
|
||||
<MoreMenuIcon />
|
||||
</TldrawUiButton>
|
||||
</TldrawUiDropdownMenuTrigger>
|
||||
{/* No tlui-cmt-menu here: the rows are tldraw menu items, which carry their own insets —
|
||||
the extra padding would set this menu apart from the canvas menus it should match. */}
|
||||
<TldrawUiDropdownMenuContent side="bottom" align="end" alignOffset={0}>
|
||||
<TldrawUiMenuContextProvider type="menu" sourceId="menu">
|
||||
<TldrawUiMenuGroup id="comments-filter">
|
||||
<TldrawUiMenuCheckboxItem
|
||||
id="show-all-pages"
|
||||
label="comments.show-all-pages"
|
||||
checked={!filters.onlyCurrentPage}
|
||||
onSelect={() => toggle('onlyCurrentPage')}
|
||||
/>
|
||||
{canFilterByAuthor && (
|
||||
<TldrawUiMenuCheckboxItem
|
||||
id="only-my-comments"
|
||||
label="comments.only-my-comments"
|
||||
checked={filters.onlyMine}
|
||||
onSelect={() => toggle('onlyMine')}
|
||||
/>
|
||||
)}
|
||||
{canFilterByUnread && (
|
||||
<TldrawUiMenuCheckboxItem
|
||||
id="only-unread"
|
||||
label="comments.only-unread"
|
||||
checked={filters.onlyUnread}
|
||||
onSelect={() => toggle('onlyUnread')}
|
||||
/>
|
||||
)}
|
||||
<TldrawUiMenuCheckboxItem
|
||||
id="show-resolved"
|
||||
label="comments.show-resolved"
|
||||
checked={filters.showResolved}
|
||||
onSelect={() => toggle('showResolved')}
|
||||
/>
|
||||
</TldrawUiMenuGroup>
|
||||
</TldrawUiMenuContextProvider>
|
||||
</TldrawUiDropdownMenuContent>
|
||||
</TldrawUiDropdownMenuRoot>
|
||||
)
|
||||
}
|
||||
24
packages/commenting/src/canvas/comments-menu-item.tsx
Normal file
24
packages/commenting/src/canvas/comments-menu-item.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { TldrawUiMenuCheckboxItem, useEditor, useValue } from 'tldraw'
|
||||
import { commentsHidden, toggleCommentsHidden } from './state'
|
||||
|
||||
/**
|
||||
* A checkbox menu item for a "View" menu that shows/hides comment pins on the canvas — checked
|
||||
* when comments are visible, like tldraw's own grid/snap toggles. Reads and toggles the shared
|
||||
* {@link commentsHidden} signal, so it stays in sync with the Shift+C shortcut and the sidebar
|
||||
* control. Drop it into whichever menu your app owns.
|
||||
* @public @react
|
||||
*/
|
||||
export function CommentsMenuItem() {
|
||||
const editor = useEditor()
|
||||
const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
||||
return (
|
||||
<TldrawUiMenuCheckboxItem
|
||||
id="comments-visible"
|
||||
label="comments.title"
|
||||
kbd="!c"
|
||||
readonlyOk
|
||||
checked={!hidden}
|
||||
onSelect={() => toggleCommentsHidden(editor)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
60
packages/commenting/src/canvas/comments-overflow-menu.tsx
Normal file
60
packages/commenting/src/canvas/comments-overflow-menu.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
TldrawUiButton,
|
||||
TldrawUiDropdownMenuContent,
|
||||
TldrawUiDropdownMenuGroup,
|
||||
TldrawUiDropdownMenuItem,
|
||||
TldrawUiDropdownMenuRoot,
|
||||
TldrawUiDropdownMenuTrigger,
|
||||
TldrawUiIcon,
|
||||
useEditor,
|
||||
useTranslation,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { commentsHidden, toggleCommentsHidden } from './state'
|
||||
|
||||
// A keyboard-shortcut glyph, not translatable copy — kept out of JSX as a constant.
|
||||
const HIDE_SHORTCUT = '⇧C'
|
||||
|
||||
/** The overflow (⋯) dropdown in the sidebar header. For now it holds the hide/show-comments
|
||||
* toggle; it's the home for later comment-wide controls (notifications, mark all as read).
|
||||
* @public @react */
|
||||
export function CommentsOverflowMenu() {
|
||||
const editor = useEditor()
|
||||
const msg = useTranslation()
|
||||
const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
||||
|
||||
return (
|
||||
<TldrawUiDropdownMenuRoot id="comments-overflow">
|
||||
<TldrawUiDropdownMenuTrigger>
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
tooltip={msg('comments.more-options')}
|
||||
title={msg('comments.more-options')}
|
||||
className="tlui-cmt-header-btn"
|
||||
>
|
||||
<TldrawUiIcon icon="dots-vertical" label={msg('comments.more-options')} small />
|
||||
</TldrawUiButton>
|
||||
</TldrawUiDropdownMenuTrigger>
|
||||
<TldrawUiDropdownMenuContent
|
||||
className="tlui-cmt-menu"
|
||||
side="bottom"
|
||||
align="end"
|
||||
alignOffset={0}
|
||||
sideOffset={4}
|
||||
>
|
||||
<TldrawUiDropdownMenuGroup>
|
||||
<TldrawUiDropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="tlui-cmt-menu-item"
|
||||
onClick={() => toggleCommentsHidden(editor)}
|
||||
>
|
||||
<span>{hidden ? msg('comments.show') : msg('comments.hide')}</span>
|
||||
<span className="tlui-cmt-menu-item__shortcut">{HIDE_SHORTCUT}</span>
|
||||
</button>
|
||||
</TldrawUiDropdownMenuItem>
|
||||
</TldrawUiDropdownMenuGroup>
|
||||
</TldrawUiDropdownMenuContent>
|
||||
</TldrawUiDropdownMenuRoot>
|
||||
)
|
||||
}
|
||||
43
packages/commenting/src/canvas/comments-overlay.test.ts
Normal file
43
packages/commenting/src/canvas/comments-overlay.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ClusterNode } from '../clustering/types'
|
||||
import { fadeNodeMarkerThreadId } from './comments-overlay'
|
||||
|
||||
function node(ids: string[], x = 0, y = 0): ClusterNode {
|
||||
const members = ids.slice().sort()
|
||||
return {
|
||||
id: members.length === 1 ? members[0] : `cluster:${members.length}:${members[0]}`,
|
||||
centroid: { x, y },
|
||||
count: members.length,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
describe('fadeNodeMarkerThreadId', () => {
|
||||
it('resolves a count-1 node to its own thread', () => {
|
||||
expect(fadeNodeMarkerThreadId(node(['t1']), null, null)).toBe('t1')
|
||||
expect(fadeNodeMarkerThreadId(node(['t1']), null, 't2')).toBe('t1')
|
||||
})
|
||||
|
||||
it('returns null for the open thread — the open-thread render slot draws it', () => {
|
||||
// Drawing it here too would mount its popover twice while the node fades out.
|
||||
expect(fadeNodeMarkerThreadId(node(['t1']), null, 't1')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves a coincident stack to its owner', () => {
|
||||
expect(fadeNodeMarkerThreadId(node(['t1', 't2']), 't1', null)).toBe('t1')
|
||||
expect(fadeNodeMarkerThreadId(node(['t1', 't2']), 't1', 't2')).toBe('t1')
|
||||
})
|
||||
|
||||
it('returns null for a stack whose owner is the open thread', () => {
|
||||
// Otherwise this node and the open slot would both draw the stack.
|
||||
expect(fadeNodeMarkerThreadId(node(['t1', 't2']), 't1', 't1')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a stack whose owner is no longer a member', () => {
|
||||
expect(fadeNodeMarkerThreadId(node(['t2', 't3']), 't1', null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a stack with no owner', () => {
|
||||
expect(fadeNodeMarkerThreadId(node(['t1', 't2']), null, null)).toBeNull()
|
||||
})
|
||||
})
|
||||
363
packages/commenting/src/canvas/comments-overlay.tsx
Normal file
363
packages/commenting/src/canvas/comments-overlay.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { isMentionPickerOpen } from '@tldraw/mentions'
|
||||
import { Fragment, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { EditorPortal, TLCommentThread, useEditor, useValue } from 'tldraw'
|
||||
import type { ClusterNode } from '../clustering/types'
|
||||
import { registerCommentAnchorLifecycle } from './anchor-lifecycle'
|
||||
import { ClusterBadge } from './cluster-badge'
|
||||
import { clusterFadeClassName } from './cluster-fade'
|
||||
import {
|
||||
CLUSTER_EXPAND_ZOOM_MS,
|
||||
revealThreadPin,
|
||||
useClusterModel,
|
||||
zoomToClusterSplit,
|
||||
} from './cluster-model'
|
||||
import { getCommentRecord, getCommentThread } from './comment-store'
|
||||
import { type CommentingContext } from './context'
|
||||
import { useCommentThreads } from './hooks'
|
||||
import { useCommentingEnabled } from './license'
|
||||
import { useCanComment, useCommentingOptions } from './options'
|
||||
import { PendingComposer } from './pending-composer'
|
||||
import { computePinStacks, isOpenStackKeyLive, pinStacksEqual } from './pin-stacking'
|
||||
import { RegionBox, RegionDraftBox } from './region-box'
|
||||
import {
|
||||
commentsHidden,
|
||||
openStackId,
|
||||
openThreadId,
|
||||
pendingComment,
|
||||
revealThreadRequest,
|
||||
sidebarFilters,
|
||||
toggleCommentsHidden,
|
||||
usePendingComment,
|
||||
} from './state'
|
||||
import { ThreadPin } from './thread-pin'
|
||||
import { ThreadStackPin } from './thread-stack'
|
||||
|
||||
/**
|
||||
* The host wiring for {@link CanvasComments} — see {@link CommentingContext}, which the sidebar
|
||||
* takes the same fields from.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type CanvasCommentsProps = CommentingContext
|
||||
|
||||
/**
|
||||
* Which thread's pin a fade node should draw: its own thread for a single pin, `stackOwner` (when
|
||||
* still a member) for a coincident stack, null for neither. Never the open thread — the open-thread
|
||||
* render slot draws that one, and drawing it here too (which the fade-out window would otherwise
|
||||
* do) mounts its popover twice.
|
||||
* @internal
|
||||
*/
|
||||
export function fadeNodeMarkerThreadId(
|
||||
node: ClusterNode,
|
||||
stackOwner: string | null,
|
||||
openId: string | null
|
||||
): string | null {
|
||||
const candidate =
|
||||
node.count === 1 ? node.id : stackOwner && node.members.includes(stackOwner) ? stackOwner : null
|
||||
return candidate === openId ? null : candidate
|
||||
}
|
||||
|
||||
/**
|
||||
* A ready-to-use comments layer for a tldraw canvas: pins each thread at its anchor, opens a
|
||||
* thread popover (with a reply composer) on click, and shows a composer where the comment tool
|
||||
* placed a new thread. Reads/writes comment records straight from `editor.store`.
|
||||
*
|
||||
* It's the batteries-included default: every visible piece is a slot on
|
||||
* `CommentTool.configure({ components })`, and the pieces it composes (`CommentPin`,
|
||||
* `CommentThread`, `CommentComposer`, the hooks, the tool) are all exported, so a consumer can
|
||||
* rebuild it from parts instead. The host wiring is the {@link CommentingContext}, which
|
||||
* `CanvasCommentsSidebar` takes too.
|
||||
*
|
||||
* @public @react
|
||||
*/
|
||||
export function CanvasComments(props: CanvasCommentsProps) {
|
||||
// The inner component holds every other hook, so mounting it as the license resolves keeps hook
|
||||
// order stable here.
|
||||
const commentingEnabled = useCommentingEnabled()
|
||||
if (!commentingEnabled) return null
|
||||
return <CanvasCommentsLayer {...props} />
|
||||
}
|
||||
|
||||
function CanvasCommentsLayer(props: CommentingContext) {
|
||||
const editor = useEditor()
|
||||
const options = useCommentingOptions()
|
||||
const allThreads = useCommentThreads(editor)
|
||||
const pending = usePendingComment()
|
||||
const canComment = useCanComment(props.currentUserId)
|
||||
// Nothing renders a pending comment when composing is blocked and there's no fallback slot, and
|
||||
// the dismiss handlers live inside PendingComposer — so clear the atom rather than strand it,
|
||||
// and leave the tool too: it holds while a composer is open, so staying would turn every click
|
||||
// into a silently swallowed placement.
|
||||
const canRenderComposer = canComment || options.components.ComposerFallback != null
|
||||
const showPendingComposer = pending != null && canRenderComposer
|
||||
useEffect(() => {
|
||||
if (pending && !showPendingComposer) {
|
||||
pendingComment.set(editor, null)
|
||||
if (editor.isIn('comment')) editor.setCurrentTool('select')
|
||||
}
|
||||
}, [editor, pending, showPendingComposer])
|
||||
const openId = useValue('open thread id', () => openThreadId.get(editor), [editor])
|
||||
// Matches the sidebar's `showResolved` filter. The open thread stays in — resolving from its own
|
||||
// popover shouldn't make the pin vanish under it.
|
||||
const showResolved = useValue('show resolved', () => sidebarFilters.get(editor).showResolved, [
|
||||
editor,
|
||||
])
|
||||
const threads = useMemo(
|
||||
() => allThreads.filter((t) => showResolved || t.resolved == null || t.id === openId),
|
||||
[allThreads, showResolved, openId]
|
||||
)
|
||||
useEffect(() => registerCommentAnchorLifecycle(editor), [editor])
|
||||
const {
|
||||
model: clusterModel,
|
||||
zoomBounds: clusterZoomBounds,
|
||||
fadeNodes,
|
||||
orphanThreads,
|
||||
heldThreads,
|
||||
} = useClusterModel(editor, threads, openId)
|
||||
const threadsById = useMemo(
|
||||
() => new Map<string, TLCommentThread>(threads.map((thread) => [thread.id, thread])),
|
||||
[threads]
|
||||
)
|
||||
// Pins with the *same* anchor point coincide at every zoom, so they render as one count-badge
|
||||
// stack. Keyed on page-space anchors, so camera moves never recompute this. Holding the map's
|
||||
// identity while the grouping is unchanged keeps a reply from re-rendering every pin — but the
|
||||
// map has no positions in it, so anything needing a stack's *point* must read the anchors itself.
|
||||
const pinStacksRef = useRef<Map<string, readonly string[]>>(new Map())
|
||||
const pinStacks = useValue(
|
||||
'comment pin stacks',
|
||||
() => {
|
||||
const stacks = computePinStacks(editor, threads)
|
||||
if (pinStacksEqual(pinStacksRef.current, stacks)) return pinStacksRef.current
|
||||
pinStacksRef.current = stacks
|
||||
return stacks
|
||||
},
|
||||
[editor, threads]
|
||||
)
|
||||
const openThread = openId ? threadsById.get(openId) : null
|
||||
const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
openThreadId.set(editor, null)
|
||||
openStackId.set(editor, null)
|
||||
pendingComment.set(editor, null)
|
||||
revealThreadRequest.set(editor, null)
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
// Clear a stale open-stack key: only the stack's own mounted handlers clear it, so collapsing to
|
||||
// a single pin strands it — and `useMarkerPreview` reads any non-null value as "a stack is open"
|
||||
// and suppresses every hover preview. Kept while any live stack still sits at that key. Reads the
|
||||
// anchors rather than keying off `pinStacks`, whose identity survives a stack moving as a whole.
|
||||
const openStackKeyIsStale = useValue(
|
||||
'open stack key stale',
|
||||
() => {
|
||||
const key = openStackId.get(editor)
|
||||
if (!key) return false
|
||||
return !isOpenStackKeyLive(editor, key, pinStacks, threadsById)
|
||||
},
|
||||
[editor, pinStacks, threadsById]
|
||||
)
|
||||
useEffect(() => {
|
||||
if (openStackKeyIsStale) openStackId.set(editor, null)
|
||||
}, [editor, openStackKeyIsStale])
|
||||
|
||||
// The requested thread, once it (and, for a comment id, its parent thread) has synced into the
|
||||
// store; null while records are still arriving or when no request is pending.
|
||||
const requestedRevealThread = useValue(
|
||||
'requested reveal thread',
|
||||
() => {
|
||||
const id = revealThreadRequest.get(editor)
|
||||
if (!id) return null
|
||||
const record = getCommentRecord(editor, id)
|
||||
if (!record) return null
|
||||
const thread =
|
||||
record.typeName === 'comment' ? getCommentThread(editor, record.threadId) : record
|
||||
return thread?.typeName === 'comment-thread' ? thread : null
|
||||
},
|
||||
[editor]
|
||||
)
|
||||
|
||||
// Bring a thread's pin into view (zooming to the first cluster split that unfolds it if it's
|
||||
// folded into a badge) and open it.
|
||||
const revealAndOpenThread = useCallback(
|
||||
(thread: TLCommentThread, duration?: number) => {
|
||||
revealThreadPin(editor, thread, clusterModel.table, clusterZoomBounds, options, duration)
|
||||
openThreadId.set(editor, thread.id)
|
||||
},
|
||||
[clusterModel.table, clusterZoomBounds, editor, options]
|
||||
)
|
||||
|
||||
// Serve a pending reveal request. Also unhides pins: the popover opens on the layer that hiding
|
||||
// withholds.
|
||||
useEffect(() => {
|
||||
if (!requestedRevealThread) return
|
||||
revealThreadRequest.set(editor, null)
|
||||
commentsHidden.set(editor, false)
|
||||
revealAndOpenThread(requestedRevealThread)
|
||||
}, [requestedRevealThread, revealAndOpenThread, editor])
|
||||
|
||||
// Picking a thread out of a cluster's hover preview. `openThreadId` alone would work, but would
|
||||
// cut straight there from the badge — zoom in first, the same move the badge's own click makes.
|
||||
const revealClusteredThread = useCallback(
|
||||
(thread: TLCommentThread) => revealAndOpenThread(thread, CLUSTER_EXPAND_ZOOM_MS),
|
||||
[revealAndOpenThread]
|
||||
)
|
||||
|
||||
const expandCluster = useCallback(
|
||||
(node: ClusterNode) => {
|
||||
zoomToClusterSplit(editor, clusterModel.table, clusterZoomBounds, node)
|
||||
},
|
||||
[clusterModel.table, clusterZoomBounds, editor]
|
||||
)
|
||||
|
||||
// Escape collapses the open thread. Capture-phase so it runs ahead of the editor, which would
|
||||
// otherwise cancel the tool or clear the selection.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || openThreadId.get(editor) === null) return
|
||||
// The mention picker owns Escape while it's open — let it dismiss the roster alone.
|
||||
if (isMentionPickerOpen()) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target && target.closest('.tlui-cmt-editing')) return
|
||||
openThreadId.set(editor, null)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
return () => document.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [editor])
|
||||
|
||||
// Shift+C toggles pin visibility. Physical `KeyC` so it's layout-independent, and skipped while
|
||||
// typing so it never fires from inside a composer.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code !== 'KeyC' || !e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target && target.closest('input, textarea, [contenteditable="true"]')) return
|
||||
toggleCommentsHidden(editor)
|
||||
e.preventDefault()
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
return () => document.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [editor])
|
||||
|
||||
// The signal is read above so this stays mounted and its shortcut/Escape effects keep running.
|
||||
if (hidden) return null
|
||||
|
||||
// The node's pin-stack group when every member shares one — a stack standing on its own, which
|
||||
// renders as a cascading count-badge list rather than a zoom-to-split cluster badge. The group
|
||||
// can include an open or orphan member the node's own leaves omit.
|
||||
const stackGroupOf = (node: ClusterNode): readonly string[] | null => {
|
||||
const group = pinStacks.get(node.members[0])
|
||||
if (!group) return null
|
||||
return node.members.every((id) => group.includes(id)) ? group : null
|
||||
}
|
||||
|
||||
// Which threads are on screen this render. A stack renders once, owned by its first on-screen
|
||||
// member — members arrive by different paths, so ownership can't be decided per-path.
|
||||
const renderedThreadIds = new Set<string>()
|
||||
if (options.enableClustering) {
|
||||
for (const { node } of fadeNodes) {
|
||||
if (node.count === 1) renderedThreadIds.add(node.id)
|
||||
// A pure-stack node's members aren't count-1 leaves, so register them here for the owner
|
||||
// logic to pick from.
|
||||
else if (stackGroupOf(node)) for (const id of node.members) renderedThreadIds.add(id)
|
||||
}
|
||||
for (const thread of orphanThreads) renderedThreadIds.add(thread.id)
|
||||
for (const thread of heldThreads) renderedThreadIds.add(thread.id)
|
||||
} else {
|
||||
for (const thread of threads) renderedThreadIds.add(thread.id)
|
||||
}
|
||||
if (openThread) renderedThreadIds.add(openThread.id)
|
||||
|
||||
// A coincident-stack member renders as the group's single count-badge stack (if it owns it)
|
||||
// or not at all; everything else is an ordinary pin.
|
||||
const renderThreadPin = (thread: TLCommentThread): ReactNode => {
|
||||
const group = pinStacks.get(thread.id)
|
||||
if (group) {
|
||||
const owner = group.find((id) => renderedThreadIds.has(id))
|
||||
if (owner !== thread.id) return null
|
||||
const stackThreads = group
|
||||
.map((id) => threadsById.get(id))
|
||||
.filter((t): t is TLCommentThread => t !== undefined)
|
||||
return <ThreadStackPin editor={editor} threads={stackThreads} {...props} />
|
||||
}
|
||||
return <ThreadPin editor={editor} thread={thread} {...props} />
|
||||
}
|
||||
|
||||
// Portalled rather than rendered into this component's slot: pins sit below the collaborator
|
||||
// cursors and popovers above the UI panels, and no single canvas layer spans both. The portal
|
||||
// also fixes where the layer lands among the container's children, keeping it behind the UI's
|
||||
// skip link in the tab order.
|
||||
return (
|
||||
<EditorPortal>
|
||||
{/* Wheel pass-through lives on each interactive element, not this root: the root spans the
|
||||
whole canvas, so a pin past its bottom/right edge inflates scrollHeight and the wheel
|
||||
hook's is-this-scrollable guard silently disables pass-through. */}
|
||||
<div className="tlui-cmt-canvas-layer">
|
||||
{options.enableClustering ? (
|
||||
<>
|
||||
{fadeNodes.map(({ node, phase }) => {
|
||||
let content: ReactNode
|
||||
const stackGroup = node.count > 1 ? stackGroupOf(node) : null
|
||||
if (node.count === 1) {
|
||||
const markerId = fadeNodeMarkerThreadId(node, null, openId)
|
||||
const thread = markerId ? threadsById.get(markerId) : undefined
|
||||
if (!thread) return null
|
||||
content = renderThreadPin(thread)
|
||||
} else if (stackGroup) {
|
||||
// Routed through the stack's owner so the open/orphan/held slots stay deduped:
|
||||
// when the owner is one of them, that slot draws the stack and this draws nothing.
|
||||
const owner = stackGroup.find((id) => renderedThreadIds.has(id)) ?? null
|
||||
const markerId = fadeNodeMarkerThreadId(node, owner, openId)
|
||||
content = markerId ? renderThreadPin(threadsById.get(markerId)!) : null
|
||||
} else {
|
||||
content = (
|
||||
<ClusterBadge
|
||||
editor={editor}
|
||||
node={node}
|
||||
onExpand={expandCluster}
|
||||
onSelectThread={revealClusteredThread}
|
||||
threadsById={threadsById}
|
||||
currentUserId={props.currentUserId}
|
||||
resolveAuthor={props.resolveAuthor}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={`cluster-fade:${node.id}`} className={clusterFadeClassName(phase)}>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{orphanThreads.map((thread) => (
|
||||
<Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
|
||||
))}
|
||||
{heldThreads.map((thread) => (
|
||||
<Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
// Clustering off: every thread renders its own pin. The open thread is excluded and
|
||||
// rendered once below, or it would mount a second, stacked pin.
|
||||
threads
|
||||
.filter((thread) => thread.id !== openId)
|
||||
.map((thread) => <Fragment key={thread.id}>{renderThreadPin(thread)}</Fragment>)
|
||||
)}
|
||||
{openThread && (
|
||||
<Fragment key={`open:${openThread.id}`}>{renderThreadPin(openThread)}</Fragment>
|
||||
)}
|
||||
<RegionDraftBox editor={editor} />
|
||||
{/* Keep the region visible while composing — the drag draft is gone by now, and no thread
|
||||
exists yet, so the pending anchor is what shows the area under the open composer. */}
|
||||
{pending?.anchor.type === 'region' && showPendingComposer && (
|
||||
<RegionBox editor={editor} box={pending.anchor} />
|
||||
)}
|
||||
{pending && showPendingComposer && (
|
||||
<PendingComposer editor={editor} pending={pending} {...props} />
|
||||
)}
|
||||
</div>
|
||||
</EditorPortal>
|
||||
)
|
||||
}
|
||||
45
packages/commenting/src/canvas/comments-sidebar.test.ts
Normal file
45
packages/commenting/src/canvas/comments-sidebar.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CommentListItemProps } from '../ui/comments-list'
|
||||
import { sortSidebarRows } from './comments-sidebar'
|
||||
|
||||
function row(id: string, lastActivity: number, resolved = false) {
|
||||
return {
|
||||
item: {
|
||||
id,
|
||||
resolved,
|
||||
author: { id: 'user:1', name: 'A' },
|
||||
preview: '',
|
||||
date: '',
|
||||
} as unknown as CommentListItemProps,
|
||||
lastActivity,
|
||||
}
|
||||
}
|
||||
|
||||
describe('sortSidebarRows', () => {
|
||||
it('orders threads by their most recent comment, not their first', () => {
|
||||
// t1 was started first but was just replied to; t2 has been quiet since it was created.
|
||||
const sorted = sortSidebarRows([row('t1', 300), row('t2', 200)])
|
||||
expect(sorted.map((r) => r.item.id)).toEqual(['t1', 't2'])
|
||||
})
|
||||
|
||||
it('sinks resolved threads below unresolved ones however recent they are', () => {
|
||||
const sorted = sortSidebarRows([row('t1', 100), row('t2', 999, true), row('t3', 200)])
|
||||
expect(sorted.map((r) => r.item.id)).toEqual(['t3', 't1', 't2'])
|
||||
})
|
||||
|
||||
it('orders resolved threads among themselves by recency too', () => {
|
||||
const sorted = sortSidebarRows([row('t1', 100, true), row('t2', 300, true)])
|
||||
expect(sorted.map((r) => r.item.id)).toEqual(['t2', 't1'])
|
||||
})
|
||||
|
||||
it('breaks activity ties by id so the list does not reshuffle', () => {
|
||||
const sorted = sortSidebarRows([row('t3', 5), row('t1', 5), row('t2', 5)])
|
||||
expect(sorted.map((r) => r.item.id)).toEqual(['t1', 't2', 't3'])
|
||||
})
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const input = [row('t1', 100), row('t2', 200)]
|
||||
sortSidebarRows(input)
|
||||
expect(input.map((r) => r.item.id)).toEqual(['t1', 't2'])
|
||||
})
|
||||
})
|
||||
208
packages/commenting/src/canvas/comments-sidebar.tsx
Normal file
208
packages/commenting/src/canvas/comments-sidebar.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { ReactNode, useCallback } from 'react'
|
||||
import { EditorPortal, TLCommentThread, useEditor, useTranslation, useValue } from 'tldraw'
|
||||
import { CommentListItemProps, CommentListItemRenderProps, CommentsList } from '../ui/comments-list'
|
||||
import { UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
||||
import { CommentsFilterMenu } from './comments-filter-menu'
|
||||
import { CommentsVisibilityToggle } from './comments-visibility-toggle'
|
||||
import { type CommentingContext } from './context'
|
||||
import { getCommentsByThread, useCommentThreads } from './hooks'
|
||||
import { useCommentingEnabled } from './license'
|
||||
import { useCommentingOptions } from './options'
|
||||
import { richTextToPlaintext } from './rich-text'
|
||||
import { commentsSidebarOpen, openThreadId, sidebarFilters } from './state'
|
||||
import { focusThread } from './thread-state'
|
||||
|
||||
/**
|
||||
* The host wiring for {@link CanvasCommentsSidebar}: the {@link CommentingContext} fields it reads,
|
||||
* plus the panel's own slots. A non-null `currentUserId` enables the "only your threads" filter, and
|
||||
* an `isCommentUnread` the "only unread" one. `CanvasComments` takes the same fields, so a host
|
||||
* mounting both can spread one object into each.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface CanvasCommentsSidebarProps extends Pick<
|
||||
CommentingContext,
|
||||
'currentUserId' | 'resolveAuthor' | 'isCommentUnread' | 'getThreadHref'
|
||||
> {
|
||||
/** Header above the list. */
|
||||
header?: ReactNode
|
||||
/** Shown when the page has no threads. */
|
||||
empty?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A comments list panel for the current page, shown while {@link commentsSidebarOpen} is set (e.g.
|
||||
* toggled by a button). Clicking a thread brings its pin into view and opens it. Batteries-included
|
||||
* over the store (a sibling to `CanvasComments`); `CommentsList` is exported for a differently-placed
|
||||
* or always-on list.
|
||||
* @public @react
|
||||
*/
|
||||
export function CanvasCommentsSidebar(props: CanvasCommentsSidebarProps) {
|
||||
const { resolveAuthor, currentUserId, isCommentUnread, header, empty, getThreadHref } = props
|
||||
// Name-only view of the resolver, for the plaintext previews (which resolve @-mentions).
|
||||
const resolveName = useCallback((id: string) => resolveAuthor(id)?.name, [resolveAuthor])
|
||||
const editor = useEditor()
|
||||
const options = useCommentingOptions()
|
||||
const commentingEnabled = useCommentingEnabled()
|
||||
const msg = useTranslation()
|
||||
const threads = useCommentThreads(editor)
|
||||
const byThread = useValue('comments by thread', () => getCommentsByThread(editor).get(), [editor])
|
||||
const currentPageId = useValue('page id', () => editor.getCurrentPageId(), [editor])
|
||||
const open = useValue('sidebar open', () => commentsSidebarOpen.get(editor), [editor])
|
||||
const openId = useValue('open thread', () => openThreadId.get(editor), [editor])
|
||||
const filters = useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
|
||||
const pageNames = useValue(
|
||||
'page names',
|
||||
() => new Map(editor.getPages().map((page) => [page.id, page.name])),
|
||||
[editor]
|
||||
)
|
||||
|
||||
if (!commentingEnabled || !open) return null
|
||||
|
||||
// Page scoping is treated as scoping, not a filter: an empty page reads "no comments yet",
|
||||
// while a list emptied by the toggles below reads "nothing matches your filters".
|
||||
const pageThreads = threads.filter(
|
||||
(thread) => !filters.onlyCurrentPage || thread.pageId === currentPageId
|
||||
)
|
||||
|
||||
const rows: SidebarRow[] = pageThreads
|
||||
.filter((thread) => filters.showResolved || thread.resolved == null)
|
||||
// "Only mine" is ignored without a known user — otherwise a persisted onlyMine=true would
|
||||
// empty the list for a signed-out viewer, with the (hidden) toggle giving no way to clear it.
|
||||
.filter(
|
||||
(thread) => !filters.onlyMine || currentUserId === null || thread.createdBy === currentUserId
|
||||
)
|
||||
// "Only unread" is likewise ignored without a read-status source.
|
||||
.filter(
|
||||
(thread) =>
|
||||
!filters.onlyUnread ||
|
||||
isCommentUnread === undefined ||
|
||||
(byThread.get(thread.id) ?? []).some((c) => isCommentUnread(c.id))
|
||||
)
|
||||
.map((thread) => {
|
||||
const threadComments = byThread.get(thread.id) ?? []
|
||||
const first = threadComments[0]
|
||||
// Comments arrive oldest-first, so the last one is the thread's most recent activity.
|
||||
const last = threadComments[threadComments.length - 1]
|
||||
let preview: ReactNode = ''
|
||||
// The `ThreadPreview` component slot overrides the built-in plaintext default.
|
||||
const ThreadPreview = options.components.ThreadPreview
|
||||
if (first) {
|
||||
preview = ThreadPreview ? (
|
||||
<ThreadPreview comment={first} />
|
||||
) : (
|
||||
richTextToPlaintext(first.body, resolveName)
|
||||
)
|
||||
}
|
||||
return {
|
||||
item: {
|
||||
id: thread.id,
|
||||
author: resolveAuthor(thread.createdBy) ?? UNKNOWN_COMMENT_AUTHOR,
|
||||
preview,
|
||||
date: new Date((first ?? thread).createdAt).toISOString(),
|
||||
resolved: thread.resolved != null,
|
||||
// The page label only earns its place when it adds information: multiple pages
|
||||
// exist, and the thread is somewhere other than where you already are.
|
||||
page:
|
||||
pageNames.size > 1 && thread.pageId !== currentPageId
|
||||
? pageNames.get(thread.pageId)
|
||||
: undefined,
|
||||
count: threadComments.length,
|
||||
selected: openId === thread.id,
|
||||
href: getThreadHref?.(thread.id),
|
||||
},
|
||||
lastActivity: (last ?? thread).createdAt,
|
||||
}
|
||||
})
|
||||
|
||||
const items = sortSidebarRows(rows).map((row) => row.item)
|
||||
|
||||
const focus = (id: string) => {
|
||||
const thread = threads.find((t) => t.id === id)
|
||||
if (thread) focusThread(editor, thread)
|
||||
}
|
||||
|
||||
// The `ThreadRow` component slot overrides the built-in row. It gets the thread record alongside
|
||||
// the summarized row, so host state on `thread.meta` is in reach without a second lookup. Indexed
|
||||
// once rather than scanned per row — this runs for every row on every render of the panel.
|
||||
const ThreadRow = options.components.ThreadRow
|
||||
const renderItem = ThreadRow
|
||||
? (() => {
|
||||
// Keyed by plain string: a row's id comes back as one, not as a branded thread id.
|
||||
const threadsById = new Map<string, TLCommentThread>(
|
||||
threads.map((thread) => [thread.id, thread])
|
||||
)
|
||||
return (rowProps: CommentListItemRenderProps) => {
|
||||
const thread = threadsById.get(rowProps.id)
|
||||
// Can't happen — the items are built from these threads — but a row with no thread has
|
||||
// nothing to render, and the slot's contract says the record is there.
|
||||
if (!thread) return null
|
||||
return <ThreadRow {...rowProps} thread={thread} />
|
||||
}
|
||||
})()
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<SidebarPanel>
|
||||
<CommentsList
|
||||
items={items}
|
||||
header={header ?? msg('comments.title')}
|
||||
headerAction={
|
||||
<div className="tlui-cmt-list__header-actions">
|
||||
<CommentsFilterMenu
|
||||
canFilterByAuthor={currentUserId !== null}
|
||||
canFilterByUnread={isCommentUnread !== undefined}
|
||||
/>
|
||||
<CommentsVisibilityToggle />
|
||||
</div>
|
||||
}
|
||||
empty={
|
||||
items.length === 0 && pageThreads.length > 0
|
||||
? msg('comments.empty-filtered')
|
||||
: (empty ?? msg('comments.empty'))
|
||||
}
|
||||
resolvedLabel={msg('comments.resolved')}
|
||||
onSelect={focus}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
</SidebarPanel>
|
||||
)
|
||||
}
|
||||
|
||||
/** A list row paired with the sort key that isn't part of what the row displays. @public */
|
||||
export interface SidebarRow {
|
||||
item: CommentListItemProps
|
||||
/** When the thread's most recent comment was posted — what the list orders by. */
|
||||
lastActivity: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Order the list: unresolved threads first, then by most recent activity, id as a stable tiebreak.
|
||||
* Recency is the thread's *latest* comment, not its first, so a thread someone just replied to rises
|
||||
* to the top instead of staying wherever it was started. (The row still shows the thread's opening
|
||||
* comment and its date — that's what identifies the thread; only the ordering follows the replies.)
|
||||
*
|
||||
* Exported so a hand-built list can match the sidebar's ordering instead of re-deriving it.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function sortSidebarRows(rows: readonly SidebarRow[]): readonly SidebarRow[] {
|
||||
return [...rows].sort(
|
||||
(a, b) =>
|
||||
Number(!!a.item.resolved) - Number(!!b.item.resolved) ||
|
||||
b.lastActivity - a.lastActivity ||
|
||||
(a.item.id < b.item.id ? -1 : 1)
|
||||
)
|
||||
}
|
||||
|
||||
/** The sidebar surface, portaled into the container. It scrolls its own list, so — unlike tldraw's
|
||||
* wheel-transparent panels — a wheel over it doesn't pan the canvas. */
|
||||
function SidebarPanel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<EditorPortal>
|
||||
<div className="tlui-cmt-canvas-sidebar" onContextMenu={(e) => e.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</EditorPortal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TldrawUiButton, useEditor, useTranslation, useValue } from 'tldraw'
|
||||
import { EyeClosedIcon, EyeOpenIcon } from '../ui/icons'
|
||||
import { commentsHidden, toggleCommentsHidden } from './state'
|
||||
|
||||
/** The sidebar header's show/hide toggle for comment pins — an eye that closes while comments
|
||||
* are hidden. The same state as the Shift+C shortcut.
|
||||
* @public @react */
|
||||
export function CommentsVisibilityToggle() {
|
||||
const editor = useEditor()
|
||||
const msg = useTranslation()
|
||||
const hidden = useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
||||
const label = hidden ? msg('comments.show') : msg('comments.hide')
|
||||
|
||||
return (
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
tooltip={label}
|
||||
title={label}
|
||||
className="tlui-cmt-header-btn"
|
||||
onClick={() => toggleCommentsHidden(editor)}
|
||||
>
|
||||
{hidden ? <EyeClosedIcon /> : <EyeOpenIcon />}
|
||||
</TldrawUiButton>
|
||||
)
|
||||
}
|
||||
48
packages/commenting/src/canvas/context.ts
Normal file
48
packages/commenting/src/canvas/context.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { type CommentAuthor, type MentionMember } from '@tldraw/mentions'
|
||||
import { type ReactNode } from 'react'
|
||||
import { type TLComment, type TLCommentId, type TLCommentThreadId } from 'tldraw'
|
||||
|
||||
/**
|
||||
* The live, host-supplied half of commenting: who the viewer is, how author ids become names, read
|
||||
* status, and the mention roster. (The static half is {@link CommentingOptions}, configured once on
|
||||
* the tool.)
|
||||
*
|
||||
* Every commenting surface reads the same set, so a host mounting more than one — `CanvasComments`
|
||||
* and `CanvasCommentsSidebar`, say — can build it once and spread it into each rather than
|
||||
* repeating the props:
|
||||
*
|
||||
* ```tsx
|
||||
* const commenting: CommentingContext = { currentUserId, resolveAuthor, isCommentUnread }
|
||||
*
|
||||
* <CanvasComments {...commenting} />
|
||||
* <CanvasCommentsSidebar {...commenting} />
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface CommentingContext {
|
||||
/** The signed-in user's id, or null for a read-only viewer. Only a signed-in user composes. */
|
||||
currentUserId: string | null
|
||||
/** Map an author id to their display info, or `undefined` when the id can't be resolved. */
|
||||
resolveAuthor(id: string): CommentAuthor | undefined
|
||||
/** Called after any comment (a new thread's first comment, or a reply) is posted. */
|
||||
onPostComment?(comment: TLComment): void
|
||||
/** Whether a comment is unread for the current user (return true for unread). */
|
||||
isCommentUnread?(commentId: TLCommentId): boolean
|
||||
/**
|
||||
* Called with every unread comment shown to the user in an open thread popover, batched per
|
||||
* report, so hosts can record read receipts without a write per comment. Needs
|
||||
* `isCommentUnread` to know what's unread.
|
||||
*/
|
||||
onCommentsRead?(commentIds: TLCommentId[]): void
|
||||
/** Resolve the members matching an `@`-query in the composers (sync or async). */
|
||||
getMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>
|
||||
/** Override a mention-picker row's content. */
|
||||
renderMentionSuggestion?(member: MentionMember): ReactNode
|
||||
/**
|
||||
* The host's URL for a thread, so surfaces can link to it — e.g. the sidebar's rows render as
|
||||
* anchors when this is present, letting ctrl/cmd-click and middle-click open the thread in a
|
||||
* new tab (a plain click still selects the thread in place; the href isn't followed).
|
||||
*/
|
||||
getThreadHref?(threadId: TLCommentThreadId): string | undefined
|
||||
}
|
||||
94
packages/commenting/src/canvas/hooks.test.ts
Normal file
94
packages/commenting/src/canvas/hooks.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
commentSchemaRecords,
|
||||
createComment,
|
||||
createCommentThread,
|
||||
createTLSchema,
|
||||
createTLStore,
|
||||
defaultBindingUtils,
|
||||
defaultShapeUtils,
|
||||
defaultTools,
|
||||
Editor,
|
||||
toRichText,
|
||||
} from 'tldraw'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { putCommentRecords } from './comment-mutations'
|
||||
import { getCommentsByThread } from './hooks'
|
||||
|
||||
let editors: Editor[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const editor of editors) editor.dispose()
|
||||
editors = []
|
||||
})
|
||||
|
||||
function makeEditor(): Editor {
|
||||
const editor = new Editor({
|
||||
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
tools: defaultTools,
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
editors.push(editor)
|
||||
return editor
|
||||
}
|
||||
|
||||
function makeThreadWithComment(editor: Editor, text: string) {
|
||||
const pageId = editor.getCurrentPageId()
|
||||
const thread = createCommentThread({
|
||||
pageId,
|
||||
anchor: { type: 'point', x: 0, y: 0 },
|
||||
createdBy: 'user1',
|
||||
})
|
||||
const comment = createComment({
|
||||
threadId: thread.id,
|
||||
pageId,
|
||||
authorId: 'user1',
|
||||
body: toRichText(text),
|
||||
})
|
||||
return { thread, comment }
|
||||
}
|
||||
|
||||
describe('getCommentsByThread', () => {
|
||||
it('groups live comments by thread, oldest first', () => {
|
||||
const editor = makeEditor()
|
||||
const { thread, comment } = makeThreadWithComment(editor, 'first')
|
||||
const reply = createComment({
|
||||
threadId: thread.id,
|
||||
pageId: editor.getCurrentPageId(),
|
||||
authorId: 'user2',
|
||||
body: toRichText('second'),
|
||||
now: comment.createdAt + 1,
|
||||
})
|
||||
// Inserted newest-first to prove the grouping sorts by createdAt rather than store order.
|
||||
putCommentRecords(editor, [thread, reply, comment])
|
||||
expect(getCommentsByThread(editor).get().get(thread.id)).toEqual([comment, reply])
|
||||
})
|
||||
|
||||
it("reuses a thread's array when another thread's comments change", () => {
|
||||
const editor = makeEditor()
|
||||
const a = makeThreadWithComment(editor, 'thread a')
|
||||
const b = makeThreadWithComment(editor, 'thread b')
|
||||
putCommentRecords(editor, [a.thread, a.comment, b.thread, b.comment])
|
||||
|
||||
const byThread = getCommentsByThread(editor)
|
||||
const before = byThread.get()
|
||||
|
||||
const replyToB = createComment({
|
||||
threadId: b.thread.id,
|
||||
pageId: editor.getCurrentPageId(),
|
||||
authorId: 'user2',
|
||||
body: toRichText('reply'),
|
||||
now: b.comment.createdAt + 1,
|
||||
})
|
||||
putCommentRecords(editor, [replyToB])
|
||||
const after = byThread.get()
|
||||
|
||||
expect(after).not.toBe(before)
|
||||
// The unchanged thread keeps its exact array, so its subscribers don't re-render...
|
||||
expect(after.get(a.thread.id)).toBe(before.get(a.thread.id))
|
||||
// ...while the changed thread gets a fresh one with the reply in place.
|
||||
expect(after.get(b.thread.id)).not.toBe(before.get(b.thread.id))
|
||||
expect(after.get(b.thread.id)).toEqual([b.comment, replyToB])
|
||||
})
|
||||
})
|
||||
83
packages/commenting/src/canvas/hooks.ts
Normal file
83
packages/commenting/src/canvas/hooks.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
computed,
|
||||
Computed,
|
||||
Editor,
|
||||
isUninitialized,
|
||||
TLComment,
|
||||
TLCommentThread,
|
||||
TLCommentThreadId,
|
||||
useValue,
|
||||
WeakCache,
|
||||
} from 'tldraw'
|
||||
import { getLiveComments, getLiveCommentThreads } from './comment-store'
|
||||
|
||||
/**
|
||||
* The comment threads that should render (pins, sidebar), reactively — the live set described by
|
||||
* {@link getLiveCommentThreads}. Use `getCommentThreads` for the unfiltered set, including
|
||||
* soft-deleted threads awaiting the server's prune.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useCommentThreads(editor: Editor): TLCommentThread[] {
|
||||
return useValue('comment threads', () => getLiveCommentThreads(editor), [editor])
|
||||
}
|
||||
|
||||
/**
|
||||
* Every live comment grouped by thread, oldest first within each thread. One computed shared by
|
||||
* every mounted thread: each `useThreadComments` would otherwise scan and sort the whole comment
|
||||
* set, so N open threads did N full passes on every comment mutation. Cached per editor so the
|
||||
* grouping survives re-renders and is rebuilt only when the comment set actually changes.
|
||||
*/
|
||||
const commentsByThread = new WeakCache<Editor, Computed<Map<TLCommentThreadId, TLComment[]>>>()
|
||||
|
||||
/** Live comments grouped by thread id, cached per editor. @internal */
|
||||
export function getCommentsByThread(editor: Editor) {
|
||||
return commentsByThread.get(editor, () =>
|
||||
computed('comments by thread', (prev) => {
|
||||
const byThread = new Map<TLCommentThreadId, TLComment[]>()
|
||||
for (const comment of getLiveComments(editor)) {
|
||||
const existing = byThread.get(comment.threadId)
|
||||
if (existing) existing.push(comment)
|
||||
else byThread.set(comment.threadId, [comment])
|
||||
}
|
||||
for (const [threadId, comments] of byThread) {
|
||||
comments.sort((a, b) => a.createdAt - b.createdAt)
|
||||
// Reuse the previous per-thread array when that thread's comment list is unchanged
|
||||
// (record identities are stable while unchanged), so `useThreadComments` subscribers
|
||||
// on other threads don't re-render when one thread gains a reply.
|
||||
if (isUninitialized(prev)) continue
|
||||
const prevComments = prev.get(threadId)
|
||||
if (
|
||||
prevComments &&
|
||||
prevComments.length === comments.length &&
|
||||
comments.every((comment, i) => prevComments[i] === comment)
|
||||
) {
|
||||
byThread.set(threadId, prevComments)
|
||||
}
|
||||
}
|
||||
return byThread
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** A thread's live comments, oldest first, reactively. @public */
|
||||
export function useThreadComments(editor: Editor, threadId: TLCommentThreadId): TLComment[] {
|
||||
return useValue(
|
||||
'thread comments',
|
||||
() => getCommentsByThread(editor).get().get(threadId) ?? EMPTY_COMMENTS,
|
||||
[editor, threadId]
|
||||
)
|
||||
}
|
||||
|
||||
/** Shared empty result so a thread with no comments doesn't churn referential equality. */
|
||||
const EMPTY_COMMENTS: TLComment[] = []
|
||||
|
||||
/** Every live comment in the store ({@link getLiveComments}), oldest first, reactively. Group by
|
||||
* `threadId` for per-thread lists. @public */
|
||||
export function useComments(editor: Editor): TLComment[] {
|
||||
return useValue(
|
||||
'all comments',
|
||||
() => getLiveComments(editor).sort((a, b) => a.createdAt - b.createdAt),
|
||||
[editor]
|
||||
)
|
||||
}
|
||||
17
packages/commenting/src/canvas/license.ts
Normal file
17
packages/commenting/src/canvas/license.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useLicenseFeatureFlag, useMaybeLicenseManager } from 'tldraw'
|
||||
|
||||
/**
|
||||
* Whether commenting is licensed for this editor. Enabled in development; in production it requires
|
||||
* a tldraw license that includes the commenting feature (or the collaboration umbrella that grants
|
||||
* it). Reactive: re-reads when license validation resolves, and returns `false` while validation is
|
||||
* pending, so gated UI stays hidden until the license is confirmed. Works outside `<Tldraw />` too:
|
||||
* UI mounted via `EditorProvider` resolves the license through the editor, and with no editor at
|
||||
* all this is `false`.
|
||||
*
|
||||
* The built-in commenting components (`CanvasComments`, `CanvasCommentsSidebar`, and the comment
|
||||
* tool's Quick Action) gate on this. Use it to gate any custom commenting UI the same way.
|
||||
* @public
|
||||
*/
|
||||
export function useCommentingEnabled(): boolean {
|
||||
return useLicenseFeatureFlag(useMaybeLicenseManager(), 'commenting')
|
||||
}
|
||||
115
packages/commenting/src/canvas/mobile-placement.ts
Normal file
115
packages/commenting/src/canvas/mobile-placement.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { RefObject, useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { PORTRAIT_BREAKPOINT, useBreakpoint, useContainer } from 'tldraw'
|
||||
import { getVisibleViewport } from '../ui/visual-viewport'
|
||||
|
||||
/**
|
||||
* Mobile mode for the commenting surfaces — the same breakpoint gate as tldraw's mobile toolbar and
|
||||
* style panel (which also honors `forceMobile` on `<Tldraw>`). The commenting layer can mount
|
||||
* without tldraw's default UI (`hideUi`, custom UI), where the breakpoint provider is absent and
|
||||
* `useBreakpoint` throws; fall back to desktop there, matching the pre-mobile rendering those hosts
|
||||
* always had.
|
||||
*/
|
||||
export function useIsMobileCommenting(): boolean {
|
||||
let breakpoint: number
|
||||
try {
|
||||
// The call is unconditional — the try only guards the provider's absence, not hook order.
|
||||
// oxlint-disable-next-line react-hooks/rules-of-hooks
|
||||
breakpoint = useBreakpoint()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return breakpoint < PORTRAIT_BREAKPOINT.TABLET_SM
|
||||
}
|
||||
|
||||
const VIEWPORT_MARGIN = 8
|
||||
|
||||
/**
|
||||
* Keep a canvas-anchored panel — the pending composer or an open thread popover — within the
|
||||
* visible viewport on mobile. The software keyboard shrinks the *visual* viewport while leaving the
|
||||
* layout viewport (and CSS `dvh`) untouched, so a panel placed at a fixed offset from its pin can
|
||||
* end up behind the keyboard. On mobile this clamps the panel's top-left into the visible box so it
|
||||
* stays on-screen above the keyboard. Desktop is untouched: when `enabled` is false the base point
|
||||
* is returned unchanged, tracking the camera exactly as before.
|
||||
*
|
||||
* `base` is the panel's desired top-left in container-relative viewport coordinates (what the call
|
||||
* site would otherwise write straight into `left`/`top`).
|
||||
*/
|
||||
export function useMobilePlacement(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
base: { x: number; y: number },
|
||||
enabled: boolean
|
||||
): { left: number; top: number } {
|
||||
const container = useContainer()
|
||||
// Destructured so the effects below depend on the two numbers rather than the object, which the
|
||||
// call sites build fresh on every render.
|
||||
const { x: baseX, y: baseY } = base
|
||||
const [placed, setPlaced] = useState<{ left: number; top: number }>(() => ({
|
||||
left: baseX,
|
||||
top: baseY,
|
||||
}))
|
||||
// The camera moves `base` every frame while panning. Reading it from a ref keeps `update` stable
|
||||
// across those frames, so the observers below are set up once per panel rather than being torn
|
||||
// down and rebuilt each frame.
|
||||
const baseRef = useRef({ x: baseX, y: baseY })
|
||||
|
||||
const update = useCallback(() => {
|
||||
if (!enabled) return
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const win = container.ownerDocument.defaultView ?? window
|
||||
const { x, y } = baseRef.current
|
||||
|
||||
// Panel coordinates are container-relative; the visual viewport is window-relative.
|
||||
const cRect = container.getBoundingClientRect()
|
||||
const vp = getVisibleViewport(win)
|
||||
const top = vp.top - cRect.top + VIEWPORT_MARGIN
|
||||
const bottom = vp.bottom - cRect.top - VIEWPORT_MARGIN
|
||||
const left = vp.left - cRect.left + VIEWPORT_MARGIN
|
||||
const right = vp.right - cRect.left - VIEWPORT_MARGIN
|
||||
|
||||
const w = el.offsetWidth
|
||||
const h = el.offsetHeight
|
||||
|
||||
const nextLeft = Math.max(left, Math.min(x, right - w))
|
||||
// The keyboard only ever covers space from the bottom, so it can pull the panel up (when its
|
||||
// bottom would be hidden) but must never push it down below its natural spot. `y` is the
|
||||
// floor: any spurious rise in `top` (e.g. iOS scrolling the page to reveal a focused input)
|
||||
// is capped here, so a panel that already clears the keyboard doesn't move at all.
|
||||
const nextTop = Math.min(y, Math.max(top, bottom - h))
|
||||
setPlaced((prev) =>
|
||||
prev.left === nextLeft && prev.top === nextTop ? prev : { left: nextLeft, top: nextTop }
|
||||
)
|
||||
}, [container, ref, enabled])
|
||||
|
||||
// Re-place as the camera moves the panel's base point.
|
||||
useLayoutEffect(() => {
|
||||
baseRef.current = { x: baseX, y: baseY }
|
||||
update()
|
||||
}, [baseX, baseY, update])
|
||||
|
||||
// Re-place when the panel grows (replies, edits), when the visual viewport changes (keyboard,
|
||||
// pinch-zoom), or when the window resizes.
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) return
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const win = container.ownerDocument.defaultView ?? window
|
||||
const ro = new ResizeObserver(update)
|
||||
ro.observe(el)
|
||||
const vv = win.visualViewport
|
||||
vv?.addEventListener('resize', update)
|
||||
vv?.addEventListener('scroll', update)
|
||||
win.addEventListener('resize', update)
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
vv?.removeEventListener('resize', update)
|
||||
vv?.removeEventListener('scroll', update)
|
||||
win.removeEventListener('resize', update)
|
||||
}
|
||||
}, [container, ref, enabled, update])
|
||||
|
||||
// Desktop keeps its fixed placement, recomputed each render so it tracks the pin as the camera
|
||||
// moves.
|
||||
if (!enabled) return { left: baseX, top: baseY }
|
||||
return placed
|
||||
}
|
||||
299
packages/commenting/src/canvas/options.test.ts
Normal file
299
packages/commenting/src/canvas/options.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { createComment, createCommentThread, toRichText, type Editor, type TLPageId } from 'tldraw'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { commitCommentMutation } from './comment-mutations'
|
||||
import { CommentTool } from './comment-tool'
|
||||
import {
|
||||
defaultCanModifyComment,
|
||||
defaultCommentingOptions,
|
||||
getCanComment,
|
||||
getCanModifyComment,
|
||||
getCommentingOptions,
|
||||
type CommentingOptions,
|
||||
type CommentModificationContext,
|
||||
} from './options'
|
||||
import { openThreadId, pendingComment } from './state'
|
||||
|
||||
// The StateNode constructor doesn't call any editor methods, so a bare stub is enough to
|
||||
// instantiate a configured tool and read its merged options.
|
||||
function optionsOf(Tool: typeof CommentTool): CommentingOptions {
|
||||
return new Tool({} as Editor).options
|
||||
}
|
||||
|
||||
describe('CommentTool.configure', () => {
|
||||
it('returns default options when unconfigured', () => {
|
||||
expect(new CommentTool({} as Editor).options).toEqual(defaultCommentingOptions)
|
||||
})
|
||||
|
||||
it('merges overrides over the defaults', () => {
|
||||
const Tool = CommentTool.configure({ history: 'record', enableClustering: false })
|
||||
expect(optionsOf(Tool)).toEqual({
|
||||
...defaultCommentingOptions,
|
||||
history: 'record',
|
||||
enableClustering: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('layers chained configure calls', () => {
|
||||
const Tool = CommentTool.configure({ history: 'record' }).configure({ enableClustering: false })
|
||||
expect(optionsOf(Tool)).toEqual({
|
||||
...defaultCommentingOptions,
|
||||
history: 'record',
|
||||
enableClustering: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('layers component slots across chained configure calls', () => {
|
||||
const CommentBody = () => null
|
||||
const PinContent = () => null
|
||||
const Tool = CommentTool.configure({ components: { CommentBody } }).configure({
|
||||
components: { PinContent },
|
||||
})
|
||||
// The second call's slot is added without dropping the first call's slot.
|
||||
expect(optionsOf(Tool).components).toEqual({ CommentBody, PinContent })
|
||||
})
|
||||
|
||||
it('does not mutate the base tool or the defaults', () => {
|
||||
CommentTool.configure({ history: 'record' })
|
||||
expect(new CommentTool({} as Editor).options).toEqual(defaultCommentingOptions)
|
||||
expect(defaultCommentingOptions.history).toBe('ignore')
|
||||
})
|
||||
})
|
||||
|
||||
// A minimal editor stub: getCommentingOptions reads the comment tool's `options` off
|
||||
// getStateDescendant, and commitCommentMutation forwards to run().
|
||||
function stubEditor(options: CommentingOptions) {
|
||||
const runCalls: Array<{ history: unknown }> = []
|
||||
const editor = {
|
||||
getStateDescendant: () => ({ options }),
|
||||
run: (fn: () => void, opts: { history: unknown }) => {
|
||||
runCalls.push(opts)
|
||||
fn()
|
||||
return editor
|
||||
},
|
||||
} as unknown as Editor
|
||||
return { editor, runCalls }
|
||||
}
|
||||
|
||||
describe('getCommentingOptions', () => {
|
||||
it('reads the tool options off the editor', () => {
|
||||
const options = { ...defaultCommentingOptions, history: 'record' } as CommentingOptions
|
||||
const { editor } = stubEditor(options)
|
||||
expect(getCommentingOptions(editor)).toBe(options)
|
||||
})
|
||||
|
||||
it('falls back to defaults when the comment tool is absent', () => {
|
||||
const editor = { getStateDescendant: () => undefined } as unknown as Editor
|
||||
expect(getCommentingOptions(editor)).toBe(defaultCommentingOptions)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCanComment', () => {
|
||||
it('defaults to requiring a current user', () => {
|
||||
const { editor } = stubEditor(defaultCommentingOptions)
|
||||
expect(getCanComment(editor, 'alice')).toBe(true)
|
||||
expect(getCanComment(editor, null)).toBe(false)
|
||||
expect(getCanComment(editor, undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes an undefined viewer to null for the callback', () => {
|
||||
const calls: Array<string | null> = []
|
||||
const { editor } = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
canComment: (ctx) => {
|
||||
calls.push(ctx.currentUserId)
|
||||
return false
|
||||
},
|
||||
})
|
||||
getCanComment(editor, undefined)
|
||||
expect(calls).toEqual([null])
|
||||
})
|
||||
|
||||
it('delegates to the canComment callback, passing the editor and viewer', () => {
|
||||
const calls: Array<{ editor: Editor; currentUserId: string | null }> = []
|
||||
const { editor } = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
canComment: (ctx) => {
|
||||
calls.push(ctx)
|
||||
return ctx.currentUserId === 'alice'
|
||||
},
|
||||
})
|
||||
expect(getCanComment(editor, 'alice')).toBe(true)
|
||||
expect(getCanComment(editor, 'bob')).toBe(false)
|
||||
expect(calls).toEqual([
|
||||
{ editor, currentUserId: 'alice' },
|
||||
{ editor, currentUserId: 'bob' },
|
||||
])
|
||||
})
|
||||
|
||||
it('lets the callback fully replace the signed-in default', () => {
|
||||
const { editor } = stubEditor({ ...defaultCommentingOptions, canComment: () => true })
|
||||
expect(getCanComment(editor, null)).toBe(true)
|
||||
})
|
||||
|
||||
// This is read during render, so a throwing host rule would take the comments layer with it.
|
||||
it('denies rather than throws when the callback throws', () => {
|
||||
const onError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { editor } = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
canComment: () => {
|
||||
throw new Error('lookup failed')
|
||||
},
|
||||
})
|
||||
expect(getCanComment(editor, 'alice')).toBe(false)
|
||||
expect(onError).toHaveBeenCalled()
|
||||
onError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// Plain records for the permission checks — the factories are pure, so no store is involved.
|
||||
const PAGE_ID = 'page:test' as TLPageId
|
||||
|
||||
function makeThread(createdBy: string) {
|
||||
return createCommentThread({
|
||||
pageId: PAGE_ID,
|
||||
anchor: { type: 'point', x: 0, y: 0 },
|
||||
createdBy,
|
||||
})
|
||||
}
|
||||
|
||||
function makeComment(authorId: string) {
|
||||
return createComment({
|
||||
threadId: makeThread(authorId).id,
|
||||
pageId: PAGE_ID,
|
||||
authorId,
|
||||
body: toRichText('hello'),
|
||||
})
|
||||
}
|
||||
|
||||
describe('getCanModifyComment', () => {
|
||||
it('defaults to the record owner: the comment author edits and deletes', () => {
|
||||
const { editor } = stubEditor(defaultCommentingOptions)
|
||||
const comment = makeComment('alice')
|
||||
expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(true)
|
||||
expect(getCanModifyComment(editor, 'alice', { action: 'delete-comment', comment })).toBe(true)
|
||||
expect(getCanModifyComment(editor, 'bob', { action: 'edit-comment', comment })).toBe(false)
|
||||
expect(getCanModifyComment(editor, 'bob', { action: 'delete-comment', comment })).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults to the thread creator for a thread delete', () => {
|
||||
const { editor } = stubEditor(defaultCommentingOptions)
|
||||
const thread = makeThread('alice')
|
||||
expect(getCanModifyComment(editor, 'alice', { action: 'delete-thread', thread })).toBe(true)
|
||||
expect(getCanModifyComment(editor, 'bob', { action: 'delete-thread', thread })).toBe(false)
|
||||
})
|
||||
|
||||
it('withholds everything from a viewer with no identity', () => {
|
||||
const { editor } = stubEditor(defaultCommentingOptions)
|
||||
const comment = makeComment('alice')
|
||||
const thread = makeThread('alice')
|
||||
expect(getCanModifyComment(editor, null, { action: 'edit-comment', comment })).toBe(false)
|
||||
expect(getCanModifyComment(editor, undefined, { action: 'delete-comment', comment })).toBe(
|
||||
false
|
||||
)
|
||||
expect(getCanModifyComment(editor, null, { action: 'delete-thread', thread })).toBe(false)
|
||||
})
|
||||
|
||||
it('passes the editor, the viewer, and the targeted write to the callback', () => {
|
||||
const calls: CommentModificationContext[] = []
|
||||
const { editor } = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
canModifyComment: (ctx) => {
|
||||
calls.push(ctx)
|
||||
return true
|
||||
},
|
||||
})
|
||||
const comment = makeComment('alice')
|
||||
getCanModifyComment(editor, undefined, { action: 'delete-comment', comment })
|
||||
// An undefined viewer normalizes to null, as it does for `canComment`.
|
||||
expect(calls).toEqual([{ editor, currentUserId: null, action: 'delete-comment', comment }])
|
||||
})
|
||||
|
||||
it("lets a callback widen the default: a moderator deleting anyone's comment", () => {
|
||||
const { editor } = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
canModifyComment: (ctx) =>
|
||||
(ctx.action !== 'edit-comment' && ctx.currentUserId === 'mod') ||
|
||||
defaultCanModifyComment(ctx),
|
||||
})
|
||||
const comment = makeComment('alice')
|
||||
const thread = makeThread('alice')
|
||||
expect(getCanModifyComment(editor, 'mod', { action: 'delete-comment', comment })).toBe(true)
|
||||
expect(getCanModifyComment(editor, 'mod', { action: 'delete-thread', thread })).toBe(true)
|
||||
// Widening deletion doesn't hand the moderator anyone else's edit affordance.
|
||||
expect(getCanModifyComment(editor, 'mod', { action: 'edit-comment', comment })).toBe(false)
|
||||
// The author keeps their own affordances.
|
||||
expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(true)
|
||||
// Everyone else still gets nothing.
|
||||
expect(getCanModifyComment(editor, 'bob', { action: 'delete-comment', comment })).toBe(false)
|
||||
})
|
||||
|
||||
it('lets a callback narrow the default', () => {
|
||||
const { editor } = stubEditor({ ...defaultCommentingOptions, canModifyComment: () => false })
|
||||
const comment = makeComment('alice')
|
||||
expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(false)
|
||||
})
|
||||
|
||||
// A throw withholds the affordance rather than the whole layer, and never offers a write a
|
||||
// server enforcing the same rule would reject anyway.
|
||||
it('denies rather than throws when the callback throws', () => {
|
||||
const onError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { editor } = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
canModifyComment: () => {
|
||||
throw new Error('lookup failed')
|
||||
},
|
||||
})
|
||||
const comment = makeComment('alice')
|
||||
expect(getCanModifyComment(editor, 'alice', { action: 'edit-comment', comment })).toBe(false)
|
||||
expect(onError).toHaveBeenCalled()
|
||||
onError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitCommentMutation', () => {
|
||||
it('uses options.history for a mutation and returns the callback result', () => {
|
||||
const { editor, runCalls } = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
history: 'record',
|
||||
} as CommentingOptions)
|
||||
const result = commitCommentMutation(editor, () => 42)
|
||||
expect(result).toBe(42)
|
||||
expect(runCalls).toEqual([{ history: 'record' }])
|
||||
})
|
||||
|
||||
it('uses dragHistory for a drag, falling back to history when unset', () => {
|
||||
const withDrag = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
history: 'ignore',
|
||||
dragHistory: 'record',
|
||||
} as CommentingOptions)
|
||||
commitCommentMutation(withDrag.editor, () => undefined, 'drag')
|
||||
expect(withDrag.runCalls).toEqual([{ history: 'record' }])
|
||||
|
||||
const noDrag = stubEditor({
|
||||
...defaultCommentingOptions,
|
||||
history: 'ignore',
|
||||
dragHistory: undefined,
|
||||
} as CommentingOptions)
|
||||
commitCommentMutation(noDrag.editor, () => undefined, 'drag')
|
||||
expect(noDrag.runCalls).toEqual([{ history: 'ignore' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('editor-scoped transient state', () => {
|
||||
it('keeps open-thread state independent per editor (multi-editor guard)', () => {
|
||||
const a = {} as Editor
|
||||
const b = {} as Editor
|
||||
openThreadId.set(a, 'thread:1')
|
||||
expect(openThreadId.get(a)).toBe('thread:1')
|
||||
expect(openThreadId.get(b)).toBe(null)
|
||||
})
|
||||
|
||||
it('keeps pending-comment state independent per editor', () => {
|
||||
const a = {} as Editor
|
||||
const b = {} as Editor
|
||||
pendingComment.set(a, { anchor: { type: 'page' }, point: { x: 0, y: 0 } })
|
||||
expect(pendingComment.get(a)).not.toBe(null)
|
||||
expect(pendingComment.get(b)).toBe(null)
|
||||
})
|
||||
})
|
||||
369
packages/commenting/src/canvas/options.ts
Normal file
369
packages/commenting/src/canvas/options.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { useMemo, type ComponentType } from 'react'
|
||||
import {
|
||||
type Editor,
|
||||
type TLComment,
|
||||
type TLCommentThread,
|
||||
type TLHistoryBatchOptions,
|
||||
type TLShapeId,
|
||||
type VecLike,
|
||||
useEditor,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { type CommentListItemRenderProps } from '../ui/comments-list'
|
||||
import { isAllowedReactionEmoji, type EmojiPickerProps } from '../ui/emoji-picker'
|
||||
import { type ReactionTooltipProps } from '../ui/reaction'
|
||||
|
||||
/**
|
||||
* The gesture that's creating a shape anchor, passed to
|
||||
* {@link CommentingOptions.shouldBePrecise}: the target shape, the page point of the release, and
|
||||
* whether Alt was held.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ShapeCommentPrecisionContext {
|
||||
readonly shapeId: TLShapeId
|
||||
readonly point: VecLike
|
||||
readonly altKey: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A commenting write that belongs to someone in particular, and the record it targets — the
|
||||
* argument to {@link CommentingOptions.canModifyComment}.
|
||||
*
|
||||
* Resolving, reopening, reacting, and moving a pin aren't here: none of them is anyone's in
|
||||
* particular, so {@link CommentingOptions.canComment} is the only gate on them.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type CommentModification =
|
||||
| { readonly action: 'edit-comment'; readonly comment: TLComment }
|
||||
| { readonly action: 'delete-comment'; readonly comment: TLComment }
|
||||
| { readonly action: 'delete-thread'; readonly thread: TLCommentThread }
|
||||
|
||||
/**
|
||||
* The argument to {@link CommentingOptions.canModifyComment}: which write, against which record,
|
||||
* and by whom.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type CommentModificationContext = {
|
||||
readonly editor: Editor
|
||||
readonly currentUserId: string | null
|
||||
} & CommentModification
|
||||
|
||||
/**
|
||||
* Component overrides for the batteries-included comments layer. Each slot replaces a built-in
|
||||
* piece; leave a slot unset to keep its default.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface CommentingComponents {
|
||||
/** A comment's body. Replaces the default rich-text `<CommentBody>`. */
|
||||
CommentBody?: ComponentType<{ comment: TLComment }>
|
||||
/** A pin's inner content. Replaces the author-initial default. */
|
||||
PinContent?: ComponentType<{ thread: TLCommentThread; comments: TLComment[] }>
|
||||
/** A sidebar row's preview. Replaces the plaintext default. */
|
||||
ThreadPreview?: ComponentType<{ comment: TLComment }>
|
||||
/**
|
||||
* A whole sidebar row. Replaces the default `<CommentListItem>`, which is exported — so a row
|
||||
* that only adds an unread dot or a status chip can spread these props into it. Use
|
||||
* `ThreadPreview` instead when only the preview text is changing.
|
||||
*/
|
||||
ThreadRow?: ComponentType<CommentListItemRenderProps & { thread: TLCommentThread }>
|
||||
/**
|
||||
* Extra controls in an open thread's header, added ahead of the built-in resolve and dismiss
|
||||
* buttons rather than replacing them. "Copy link" is already built in whenever the host supplies
|
||||
* `getThreadHref`.
|
||||
*/
|
||||
ThreadActions?: ComponentType<{ thread: TLCommentThread; comments: TLComment[] }>
|
||||
/**
|
||||
* A reaction's visual, given its token. The default renders the token string for the OS emoji
|
||||
* font. Override to draw a custom palette — an `<img>`, an SVG, anything. The token is what gets
|
||||
* stored and synced; this only controls how it's drawn.
|
||||
*/
|
||||
ReactionContent?: ComponentType<{ token: string }>
|
||||
/**
|
||||
* What the add-reaction button opens. Replaces the default `<EmojiPicker>` grid. Pairs with
|
||||
* `ReactionContent` (which draws the tokens this emits) and `isAllowedReaction` (which must
|
||||
* accept them).
|
||||
*/
|
||||
ReactionPalette?: ComponentType<EmojiPickerProps>
|
||||
/**
|
||||
* The hover affordance naming who reacted with an emoji. Receives the reactors and the pill (as
|
||||
* `children`) and owns the whole thing — box, size, shape, position. For a wording change,
|
||||
* translate the `comments.reacted-*` strings instead.
|
||||
*/
|
||||
ReactionTooltip?: ComponentType<ReactionTooltipProps>
|
||||
/**
|
||||
* Shown where a composer would sit when the viewer can't compose (see
|
||||
* {@link CommentingOptions.canComment}). `context` is the surface rendering it: an open thread
|
||||
* popover (`'thread'`) or the comment tool's placement popover (`'pending'`). Unset, those
|
||||
* surfaces render nothing.
|
||||
*/
|
||||
ComposerFallback?: ComponentType<{ context: 'pending' | 'thread' }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the commenting layer. Static config only — pass it once via
|
||||
* `CommentTool.configure({ ... })`, mirroring `ShapeUtil.configure`. Live, reactive values
|
||||
* (`currentUserId`, author resolution, read-status callbacks) are the `CommentingContext`, passed as
|
||||
* props to each commenting surface.
|
||||
*
|
||||
* For defaults, see {@link defaultCommentingOptions}.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <Tldraw tools={[CommentTool.configure({ history: 'ignore', enableClustering: false })]} />
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface CommentingOptions {
|
||||
// History / undo
|
||||
/**
|
||||
* How comment mutations interact with the editor undo stack. Defaults to `'ignore'` — comments
|
||||
* are deliberately not undoable (see `TLComment`). `'record'` is a multiplayer footgun: undoing
|
||||
* a delete resurrects a thread a collaborator already removed. Safe only single-player.
|
||||
*/
|
||||
readonly history: TLHistoryBatchOptions['history']
|
||||
/**
|
||||
* History mode for the pin drag-to-move re-anchor specifically. Unlike posts/edits this is a
|
||||
* spatial edit that may reasonably be undoable alongside a shape move. Defaults to `history`.
|
||||
*/
|
||||
readonly dragHistory: TLHistoryBatchOptions['history'] | undefined
|
||||
|
||||
// Feature toggles
|
||||
/** Fold nearby pins into count badges as the camera zooms out. */
|
||||
readonly enableClustering: boolean
|
||||
/**
|
||||
* Whether a user may hold several emoji reactions on one comment. `true` (the default) is the
|
||||
* Slack model: each emoji toggles independently. `false` is single-select: picking a new emoji
|
||||
* replaces the user's existing reaction. Note this is enforced client-side; the server accepts
|
||||
* per-emoji records either way.
|
||||
*/
|
||||
readonly allowMultipleReactions: boolean
|
||||
/**
|
||||
* Whether a token may be added as a reaction. Defaults to {@link isAllowedReactionEmoji}, which
|
||||
* keeps a scripted client from writing junk values the picker would never offer. Override
|
||||
* alongside a custom `ReactionPalette`. Removals aren't checked — an off-palette reaction must
|
||||
* still be clearable.
|
||||
*/
|
||||
isAllowedReaction(token: string): boolean
|
||||
/**
|
||||
* Whether dragging the comment tool out creates a region anchor — a comment attached to a
|
||||
* rectangular area, drawn as a dashed box with the pin on the corner the drag released on. Off
|
||||
* by default, where comments attach to points and shapes only and a drag trails the composer.
|
||||
*/
|
||||
readonly enableRegions: boolean
|
||||
|
||||
// Permissions
|
||||
/**
|
||||
* Whether the viewer may participate in commenting: composing, editing, deleting, resolving, and
|
||||
* moving pins. When false, {@link CommentingComponents.ComposerFallback} renders in the
|
||||
* composer's place and action affordances are hidden. Unset, participation is allowed exactly
|
||||
* when `currentUserId` is set.
|
||||
*
|
||||
* Called during render via {@link useCanComment}, so signal reads are tracked. Posting still
|
||||
* needs a `currentUserId`, so returning true for a signed-out viewer yields a composer whose
|
||||
* send button stays disabled. A callback that throws is logged and read as false, rather than
|
||||
* taking the comments layer down with it.
|
||||
*/
|
||||
readonly canComment:
|
||||
| ((ctx: { editor: Editor; currentUserId: string | null }) => boolean)
|
||||
| undefined
|
||||
/**
|
||||
* Whether the viewer may make a particular write against a particular record: editing or
|
||||
* deleting a comment, or deleting a thread. Unset, each is its record's owner's to make
|
||||
* ({@link defaultCanModifyComment}) — you edit and delete your own comments, and delete threads
|
||||
* you started. Override it to widen that (a workspace admin or moderator who may remove
|
||||
* anyone's comment) or to narrow it (no edits after an hour). Where it returns false the
|
||||
* affordance isn't rendered.
|
||||
*
|
||||
* Checked after {@link CommentingOptions.canComment}, which gates commenting as a whole: a
|
||||
* viewer who may not participate gets no action affordances at all, whatever this returns.
|
||||
*
|
||||
* Called during render via {@link useCanModifyComment}, so reactive reads (signals) are tracked.
|
||||
* A callback that throws is logged and read as false: an affordance is withheld rather than the
|
||||
* comments layer lost, and a denial is what a server enforcing the same rule would have said.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* CommentTool.configure({
|
||||
* canModifyComment: (ctx) =>
|
||||
* // Moderators may delete anything; everything else stays the owner's to do.
|
||||
* (ctx.action !== 'edit-comment' && isModerator(ctx.currentUserId)) ||
|
||||
* defaultCanModifyComment(ctx),
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
readonly canModifyComment: ((ctx: CommentModificationContext) => boolean) | undefined
|
||||
|
||||
// Anchoring
|
||||
/** Normalized (0–1) spot within a shape where imprecise shape pins sit. Default top-right. */
|
||||
readonly impreciseShapeAnchor: { readonly x: number; readonly y: number }
|
||||
/**
|
||||
* Whether a comment landing on a shape pins to the exact clicked spot, or to the shape as a
|
||||
* whole (rendered at `impreciseShapeAnchor`). Always precise by default; return `false`, or
|
||||
* decide from the context. Governs new placements only — existing anchors render as stored.
|
||||
*/
|
||||
shouldBePrecise(editor: Editor, context: ShapeCommentPrecisionContext): boolean
|
||||
|
||||
// Components
|
||||
/** Component overrides. See {@link CommentingComponents}. */
|
||||
readonly components: CommentingComponents
|
||||
}
|
||||
|
||||
/**
|
||||
* The default {@link CommentingOptions}. Override via `CommentTool.configure({ ... })`.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const defaultCommentingOptions = {
|
||||
history: 'ignore',
|
||||
dragHistory: undefined,
|
||||
enableClustering: true,
|
||||
allowMultipleReactions: true,
|
||||
isAllowedReaction: isAllowedReactionEmoji,
|
||||
enableRegions: false,
|
||||
canComment: undefined,
|
||||
canModifyComment: undefined,
|
||||
impreciseShapeAnchor: { x: 1, y: 0 },
|
||||
shouldBePrecise: () => true,
|
||||
components: {},
|
||||
} as const satisfies CommentingOptions
|
||||
|
||||
/**
|
||||
* The merged {@link CommentingOptions} for an editor, read off the registered comment tool (which
|
||||
* carries them via `CommentTool.configure`). Falls back to {@link defaultCommentingOptions} when
|
||||
* the comment tool isn't registered. Usable from anywhere with an `Editor` — including the tool's
|
||||
* own state, which has no React context.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function getCommentingOptions(editor: Editor): CommentingOptions {
|
||||
const tool = editor.getStateDescendant('comment') as { options?: CommentingOptions } | undefined
|
||||
return tool?.options ?? defaultCommentingOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for {@link getCommentingOptions}. Options are fixed per editor (set at tool
|
||||
* registration), so this doesn't need to be reactive.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useCommentingOptions(): CommentingOptions {
|
||||
const editor = useEditor()
|
||||
return useMemo(() => getCommentingOptions(editor), [editor])
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask a host's permission callback, denying the write if it throws.
|
||||
*
|
||||
* These are called during render, so an exception in one would take the comments layer down with
|
||||
* the answer. Denying costs an affordance, which is what a `false` would have cost anyway, and it
|
||||
* can't offer a write a server enforcing the same rule would then reject.
|
||||
*/
|
||||
function permits(option: string, check: () => boolean): boolean {
|
||||
try {
|
||||
return check()
|
||||
} catch (error) {
|
||||
console.error(`[tldraw] \`${option}\` threw, so the write is denied:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the viewer may participate in commenting, per {@link CommentingOptions.canComment}
|
||||
* (defaulting to `currentUserId != null` when unset). A callback that throws denies.
|
||||
*
|
||||
* This is a plain, untracked read — in React, use {@link useCanComment} instead.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function getCanComment(editor: Editor, currentUserId: string | null | undefined): boolean {
|
||||
const { canComment } = getCommentingOptions(editor)
|
||||
if (!canComment) return currentUserId != null
|
||||
return permits('canComment', () => canComment({ editor, currentUserId: currentUserId ?? null }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive React hook for {@link getCanComment}: a `canComment` callback that reads signals
|
||||
* re-evaluates when they change.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useCanComment(currentUserId: string | null | undefined): boolean {
|
||||
const editor = useEditor()
|
||||
return useValue('can comment', () => getCanComment(editor, currentUserId), [
|
||||
editor,
|
||||
currentUserId,
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* The default {@link CommentingOptions.canModifyComment}: a write is its record's owner's to make —
|
||||
* a comment's author edits and deletes it, a thread's creator deletes the thread — and a viewer
|
||||
* with no identity may make none of them.
|
||||
*
|
||||
* Exported so a callback can widen the default rather than restate it:
|
||||
* `(ctx) => isModerator(ctx.currentUserId) || defaultCanModifyComment(ctx)`.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function defaultCanModifyComment(ctx: CommentModificationContext): boolean {
|
||||
const { currentUserId } = ctx
|
||||
if (!currentUserId) return false
|
||||
const owner = ctx.action === 'delete-thread' ? ctx.thread.createdBy : ctx.comment.authorId
|
||||
return owner === currentUserId
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the viewer may make a given write against a given record, per
|
||||
* {@link CommentingOptions.canModifyComment} (defaulting to {@link defaultCanModifyComment} when
|
||||
* unset). Where this is false the affordance isn't rendered.
|
||||
*
|
||||
* This is the per-record rule alone: the built-in UI additionally requires
|
||||
* {@link CommentingOptions.canComment}, since a viewer who may not participate gets no action
|
||||
* affordances at all.
|
||||
*
|
||||
* A plain, untracked read — a `canModifyComment` callback that reads signals is not observed. In
|
||||
* React, use {@link useCanModifyComment} instead.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function getCanModifyComment(
|
||||
editor: Editor,
|
||||
currentUserId: string | null | undefined,
|
||||
modification: CommentModification
|
||||
): boolean {
|
||||
const { canModifyComment } = getCommentingOptions(editor)
|
||||
const ctx: CommentModificationContext = {
|
||||
editor,
|
||||
currentUserId: currentUserId ?? null,
|
||||
...modification,
|
||||
}
|
||||
if (!canModifyComment) return defaultCanModifyComment(ctx)
|
||||
return permits('canModifyComment', () => canModifyComment(ctx))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive React hook for {@link getCanModifyComment}: a `canModifyComment` callback that reads
|
||||
* signals re-evaluates when they change.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useCanModifyComment(
|
||||
currentUserId: string | null | undefined,
|
||||
modification: CommentModification
|
||||
): boolean {
|
||||
const editor = useEditor()
|
||||
// Comment records are immutable, so the record itself is what changes when the thing being
|
||||
// checked changes — `modification` is a fresh object on every render and can't be a dep.
|
||||
const record =
|
||||
modification.action === 'delete-thread' ? modification.thread : modification.comment
|
||||
return useValue(
|
||||
'can modify comment',
|
||||
() => getCanModifyComment(editor, currentUserId, modification),
|
||||
[editor, currentUserId, modification.action, record]
|
||||
)
|
||||
}
|
||||
161
packages/commenting/src/canvas/pending-composer.tsx
Normal file
161
packages/commenting/src/canvas/pending-composer.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Avatar, isMentionPickerOpen } from '@tldraw/mentions'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
createComment,
|
||||
createCommentThread,
|
||||
Editor,
|
||||
EditorPortal,
|
||||
TLRichText,
|
||||
usePassThroughWheelEvents,
|
||||
useTranslation,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { CommentComposer } from '../ui/comment-composer'
|
||||
import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
|
||||
import { CommentPin } from '../ui/comment-pin'
|
||||
import {
|
||||
clearCommentDraft,
|
||||
getCommentDraft,
|
||||
NEW_COMMENT_DRAFT,
|
||||
saveCommentDraft,
|
||||
} from './comment-drafts'
|
||||
import { commitCommentMutation } from './comment-mutations'
|
||||
import { UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
||||
import { PendingComment } from './comment-tool'
|
||||
import { type CommentingContext } from './context'
|
||||
import { useIsMobileCommenting, useMobilePlacement } from './mobile-placement'
|
||||
import { useCanComment, useCommentingOptions } from './options'
|
||||
import { pendingComment } from './state'
|
||||
|
||||
const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
|
||||
|
||||
/**
|
||||
* The composer for a thread that doesn't exist yet: the comment tool has placed a point (or
|
||||
* region) and is waiting on a first comment. Submitting creates the thread and its comment
|
||||
* together; clicking away keeps the draft for the next placement.
|
||||
*/
|
||||
export function PendingComposer({
|
||||
editor,
|
||||
pending,
|
||||
currentUserId,
|
||||
resolveAuthor,
|
||||
onPostComment,
|
||||
getMentionSuggestions,
|
||||
renderMentionSuggestion,
|
||||
}: CommentingContext & { editor: Editor; pending: PendingComment }) {
|
||||
const ComposerFallback = useCommentingOptions().components.ComposerFallback
|
||||
const canComment = useCanComment(currentUserId)
|
||||
const me = currentUserId ? resolveAuthor(currentUserId) : undefined
|
||||
// The leading pin previews the pin this draft becomes: a white pin holding the author's avatar.
|
||||
const draftAvatar = (
|
||||
<CommentPin>
|
||||
<Avatar author={me ?? UNKNOWN_COMMENT_AUTHOR} />
|
||||
</CommentPin>
|
||||
)
|
||||
// Click-away keeps the draft (saved on every change) and the next placement composer
|
||||
// restores it — the flip side of dismissing without a discard warning.
|
||||
const [text, setText] = useState<TLRichText>(
|
||||
() => getCommentDraft(NEW_COMMENT_DRAFT) ?? EMPTY_COMMENT
|
||||
)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const msg = useTranslation()
|
||||
// Over this floating panel, a scroll reaches the canvas (except where it scrolls itself).
|
||||
usePassThroughWheelEvents(ref)
|
||||
|
||||
const point = useValue('composer point', () => editor.pageToViewport(pending.point), [
|
||||
editor,
|
||||
pending.point,
|
||||
])
|
||||
// On mobile the composer floats free of the pin so it can clear the software keyboard; desktop
|
||||
// keeps it pinned to the point.
|
||||
const isMobile = useIsMobileCommenting()
|
||||
const placed = useMobilePlacement(ref, point, isMobile)
|
||||
|
||||
// Dismiss on a click anywhere outside the composer (capture-phase, ahead of stopPropagation).
|
||||
useEffect(() => {
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
const el = ref.current
|
||||
const target = e.target as HTMLElement | null
|
||||
if (!el || !target) return
|
||||
// A click in the composer, or in the mention picker it spawns (portaled elsewhere), is
|
||||
// not "outside" — keep the draft open so the pick can insert.
|
||||
if (el.contains(target) || target.closest('.tlui-cmt-mention-popup')) return
|
||||
pendingComment.set(editor, null)
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true)
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
||||
}, [editor])
|
||||
|
||||
const submit = () => {
|
||||
if (isCommentEmpty(text) || !currentUserId) return
|
||||
const comment = commitCommentMutation(editor, ({ put }) => {
|
||||
const pageId = editor.getCurrentPageId()
|
||||
const thread = createCommentThread({
|
||||
pageId,
|
||||
anchor: pending.anchor,
|
||||
createdBy: currentUserId,
|
||||
})
|
||||
const comment = createComment({
|
||||
threadId: thread.id,
|
||||
pageId,
|
||||
authorId: currentUserId,
|
||||
body: text,
|
||||
})
|
||||
put([thread, comment])
|
||||
return comment
|
||||
})
|
||||
setText(EMPTY_COMMENT)
|
||||
clearCommentDraft(NEW_COMMENT_DRAFT)
|
||||
pendingComment.set(editor, null)
|
||||
// Posting ends the placement interaction — hand the comment tool back to select. Only from
|
||||
// the settled idle state: Enter can land while a fresh press is mid-gesture (the composer
|
||||
// trails the pointer), and switching tools under a held pointer would strand the gesture.
|
||||
if (editor.isIn('comment.idle')) editor.setCurrentTool('select')
|
||||
// The host's callback is its own operation, not part of the post's history scope. It runs
|
||||
// last so a throwing host can't strand the composer holding a draft of a posted comment.
|
||||
onPostComment?.(comment)
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorPortal>
|
||||
<div
|
||||
ref={ref}
|
||||
className={[
|
||||
'tlui-cmt-canvas-composer',
|
||||
pending.anchor.type === 'region' && 'tlui-cmt-canvas-composer--region',
|
||||
!canComment && 'tlui-cmt-canvas-composer--fallback',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{ left: placed.left, top: placed.top }}
|
||||
onPointerDown={stop}
|
||||
onContextMenu={stop}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && !isMentionPickerOpen()) pendingComment.set(editor, null)
|
||||
}}
|
||||
>
|
||||
{canComment ? (
|
||||
<CommentComposer
|
||||
author={me ?? UNKNOWN_COMMENT_AUTHOR}
|
||||
placeholder={msg('comments.add-placeholder')}
|
||||
sendLabel={msg('comments.send')}
|
||||
value={text}
|
||||
onChange={(value) => {
|
||||
setText(value)
|
||||
saveCommentDraft(NEW_COMMENT_DRAFT, value)
|
||||
}}
|
||||
onSubmit={submit}
|
||||
// No user, no author for the record — dead send button.
|
||||
disabled={isCommentEmpty(text) || !currentUserId}
|
||||
getMentionSuggestions={getMentionSuggestions}
|
||||
renderMentionSuggestion={renderMentionSuggestion}
|
||||
autoFocus
|
||||
leading={draftAvatar}
|
||||
/>
|
||||
) : (
|
||||
ComposerFallback && <ComposerFallback context="pending" />
|
||||
)}
|
||||
</div>
|
||||
</EditorPortal>
|
||||
)
|
||||
}
|
||||
224
packages/commenting/src/canvas/pin-stacking.test.ts
Normal file
224
packages/commenting/src/canvas/pin-stacking.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { Box, Mat, type Editor, type TLCommentAnchor, type TLCommentThread } from 'tldraw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computePinStacks, isOpenStackKeyLive, pinStackKey, pinStacksEqual } from './pin-stacking'
|
||||
import { anchorPagePoint } from './thread-state'
|
||||
|
||||
const CURRENT_PAGE = 'page:one'
|
||||
const OTHER_PAGE = 'page:two'
|
||||
|
||||
function thread(
|
||||
id: string,
|
||||
anchor: TLCommentAnchor,
|
||||
opts: { pageId?: string; createdAt?: number } = {}
|
||||
): TLCommentThread {
|
||||
return {
|
||||
id,
|
||||
typeName: 'comment-thread',
|
||||
pageId: opts.pageId ?? CURRENT_PAGE,
|
||||
anchor,
|
||||
createdBy: 'user:1',
|
||||
createdAt: opts.createdAt ?? 0,
|
||||
resolved: null,
|
||||
meta: {},
|
||||
} as unknown as TLCommentThread
|
||||
}
|
||||
|
||||
/**
|
||||
* Unrotated shapes: local geometry sized by the box, placed by a translate-only page transform.
|
||||
* No comment tool registered, so the commenting options `anchorPagePoint` reads are the defaults.
|
||||
*/
|
||||
function stubEditor(
|
||||
shapes: Record<string, { minX: number; minY: number; maxX: number; maxY: number }> = {}
|
||||
): Editor {
|
||||
const shapeId = (shape: string | { id: string }) => (typeof shape === 'string' ? shape : shape.id)
|
||||
return {
|
||||
getCurrentPageId: () => CURRENT_PAGE,
|
||||
getStateDescendant: () => undefined,
|
||||
getShape: (id: string) => (shapes[id] ? { id } : undefined),
|
||||
getShapeGeometry: (shape: string | { id: string }) => {
|
||||
const bounds = shapes[shapeId(shape)]
|
||||
return { bounds: new Box(0, 0, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY) }
|
||||
},
|
||||
getShapePageTransform: (shape: string | { id: string }) => {
|
||||
const bounds = shapes[shapeId(shape)]
|
||||
return bounds ? Mat.Translate(bounds.minX, bounds.minY) : undefined
|
||||
},
|
||||
} as unknown as Editor
|
||||
}
|
||||
|
||||
const SHAPE = { 'shape:a': { minX: 0, minY: 0, maxX: 200, maxY: 100 } }
|
||||
/** The same shape after someone moved it — same size, different page position. */
|
||||
const SHAPE_MOVED = { 'shape:a': { minX: 500, minY: 300, maxX: 700, maxY: 400 } }
|
||||
|
||||
function impreciseAnchor(shapeId: string): TLCommentAnchor {
|
||||
return { type: 'shape', shapeId, x: 0.2, y: 0.9, isPrecise: false } as TLCommentAnchor
|
||||
}
|
||||
|
||||
/** The stack key the pins on `shape:a` coincide at, for the given shape placement. */
|
||||
function stackKeyOn(shapes: typeof SHAPE): string {
|
||||
return pinStackKey(anchorPagePoint(stubEditor(shapes), impreciseAnchor('shape:a'))!)
|
||||
}
|
||||
|
||||
describe('computePinStacks', () => {
|
||||
it('groups coincident imprecise pins on one shape, oldest first', () => {
|
||||
const stacks = computePinStacks(stubEditor(SHAPE), [
|
||||
thread('t2', impreciseAnchor('shape:a'), { createdAt: 20 }),
|
||||
thread('t1', impreciseAnchor('shape:a'), { createdAt: 10 }),
|
||||
])
|
||||
expect(stacks.get('t1')).toEqual(['t1', 't2'])
|
||||
expect(stacks.get('t2')).toEqual(['t1', 't2'])
|
||||
})
|
||||
|
||||
it('leaves separated pins ungrouped', () => {
|
||||
const stacks = computePinStacks(stubEditor(), [
|
||||
thread('t1', { type: 'point', x: 0, y: 0 }),
|
||||
thread('t2', { type: 'point', x: 50, y: 0 }),
|
||||
])
|
||||
expect(stacks.size).toBe(0)
|
||||
})
|
||||
|
||||
it('groups coincident point anchors', () => {
|
||||
const stacks = computePinStacks(stubEditor(), [
|
||||
thread('t1', { type: 'point', x: 5, y: 5 }, { createdAt: 1 }),
|
||||
thread('t2', { type: 'point', x: 5, y: 5 }, { createdAt: 2 }),
|
||||
thread('t3', { type: 'point', x: 5, y: 5 }, { createdAt: 3 }),
|
||||
])
|
||||
expect(stacks.get('t2')).toEqual(['t1', 't2', 't3'])
|
||||
})
|
||||
|
||||
it('breaks creation-time ties by id so ordering is deterministic', () => {
|
||||
const stacks = computePinStacks(stubEditor(), [
|
||||
thread('t2', { type: 'point', x: 5, y: 5 }, { createdAt: 1 }),
|
||||
thread('t1', { type: 'point', x: 5, y: 5 }, { createdAt: 1 }),
|
||||
])
|
||||
expect(stacks.get('t1')).toEqual(['t1', 't2'])
|
||||
})
|
||||
|
||||
it('ignores threads on other pages and unresolvable anchors', () => {
|
||||
const stacks = computePinStacks(stubEditor(SHAPE), [
|
||||
thread('t1', impreciseAnchor('shape:a')),
|
||||
thread('t2', impreciseAnchor('shape:a'), { pageId: OTHER_PAGE }),
|
||||
thread('t3', impreciseAnchor('shape:gone')),
|
||||
])
|
||||
expect(stacks.size).toBe(0)
|
||||
})
|
||||
|
||||
it('does not group a precise pin sitting away from a coincident pair', () => {
|
||||
const precise: TLCommentAnchor = {
|
||||
type: 'shape',
|
||||
shapeId: 'shape:a',
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
isPrecise: true,
|
||||
} as TLCommentAnchor
|
||||
const stacks = computePinStacks(stubEditor(SHAPE), [
|
||||
thread('t1', impreciseAnchor('shape:a'), { createdAt: 1 }),
|
||||
thread('t2', impreciseAnchor('shape:a'), { createdAt: 2 }),
|
||||
thread('t3', precise),
|
||||
])
|
||||
expect(stacks.get('t1')).toEqual(['t1', 't2'])
|
||||
expect(stacks.has('t3')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pinStacksEqual', () => {
|
||||
const pair = () =>
|
||||
new Map<string, readonly string[]>([
|
||||
['t1', ['t1', 't2']],
|
||||
['t2', ['t1', 't2']],
|
||||
])
|
||||
|
||||
it('accepts equal groupings whatever the map or group identity', () => {
|
||||
const a = pair()
|
||||
expect(pinStacksEqual(a, a)).toBe(true)
|
||||
expect(pinStacksEqual(a, pair())).toBe(true)
|
||||
expect(pinStacksEqual(new Map(), new Map())).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a changed member set, a changed group, and a changed order', () => {
|
||||
// the stack broke up
|
||||
expect(pinStacksEqual(pair(), new Map())).toBe(false)
|
||||
// a third pin joined
|
||||
expect(
|
||||
pinStacksEqual(
|
||||
pair(),
|
||||
new Map<string, readonly string[]>([
|
||||
['t1', ['t1', 't2', 't3']],
|
||||
['t2', ['t1', 't2', 't3']],
|
||||
['t3', ['t1', 't2', 't3']],
|
||||
])
|
||||
)
|
||||
).toBe(false)
|
||||
// same size, different members
|
||||
expect(
|
||||
pinStacksEqual(
|
||||
pair(),
|
||||
new Map<string, readonly string[]>([
|
||||
['t1', ['t1', 't3']],
|
||||
['t3', ['t1', 't3']],
|
||||
])
|
||||
)
|
||||
).toBe(false)
|
||||
// same members, reordered within the group (the list renders in this order)
|
||||
expect(
|
||||
pinStacksEqual(
|
||||
pair(),
|
||||
new Map<string, readonly string[]>([
|
||||
['t1', ['t2', 't1']],
|
||||
['t2', ['t2', 't1']],
|
||||
])
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('is blind to position — equal groupings can sit at different page points', () => {
|
||||
// The overlay holds the map's identity while this is true, which is why nothing keyed on a
|
||||
// stack's *point* may key off it. See isOpenStackKeyLive.
|
||||
const threads = [
|
||||
thread('t1', impreciseAnchor('shape:a'), { createdAt: 1 }),
|
||||
thread('t2', impreciseAnchor('shape:a'), { createdAt: 2 }),
|
||||
]
|
||||
const here = computePinStacks(stubEditor(SHAPE), threads)
|
||||
const moved = computePinStacks(stubEditor(SHAPE_MOVED), threads)
|
||||
expect(pinStacksEqual(here, moved)).toBe(true)
|
||||
expect(stackKeyOn(SHAPE)).not.toBe(stackKeyOn(SHAPE_MOVED))
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOpenStackKeyLive', () => {
|
||||
const threads = [
|
||||
thread('t1', impreciseAnchor('shape:a'), { createdAt: 1 }),
|
||||
thread('t2', impreciseAnchor('shape:a'), { createdAt: 2 }),
|
||||
]
|
||||
const byId = new Map(threads.map((t) => [t.id, t]))
|
||||
|
||||
it('is live while a stack still sits at the key', () => {
|
||||
const editor = stubEditor(SHAPE)
|
||||
expect(
|
||||
isOpenStackKeyLive(editor, stackKeyOn(SHAPE), computePinStacks(editor, threads), byId)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('goes stale when the stack moves — the case pinStacksEqual cannot see', () => {
|
||||
// The anchor shape moved, so the point key changed while the membership didn't. Staying live
|
||||
// here strands the old key in `openStackId`, killing every hover preview on the layer.
|
||||
const editor = stubEditor(SHAPE_MOVED)
|
||||
expect(
|
||||
isOpenStackKeyLive(editor, stackKeyOn(SHAPE), computePinStacks(editor, threads), byId)
|
||||
).toBe(false)
|
||||
// and the key the stack moved *to* is live, so the caller clears rather than thrashing
|
||||
expect(
|
||||
isOpenStackKeyLive(editor, stackKeyOn(SHAPE_MOVED), computePinStacks(editor, threads), byId)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('goes stale once nothing stacks there, or the threads are gone', () => {
|
||||
const editor = stubEditor(SHAPE)
|
||||
// the stack collapsed to a single pin: no entries at all
|
||||
expect(isOpenStackKeyLive(editor, stackKeyOn(SHAPE), new Map(), byId)).toBe(false)
|
||||
// entries for threads that have left the input
|
||||
expect(
|
||||
isOpenStackKeyLive(editor, stackKeyOn(SHAPE), computePinStacks(editor, threads), new Map())
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
103
packages/commenting/src/canvas/pin-stacking.ts
Normal file
103
packages/commenting/src/canvas/pin-stacking.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { Editor, TLCommentThread } from 'tldraw'
|
||||
import { anchorPagePoint } from './thread-state'
|
||||
|
||||
/** Two anchors within this page-space distance (per axis) share a stack. Identical imprecise
|
||||
* anchors on one shape resolve to the same point exactly; the tolerance only absorbs float noise. */
|
||||
const PIN_STACK_QUANTUM = 0.1
|
||||
|
||||
/**
|
||||
* The key a page point stacks under. Threads sharing a key are coincident. Stable identity for a
|
||||
* stack itself: it survives losing any member (the survivors keep the same key), unlike keying by
|
||||
* a particular thread id, so open-stack state stays put when the stack's oldest thread is deleted.
|
||||
*/
|
||||
export function pinStackKey(point: { x: number; y: number }): string {
|
||||
return `${Math.round(point.x / PIN_STACK_QUANTUM)}:${Math.round(point.y / PIN_STACK_QUANTUM)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Group threads whose pins land on the same page point — coincident pins (typically several
|
||||
* imprecise comments on one shape) that zooming can never separate. The overlay renders each
|
||||
* group as a single count-badge pin that opens the threads as a list. Every member id maps to
|
||||
* its group's ordered member ids (oldest first); threads without an entry pin individually.
|
||||
*
|
||||
* Keyed by page-space anchor point, not screen position, so the result only changes when threads
|
||||
* or their anchors move — never on camera moves.
|
||||
* @internal
|
||||
*/
|
||||
export function computePinStacks(
|
||||
editor: Editor,
|
||||
threads: readonly TLCommentThread[]
|
||||
): Map<string, readonly string[]> {
|
||||
const pageId = editor.getCurrentPageId()
|
||||
const groups = new Map<string, TLCommentThread[]>()
|
||||
|
||||
for (const thread of threads) {
|
||||
if (thread.pageId !== pageId) continue
|
||||
const point = anchorPagePoint(editor, thread.anchor)
|
||||
if (!point) continue
|
||||
const key = pinStackKey(point)
|
||||
const group = groups.get(key)
|
||||
if (group) {
|
||||
group.push(thread)
|
||||
} else {
|
||||
groups.set(key, [thread])
|
||||
}
|
||||
}
|
||||
|
||||
const stacks = new Map<string, readonly string[]>()
|
||||
for (const group of groups.values()) {
|
||||
if (group.length < 2) continue
|
||||
group.sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1))
|
||||
const ids = group.map((thread) => thread.id)
|
||||
for (const id of ids) {
|
||||
stacks.set(id, ids)
|
||||
}
|
||||
}
|
||||
return stacks
|
||||
}
|
||||
|
||||
/**
|
||||
* Value equality for pin-stack maps, so the overlay can hold the map's identity across a recompute
|
||||
* that changed no grouping. Membership only: two equal maps can describe stacks at different page
|
||||
* points, so anything keyed on a stack's *point* must read the anchors itself, as
|
||||
* {@link isOpenStackKeyLive} does.
|
||||
* @internal
|
||||
*/
|
||||
export function pinStacksEqual(
|
||||
a: ReadonlyMap<string, readonly string[]>,
|
||||
b: ReadonlyMap<string, readonly string[]>
|
||||
): boolean {
|
||||
if (a === b) return true
|
||||
if (a.size !== b.size) return false
|
||||
for (const [id, group] of b) {
|
||||
const prevGroup = a.get(id)
|
||||
if (!prevGroup) return false
|
||||
if (prevGroup === group) continue
|
||||
if (prevGroup.length !== group.length) return false
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
if (prevGroup[i] !== group[i]) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an open-stack key still names a stack on the canvas — false once that stack is emptied,
|
||||
* moved, or off the page, at which point the caller must clear the key. Reads the anchors, so a
|
||||
* reactive caller re-evaluates on a move that {@link pinStacksEqual} can't see.
|
||||
* @internal
|
||||
*/
|
||||
export function isOpenStackKeyLive(
|
||||
editor: Editor,
|
||||
key: string,
|
||||
stacks: ReadonlyMap<string, readonly string[]>,
|
||||
threadsById: ReadonlyMap<string, TLCommentThread>
|
||||
): boolean {
|
||||
for (const id of stacks.keys()) {
|
||||
const thread = threadsById.get(id)
|
||||
if (!thread) continue
|
||||
const point = anchorPagePoint(editor, thread.anchor)
|
||||
if (point && pinStackKey(point) === key) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
124
packages/commenting/src/canvas/region-box.tsx
Normal file
124
packages/commenting/src/canvas/region-box.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { type PointerEvent as ReactPointerEvent, useRef } from 'react'
|
||||
import { type BoxModel, Editor, useValue, VecLike } from 'tldraw'
|
||||
import { regionDraft } from './state'
|
||||
|
||||
/** A region's dashed box. Purely visual — a region moves by its pin and resizes from its corner
|
||||
* handles, so the box itself takes no pointer events. Positioned in viewport space, as a sibling
|
||||
* of the pins in the canvas layer. */
|
||||
export function RegionBox({ editor, box }: { editor: Editor; box: BoxModel }) {
|
||||
const rect = useValue(
|
||||
'region rect',
|
||||
() => {
|
||||
// Position from the page→viewport top-left; screen size scales with zoom, page size doesn't.
|
||||
const topLeft = editor.pageToViewport({ x: box.x, y: box.y })
|
||||
const zoom = editor.getZoomLevel()
|
||||
return { left: topLeft.x, top: topLeft.y, width: box.w * zoom, height: box.h * zoom }
|
||||
},
|
||||
[editor, box.x, box.y, box.w, box.h]
|
||||
)
|
||||
return <div className="tlui-cmt-canvas-region" style={rect} />
|
||||
}
|
||||
|
||||
/** The live region being dragged out by the comment tool, or nothing when not dragging. */
|
||||
export function RegionDraftBox({ editor }: { editor: Editor }) {
|
||||
const box = useValue('region draft', () => regionDraft.get(editor), [editor])
|
||||
if (!box) return null
|
||||
return <RegionBox editor={editor} box={box} />
|
||||
}
|
||||
|
||||
// A resize handle's normalized 0–1 spot on the box, and its cursor. An axis at 0.5 is *not*
|
||||
// controlled by that handle — the resize math reads the spot rather than special-casing corners.
|
||||
export interface RegionHandle {
|
||||
x: number
|
||||
y: number
|
||||
cursor: string
|
||||
}
|
||||
|
||||
// The four corners, each resizing both axes.
|
||||
export const REGION_CORNERS: readonly RegionHandle[] = [
|
||||
{ x: 0, y: 0, cursor: 'nwse-resize' },
|
||||
{ x: 1, y: 0, cursor: 'nesw-resize' },
|
||||
{ x: 0, y: 1, cursor: 'nesw-resize' },
|
||||
{ x: 1, y: 1, cursor: 'nwse-resize' },
|
||||
]
|
||||
|
||||
// Screen-space slack around a region's bounds within which its box and handles stay revealed, so
|
||||
// the handles (which sit on the edge) are comfortably reachable.
|
||||
export const REGION_HANDLE_MARGIN_PX = 12
|
||||
|
||||
/** Resize `box` by dragging `handle` to `cursor` (page coords). Each controlled axis spans from the
|
||||
* handle's fixed opposite edge to the cursor (normalized, so dragging past it flips); an axis the
|
||||
* handle doesn't control (a midpoint, at 0.5) keeps its original position and size. */
|
||||
function resizeRegion(box: BoxModel, handle: RegionHandle, cursor: VecLike): BoxModel {
|
||||
const controlsX = handle.x !== 0.5
|
||||
const controlsY = handle.y !== 0.5
|
||||
const fixedX = box.x + (1 - handle.x) * box.w
|
||||
const fixedY = box.y + (1 - handle.y) * box.h
|
||||
return {
|
||||
x: controlsX ? Math.min(fixedX, cursor.x) : box.x,
|
||||
y: controlsY ? Math.min(fixedY, cursor.y) : box.y,
|
||||
w: controlsX ? Math.abs(cursor.x - fixedX) : box.w,
|
||||
h: controlsY ? Math.abs(cursor.y - fixedY) : box.h,
|
||||
}
|
||||
}
|
||||
|
||||
/** Draggable handles that resize a region — corners (both axes) or edges (one axis), per the resize
|
||||
* option. Previews live, commits on release. */
|
||||
export function RegionResizeHandles({
|
||||
editor,
|
||||
box,
|
||||
handles,
|
||||
onPreview,
|
||||
onCommit,
|
||||
}: {
|
||||
editor: Editor
|
||||
box: BoxModel
|
||||
handles: readonly RegionHandle[]
|
||||
onPreview(bounds: BoxModel | null): void
|
||||
onCommit(bounds: BoxModel): void
|
||||
}) {
|
||||
// The box at pointer-down, captured so the box prop reflowing under the live preview doesn't move
|
||||
// the fixed edges mid-drag.
|
||||
const boxRef = useRef<BoxModel | null>(null)
|
||||
const points = useValue(
|
||||
'region handle points',
|
||||
() =>
|
||||
handles.map((h) => {
|
||||
const p = editor.pageToViewport({ x: box.x + h.x * box.w, y: box.y + h.y * box.h })
|
||||
return { ...h, key: `${h.x}-${h.y}`, left: p.x, top: p.y }
|
||||
}),
|
||||
[editor, box.x, box.y, box.w, box.h, handles]
|
||||
)
|
||||
const startResize = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation()
|
||||
boxRef.current = box
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}
|
||||
const resizedTo = (h: RegionHandle, e: ReactPointerEvent<HTMLDivElement>): BoxModel =>
|
||||
resizeRegion(boxRef.current!, h, editor.screenToPage({ x: e.clientX, y: e.clientY }))
|
||||
const onResize = (h: RegionHandle) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (boxRef.current) onPreview(resizedTo(h, e))
|
||||
}
|
||||
const endResize = (h: RegionHandle) => (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!boxRef.current) return
|
||||
const bounds = resizedTo(h, e)
|
||||
boxRef.current = null
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId))
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
onCommit(bounds)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{points.map((h) => (
|
||||
<div
|
||||
key={h.key}
|
||||
className="tlui-cmt-canvas-region-handle"
|
||||
style={{ left: h.left, top: h.top, cursor: h.cursor }}
|
||||
onPointerDown={startResize}
|
||||
onPointerMove={onResize(h)}
|
||||
onPointerUp={endResize(h)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
15
packages/commenting/src/canvas/rich-text.ts
Normal file
15
packages/commenting/src/canvas/rich-text.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { TLRichText } from 'tldraw'
|
||||
import { renderCommentPlaintext } from './comment-render'
|
||||
|
||||
/**
|
||||
* Flatten a rich-text comment body to plaintext through the limited comment extension set,
|
||||
* separating paragraphs and list items with newlines. A convenience for consumers rendering bodies
|
||||
* as plain text (e.g. the sidebar preview); richer rendering can read the `TLRichText` directly.
|
||||
* @public
|
||||
*/
|
||||
export function richTextToPlaintext(
|
||||
body: TLRichText,
|
||||
resolveName?: (id: string) => string | undefined
|
||||
): string {
|
||||
return renderCommentPlaintext(body, resolveName)
|
||||
}
|
||||
26
packages/commenting/src/canvas/sidebar-filters.ts
Normal file
26
packages/commenting/src/canvas/sidebar-filters.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Which threads the comments sidebar shows. Held as an editor-scoped signal (the `sidebarFilters`
|
||||
* `EditorAtom` in `./state`) rather than component state so it survives the sidebar
|
||||
* unmounting when the comment tool deactivates — a user's "hide resolved" choice shouldn't reset
|
||||
* every time they leave the tool.
|
||||
* @public
|
||||
*/
|
||||
export interface SidebarFilters {
|
||||
/** Include resolved threads. */
|
||||
showResolved: boolean
|
||||
/** Only threads the current user started. Ignored when there's no current user. */
|
||||
onlyMine: boolean
|
||||
/** Only threads with unread comments. Ignored when the host provides no read status. */
|
||||
onlyUnread: boolean
|
||||
/** Only threads on the current page. Off = every page's threads, each labelled. */
|
||||
onlyCurrentPage: boolean
|
||||
}
|
||||
|
||||
/** The out-of-the-box view: every page's threads, resolved ones hidden until asked for.
|
||||
* @public */
|
||||
export const DEFAULT_SIDEBAR_FILTERS: SidebarFilters = {
|
||||
showResolved: false,
|
||||
onlyMine: false,
|
||||
onlyUnread: false,
|
||||
onlyCurrentPage: false,
|
||||
}
|
||||
151
packages/commenting/src/canvas/state.ts
Normal file
151
packages/commenting/src/canvas/state.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { EditorAtom, type BoxModel, type Editor, useEditor, useValue } from 'tldraw'
|
||||
import type { PendingComment } from './comment-tool'
|
||||
import { DEFAULT_SIDEBAR_FILTERS, type SidebarFilters } from './sidebar-filters'
|
||||
|
||||
/**
|
||||
* Transient commenting UI state, scoped per editor via {@link EditorAtom} so two editors on one
|
||||
* page don't share open-thread, visibility, and filter state. Reachable from both React
|
||||
* (`useEditor()`) and the comment tool (`this.editor`).
|
||||
*/
|
||||
|
||||
/** The id of the one open thread (only one popover is open at a time), or null when all closed.
|
||||
* @public */
|
||||
export const openThreadId = new EditorAtom<string | null>('openThreadId', () => null)
|
||||
|
||||
/** The coincident-pin stack whose thread list is showing (keyed by its oldest member's thread id),
|
||||
* or null. Editor state rather than component state: the stack pin remounts when its owning render
|
||||
* path changes, and the open list must survive that.
|
||||
* @internal */
|
||||
export const openStackId = new EditorAtom<string | null>('openStackId', () => null)
|
||||
|
||||
/** The comment currently being placed (composer open, not yet posted), or null.
|
||||
* @internal */
|
||||
export const pendingComment = new EditorAtom<PendingComment | null>('pendingComment', () => null)
|
||||
|
||||
/**
|
||||
* A pending request to reveal a thread: a thread or comment id to open and bring into view, or
|
||||
* null when none is pending. Written by {@link revealThread}; served and cleared by
|
||||
* `CanvasComments`, which owns the wait for the records to sync in and the cluster-aware reveal.
|
||||
* @internal
|
||||
*/
|
||||
export const revealThreadRequest = new EditorAtom<string | null>('revealThreadRequest', () => null)
|
||||
|
||||
/**
|
||||
* Open a thread and bring it into view, given a thread id or the id of any comment in it. Use it to
|
||||
* jump to a thread from outside the canvas — a notification, a deep link, your own list.
|
||||
*
|
||||
* The request is served by `CanvasComments`, so it works before the records have arrived: the layer
|
||||
* waits for them, switches pages, unhides pins, and zooms far enough to split the thread out of any
|
||||
* cluster. That also means nothing happens if `CanvasComments` isn't mounted.
|
||||
*
|
||||
* To open a thread you already hold and skip the wait, see {@link focusThread}.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* revealThread(editor, new URLSearchParams(location.search).get('comment')!)
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function revealThread(editor: Editor, threadOrCommentId: string): void {
|
||||
revealThreadRequest.set(editor, threadOrCommentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The id passed to the most recent {@link revealThread} call that `CanvasComments` hasn't served
|
||||
* yet, or null. A request also clears when `CanvasComments` unmounts.
|
||||
*
|
||||
* This is a plain, untracked read — in React, use {@link useRevealThreadPending}, unless you need
|
||||
* the value as of *now* rather than as of the render you closed over.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function getRevealThreadPending(editor: Editor): string | null {
|
||||
return revealThreadRequest.get(editor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive React hook for {@link getRevealThreadPending}.
|
||||
*
|
||||
* Use it to notice a reveal that never lands — usually a deep link to a deleted comment. Give it a
|
||||
* grace period first, since a request also sits here while its records sync in, and re-check with
|
||||
* {@link getRevealThreadPending} when it elapses.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function useRevealThreadPending(): string | null {
|
||||
const editor = useEditor()
|
||||
return useValue('pending reveal thread', () => getRevealThreadPending(editor), [editor])
|
||||
}
|
||||
|
||||
/** The region rectangle being dragged out right now (page coords), or null when not dragging. The
|
||||
* comment tool writes it on each move; the overlay reads it to draw the live dashed box. */
|
||||
export const regionDraft = new EditorAtom<BoxModel | null>('regionDraft', () => null)
|
||||
|
||||
/**
|
||||
* Whether comment pins are hidden on the canvas. Governs the on-canvas layer (pins + open popover)
|
||||
* only — the sidebar is unaffected.
|
||||
* @public
|
||||
*/
|
||||
export const commentsHidden = new EditorAtom<boolean>('commentsHidden', () => false)
|
||||
|
||||
/**
|
||||
* Whether the comments sidebar (the thread list) is open. Driven by an explicit control rather than
|
||||
* by which tool is active, so browsing threads is separate from placing them.
|
||||
* @public
|
||||
*/
|
||||
export const commentsSidebarOpen = new EditorAtom<boolean>('commentsSidebarOpen', () => false)
|
||||
|
||||
/** Which threads the comments sidebar shows.
|
||||
* @public */
|
||||
export const sidebarFilters = new EditorAtom<SidebarFilters>(
|
||||
'sidebarFilters',
|
||||
() => DEFAULT_SIDEBAR_FILTERS
|
||||
)
|
||||
|
||||
/** Toggle comment-pin visibility for an editor.
|
||||
* @public */
|
||||
export function toggleCommentsHidden(editor: Editor): void {
|
||||
commentsHidden.update(editor, (hidden) => !hidden)
|
||||
}
|
||||
|
||||
/** Open or close the comments sidebar for an editor.
|
||||
* @public */
|
||||
export function toggleCommentsSidebar(editor: Editor): void {
|
||||
commentsSidebarOpen.update(editor, (open) => !open)
|
||||
}
|
||||
|
||||
/** React hook for the open thread id.
|
||||
* @public */
|
||||
export function useOpenThreadId(): string | null {
|
||||
const editor = useEditor()
|
||||
return useValue('open thread id', () => openThreadId.get(editor), [editor])
|
||||
}
|
||||
|
||||
/** React hook for the pending (being-placed) comment.
|
||||
* @internal */
|
||||
export function usePendingComment(): PendingComment | null {
|
||||
const editor = useEditor()
|
||||
return useValue('pending comment', () => pendingComment.get(editor), [editor])
|
||||
}
|
||||
|
||||
/** React hook for whether comment pins are hidden.
|
||||
* @public */
|
||||
export function useCommentsHidden(): boolean {
|
||||
const editor = useEditor()
|
||||
return useValue('comments hidden', () => commentsHidden.get(editor), [editor])
|
||||
}
|
||||
|
||||
/** React hook for whether the comments sidebar is open.
|
||||
* @public */
|
||||
export function useCommentsSidebarOpen(): boolean {
|
||||
const editor = useEditor()
|
||||
return useValue('comments sidebar open', () => commentsSidebarOpen.get(editor), [editor])
|
||||
}
|
||||
|
||||
/** React hook for the current sidebar filters.
|
||||
* @public */
|
||||
export function useSidebarFilters(): SidebarFilters {
|
||||
const editor = useEditor()
|
||||
return useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
|
||||
}
|
||||
409
packages/commenting/src/canvas/thread-pin.tsx
Normal file
409
packages/commenting/src/canvas/thread-pin.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
import { Avatar } from '@tldraw/mentions'
|
||||
import {
|
||||
memo,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
type BoxModel,
|
||||
Editor,
|
||||
TLCommentThread,
|
||||
useContainer,
|
||||
usePassThroughWheelEvents,
|
||||
useTranslation,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { CommentPin } from '../ui/comment-pin'
|
||||
import { forwardPointerEventToCanvas, isCanvasPanGesture } from './canvas-events'
|
||||
import { commitCommentMutation } from './comment-mutations'
|
||||
import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
||||
import { type CommentingContext } from './context'
|
||||
import { useThreadComments } from './hooks'
|
||||
import { getCommentingOptions, useCanComment, useCommentingOptions } from './options'
|
||||
import {
|
||||
REGION_CORNERS,
|
||||
REGION_HANDLE_MARGIN_PX,
|
||||
RegionBox,
|
||||
RegionResizeHandles,
|
||||
} from './region-box'
|
||||
import { openThreadId } from './state'
|
||||
import { ThreadPreview, useMarkerPreview } from './thread-preview'
|
||||
import {
|
||||
anchorPagePoint,
|
||||
anchorViewportPoint,
|
||||
commentTargetShapeAt,
|
||||
impreciseShapePinInset,
|
||||
isBoxInInflatedViewport,
|
||||
isInInflatedViewport,
|
||||
REGION_PIN_CORNER,
|
||||
regionAnchorPinCorner,
|
||||
regionPinPoint,
|
||||
regionWithPinAt,
|
||||
shapeAnchorAt,
|
||||
} from './thread-state'
|
||||
import { POPOVER_OFFSET, ThreadPopover, ThreadView } from './thread-view'
|
||||
|
||||
/** The opened popover has a header row the hover preview lacks, so it opens this much higher — the
|
||||
* first comment then lands where the preview's sat. Re-measure if the header height or the preview
|
||||
* panel padding changes. */
|
||||
const THREAD_HEADER_BLOCK = 36
|
||||
|
||||
/**
|
||||
* A single thread's pin: the marker at its anchor, the thread popover when it's open, the hover
|
||||
* preview, and — for a region anchor — the dashed box and its resize handles. Dragging the marker
|
||||
* re-anchors the thread.
|
||||
*
|
||||
* Memoized so a pin skips re-rendering when the layer re-renders for unrelated reasons; camera
|
||||
* tracking still works, since the pin subscribes to its own viewport position via signals.
|
||||
*/
|
||||
export const ThreadPin = memo(function ThreadPin({
|
||||
editor,
|
||||
thread,
|
||||
...props
|
||||
}: CommentingContext & {
|
||||
editor: Editor
|
||||
thread: TLCommentThread
|
||||
}) {
|
||||
const { resolveAuthor } = props
|
||||
const options = useCommentingOptions()
|
||||
const canComment = useCanComment(props.currentUserId)
|
||||
const container = useContainer()
|
||||
const msg = useTranslation()
|
||||
const comments = useThreadComments(editor, thread.id)
|
||||
// Only one thread's popover is open at a time — shared across pins via the atom.
|
||||
const open = useValue('thread open', () => openThreadId.get(editor) === thread.id, [
|
||||
editor,
|
||||
thread.id,
|
||||
])
|
||||
// While dragging the marker, its page point overrides the anchor's; committed on drop.
|
||||
const [dragPagePoint, setDragPagePoint] = useState<{ x: number; y: number } | null>(null)
|
||||
const [resizeBounds, setResizeBounds] = useState<BoxModel | null>(null)
|
||||
// Hovering the marker previews the thread's opening comment, on the delay every marker uses.
|
||||
const { previewShown, previewHandlers } = useMarkerPreview(editor, `pin:${thread.id}`)
|
||||
const previewThreads = useMemo(() => [thread], [thread])
|
||||
// The 'pointer' reveal mode: is the pointer within the region's bounds (plus a grab margin)?
|
||||
// Driven by pointer position, not DOM hover, so moving from anywhere in the region out to a corner
|
||||
// handle never loses the affordance — the box stays `pointer-events: none`.
|
||||
const pointerInRegion = useValue(
|
||||
'pointer in region',
|
||||
() => {
|
||||
if (thread.anchor.type !== 'region' || thread.pageId !== editor.getCurrentPageId())
|
||||
return false
|
||||
const m = REGION_HANDLE_MARGIN_PX / editor.getZoomLevel()
|
||||
const p = editor.inputs.getCurrentPagePoint()
|
||||
const a = thread.anchor
|
||||
return p.x >= a.x - m && p.x <= a.x + a.w + m && p.y >= a.y - m && p.y <= a.y + a.h + m
|
||||
},
|
||||
[editor, thread.anchor, thread.pageId]
|
||||
)
|
||||
// A region's box and handles are revealed while open, mid-resize, or while the pointer is
|
||||
// within the region.
|
||||
const revealed = open || resizeBounds != null || pointerInRegion
|
||||
// A region thread's pin corner is its own (the corner its creating drag released on), with
|
||||
// the default as the fallback for older records.
|
||||
const pinCorner =
|
||||
thread.anchor.type === 'region' ? regionAnchorPinCorner(thread.anchor) : REGION_PIN_CORNER
|
||||
// A region resizes from its corners — every corner but the pin's own, which the pin owns.
|
||||
const resizeHandles = useMemo(
|
||||
() => REGION_CORNERS.filter((c) => c.x !== pinCorner.x || c.y !== pinCorner.y),
|
||||
[pinCorner.x, pinCorner.y]
|
||||
)
|
||||
const dragRef = useRef<{
|
||||
startX: number
|
||||
startY: number
|
||||
moved: boolean
|
||||
// The anchor's page-space offset from the grab point, so a drag translates the pin by the
|
||||
// cursor's delta (like RegionBox's move) instead of snapping the anchor to the cursor.
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
} | null>(null)
|
||||
const markerRef = useRef<HTMLButtonElement>(null)
|
||||
// Wheel pass-through sits on the marker (which is never scrollable), not the layer root —
|
||||
// see the note on the layer.
|
||||
usePassThroughWheelEvents(markerRef)
|
||||
|
||||
// The drop-target hint is editor-global state with no automatic reset. If the pin unmounts
|
||||
// mid-drag (e.g. Shift+C hides comments), no pointer event will ever reach the drag handlers —
|
||||
// clear the hint here or it stays on the shape indefinitely.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (dragRef.current) editor.setHintingShapes([])
|
||||
}
|
||||
}, [editor])
|
||||
|
||||
// Clicking outside the open popover closes the thread. Capture phase + a class check rather than
|
||||
// stopPropagation, since the popover portals elsewhere in the DOM. The marker is excluded so its own
|
||||
// click-to-toggle handles it instead of this closing and the toggle reopening.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
const target = e.target as HTMLElement | null
|
||||
if (!target) return
|
||||
if (target.closest('.tlui-cmt-canvas-popover')) return
|
||||
const marker = markerRef.current
|
||||
if (marker && marker.contains(target)) return
|
||||
// A press on a region's resize handle edits this thread — don't dismiss it.
|
||||
if (target.closest('.tlui-cmt-canvas-region-handle')) return
|
||||
// A click inside a menu/popover layered above us (the sidebar's filter or overflow
|
||||
// dropdown, or the composer's mention picker — all portaled elsewhere) belongs to that
|
||||
// layer; defer to its own dismissal instead of closing the thread out from under it.
|
||||
if (
|
||||
target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .tlui-cmt-mention-popup')
|
||||
)
|
||||
return
|
||||
openThreadId.set(editor, null)
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true)
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
||||
}, [open, editor])
|
||||
|
||||
// The pin must not unmount mid-interaction: an open thread's popover hangs off it, and a drag
|
||||
// or resize holds pointer capture on it — so those states are exempt from the viewport cull.
|
||||
const exemptFromCull = open || dragPagePoint != null || resizeBounds != null
|
||||
const point = useValue(
|
||||
'pin point',
|
||||
() => {
|
||||
if (thread.pageId !== editor.getCurrentPageId()) return null
|
||||
const point = anchorViewportPoint(editor, thread.anchor)
|
||||
if (!point) return null
|
||||
// Off-screen pins (plus a pre-mount margin) unmount rather than re-render every camera frame. A
|
||||
// region thread stays mounted while any part of its box is on screen, since the box renders here too.
|
||||
if (!exemptFromCull) {
|
||||
const visible =
|
||||
thread.anchor.type === 'region'
|
||||
? isBoxInInflatedViewport(editor, thread.anchor)
|
||||
: isInInflatedViewport(editor, point)
|
||||
if (!visible) return null
|
||||
}
|
||||
return point
|
||||
},
|
||||
[editor, thread.anchor, thread.pageId, exemptFromCull]
|
||||
)
|
||||
if (!point) return null
|
||||
|
||||
const toggleOpen = () =>
|
||||
openThreadId.set(editor, openThreadId.get(editor) === thread.id ? null : thread.id)
|
||||
|
||||
const PinContent = options.components.PinContent
|
||||
const threadAuthor = resolveAuthor(thread.createdBy)
|
||||
const pinContent = PinContent ? (
|
||||
<PinContent thread={thread} comments={comments} />
|
||||
) : (
|
||||
<Avatar author={threadAuthor ?? UNKNOWN_COMMENT_AUTHOR} />
|
||||
)
|
||||
const pinLabel = msg(
|
||||
thread.resolved ? 'comments.pin-label-resolved' : 'comments.pin-label'
|
||||
).replace('{name}', threadAuthor?.name ?? UNKNOWN_AUTHOR)
|
||||
|
||||
// Drag the marker to move the thread: position is overridden locally while dragging, then re-anchored
|
||||
// on drop. A region translates keeping its size; a barely-moved pointer is a click.
|
||||
const isRegion = thread.anchor.type === 'region'
|
||||
// The marker is a button (so it's keyboard-reachable), so the drag handlers are typed to it.
|
||||
const startDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
// A middle/right-button or space-held press over a pin is a camera pan, not a pin drag —
|
||||
// hand it to the canvas untouched.
|
||||
if (isCanvasPanGesture(editor, e)) {
|
||||
forwardPointerEventToCanvas(container, e)
|
||||
return
|
||||
}
|
||||
e.stopPropagation()
|
||||
const grabPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
||||
const anchorPage = anchorPagePoint(editor, thread.anchor)
|
||||
// The drag delta is taken from where the pin is drawn, which for an imprecise shape pin
|
||||
// is inset from its anchor point — without this the pin jumps by the inset on drag start.
|
||||
const inset = impreciseShapePinInset(editor, thread.anchor)
|
||||
if (anchorPage && inset) {
|
||||
const zoom = editor.getZoomLevel()
|
||||
anchorPage.x += inset.x / zoom
|
||||
anchorPage.y += inset.y / zoom
|
||||
}
|
||||
dragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
moved: false,
|
||||
offsetX: anchorPage ? anchorPage.x - grabPage.x : 0,
|
||||
offsetY: anchorPage ? anchorPage.y - grabPage.y : 0,
|
||||
}
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}
|
||||
const onDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag) return
|
||||
// Moving a pin re-anchors the thread record — a commenting write. Without the permission the
|
||||
// press stays a click (`moved` never sets, so release toggles the popover and never commits).
|
||||
if (!canComment) return
|
||||
if (!drag.moved && Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return
|
||||
drag.moved = true
|
||||
const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
||||
const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
|
||||
setDragPagePoint(pagePoint)
|
||||
// Hint the shape the pin would re-anchor to on drop — the same hit-test endDrag resolves
|
||||
// with. Regions translate rather than re-anchor, so they never hint.
|
||||
if (!isRegion) {
|
||||
const hit = commentTargetShapeAt(editor, pagePoint)
|
||||
editor.setHintingShapes(hit ? [hit.id] : [])
|
||||
}
|
||||
}
|
||||
// Release capture and take the drag record; null when no drag was in progress.
|
||||
const takeDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
const drag = dragRef.current
|
||||
dragRef.current = null
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
}
|
||||
return drag
|
||||
}
|
||||
// A cancelled pointer (touch gesture takeover, browser interruption) aborts the drag outright:
|
||||
// no re-anchor commit, no click-toggle — the pin snaps back and the hint clears.
|
||||
const cancelDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
if (!takeDrag(e)) return
|
||||
setDragPagePoint(null)
|
||||
editor.setHintingShapes([])
|
||||
}
|
||||
const endDrag = (e: ReactPointerEvent<HTMLButtonElement>) => {
|
||||
const drag = takeDrag(e)
|
||||
if (!drag) return
|
||||
editor.setHintingShapes([])
|
||||
if (!drag.moved) {
|
||||
toggleOpen()
|
||||
return
|
||||
}
|
||||
const cursorPage = editor.screenToPage({ x: e.clientX, y: e.clientY })
|
||||
const pagePoint = { x: cursorPage.x + drag.offsetX, y: cursorPage.y + drag.offsetY }
|
||||
setDragPagePoint(null)
|
||||
let anchor: TLCommentThread['anchor']
|
||||
if (thread.anchor.type === 'region') {
|
||||
anchor = regionWithPinAt(thread.anchor, pinCorner, pagePoint)
|
||||
} else {
|
||||
const hit = commentTargetShapeAt(editor, pagePoint)
|
||||
anchor = hit
|
||||
? shapeAnchorAt(
|
||||
editor,
|
||||
hit.id,
|
||||
pagePoint,
|
||||
getCommentingOptions(editor).shouldBePrecise(editor, {
|
||||
shapeId: hit.id,
|
||||
point: pagePoint,
|
||||
altKey: e.altKey,
|
||||
})
|
||||
)
|
||||
: { type: 'point', x: pagePoint.x, y: pagePoint.y }
|
||||
}
|
||||
commitCommentMutation(editor, ({ put }) => put([{ ...thread, anchor }]), 'drag')
|
||||
}
|
||||
|
||||
// The pin (and its popover) track the live edit: a resize moves it to the region's pin corner, a
|
||||
// move to the drag point; otherwise it sits at the stored anchor's viewport point.
|
||||
const livePinPage = resizeBounds ? regionPinPoint(resizeBounds, pinCorner) : dragPagePoint
|
||||
const renderPointBase = livePinPage ? editor.pageToViewport(livePinPage) : point
|
||||
// A region's pin centres on its corner — overlapping the box — rather than hanging off it.
|
||||
// The marker anchors bottom-left, so step half its 28px size (--tlui-cmt-pin-size) left and
|
||||
// down (screen px), matching the draft composer's --region centring on the same corner.
|
||||
const renderPoint = isRegion
|
||||
? { x: renderPointBase.x - 14, y: renderPointBase.y + 14 }
|
||||
: renderPointBase
|
||||
|
||||
// A region's live box bounds, by priority: a corner resize, else a pin-drag translation (the pin
|
||||
// corner tracks the cursor), else the stored anchor. Undefined for non-region threads.
|
||||
const regionAnchor = thread.anchor.type === 'region' ? thread.anchor : undefined
|
||||
const movedRegion =
|
||||
regionAnchor && dragPagePoint
|
||||
? regionWithPinAt(regionAnchor, pinCorner, dragPagePoint)
|
||||
: regionAnchor
|
||||
const regionBoxBounds = resizeBounds ?? movedRegion
|
||||
const commitResize = (bounds: BoxModel) => {
|
||||
setResizeBounds(null)
|
||||
if (!canComment) return
|
||||
// Same commit path as a pin drag, so the configured `dragHistory` governs both — going
|
||||
// straight to `editor.run` here would make region resizes silently ignore the option.
|
||||
commitCommentMutation(
|
||||
editor,
|
||||
// Spread the existing anchor first so the region's pin corner survives a resize.
|
||||
({ put }) => put([{ ...thread, anchor: { ...regionAnchor!, ...bounds } }]),
|
||||
'drag'
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{regionBoxBounds && (dragPagePoint || revealed) && (
|
||||
<RegionBox editor={editor} box={regionBoxBounds} />
|
||||
)}
|
||||
{regionBoxBounds && revealed && !dragPagePoint && canComment && (
|
||||
<RegionResizeHandles
|
||||
editor={editor}
|
||||
box={regionBoxBounds}
|
||||
handles={resizeHandles}
|
||||
onPreview={setResizeBounds}
|
||||
onCommit={commitResize}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={[
|
||||
'tlui-cmt-canvas-pin',
|
||||
open && 'tlui-cmt-canvas-pin--open',
|
||||
dragPagePoint && 'tlui-cmt-canvas-pin--dragging',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{ left: renderPoint.x, top: renderPoint.y }}
|
||||
>
|
||||
<button
|
||||
ref={markerRef}
|
||||
type="button"
|
||||
className="tlui-cmt-button tlui-cmt-canvas-pin__marker"
|
||||
aria-label={pinLabel}
|
||||
aria-expanded={open}
|
||||
onPointerDown={startDrag}
|
||||
onPointerMove={onDrag}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={cancelDrag}
|
||||
// Pointer activation is already handled by endDrag (which distinguishes a click
|
||||
// from a drag), so only take keyboard-synthesised clicks here — those report
|
||||
// `detail === 0` — or the thread would toggle twice per mouse click.
|
||||
onClick={(e) => {
|
||||
if (e.detail === 0) toggleOpen()
|
||||
}}
|
||||
onPointerEnter={previewHandlers.onPointerEnter}
|
||||
onPointerLeave={previewHandlers.onPointerLeave}
|
||||
// Focus stands in for hover, so tabbing to a marker gets the same preview.
|
||||
onFocus={previewHandlers.onPointerEnter}
|
||||
onBlur={previewHandlers.onPointerLeave}
|
||||
>
|
||||
<CommentPin resolved={thread.resolved != null} open={open}>
|
||||
{pinContent}
|
||||
</CommentPin>
|
||||
</button>
|
||||
{/* The popover portals up to the menus layer (above the UI panels) so it isn't clipped;
|
||||
the pin itself stays in the canvas-in-front layer, beneath the UI. */}
|
||||
{open && (
|
||||
<ThreadPopover
|
||||
base={{
|
||||
x: renderPoint.x + POPOVER_OFFSET.thread.x,
|
||||
y: renderPoint.y + POPOVER_OFFSET.thread.y - THREAD_HEADER_BLOCK,
|
||||
}}
|
||||
>
|
||||
<ThreadView editor={editor} thread={thread} {...props} />
|
||||
</ThreadPopover>
|
||||
)}
|
||||
{/* Not while dragging: the pin is being moved, not read, and a panel trailing the
|
||||
cursor would obscure the drop target. */}
|
||||
{previewShown && !dragPagePoint && (
|
||||
<ThreadPreview
|
||||
editor={editor}
|
||||
threads={previewThreads}
|
||||
variant="thread"
|
||||
point={renderPoint}
|
||||
onSelectThread={() => openThreadId.set(editor, thread.id)}
|
||||
{...previewHandlers}
|
||||
currentUserId={props.currentUserId}
|
||||
resolveAuthor={resolveAuthor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
91
packages/commenting/src/canvas/thread-preview.test.ts
Normal file
91
packages/commenting/src/canvas/thread-preview.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { TLComment, TLCommentThread } from 'tldraw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { selectPreviewCards, sortThreadsForPreview } from './thread-preview'
|
||||
|
||||
function thread(id: string, createdAt: number): TLCommentThread {
|
||||
return {
|
||||
id,
|
||||
typeName: 'comment-thread',
|
||||
pageId: 'page:one',
|
||||
anchor: { type: 'point', x: 0, y: 0 },
|
||||
createdBy: 'user:1',
|
||||
createdAt,
|
||||
resolved: null,
|
||||
meta: {},
|
||||
} as unknown as TLCommentThread
|
||||
}
|
||||
|
||||
function comment(threadId: string): TLComment {
|
||||
return { id: `comment:${threadId}`, typeName: 'comment', threadId } as unknown as TLComment
|
||||
}
|
||||
|
||||
/** Stands in for the store lookup: only the named threads have their opening comment yet. */
|
||||
function arrived(...threadIds: string[]) {
|
||||
return (t: TLCommentThread) => (threadIds.includes(t.id) ? comment(t.id) : undefined)
|
||||
}
|
||||
|
||||
describe('sortThreadsForPreview', () => {
|
||||
it('orders threads oldest first', () => {
|
||||
const sorted = sortThreadsForPreview([thread('t2', 20), thread('t1', 10), thread('t3', 30)])
|
||||
expect(sorted.map((t) => t.id)).toEqual(['t1', 't2', 't3'])
|
||||
})
|
||||
|
||||
it('breaks creation-time ties by id, matching the stack ordering', () => {
|
||||
const sorted = sortThreadsForPreview([thread('t3', 5), thread('t1', 5), thread('t2', 5)])
|
||||
expect(sorted.map((t) => t.id)).toEqual(['t1', 't2', 't3'])
|
||||
})
|
||||
|
||||
// A cluster hands over `node.members`, which the clustering table sorts by id — the preview has
|
||||
// to re-sort rather than inherit that order, or a cluster would read newest-first at random.
|
||||
it('reorders an id-sorted cluster membership by creation time', () => {
|
||||
const sorted = sortThreadsForPreview([thread('t1', 300), thread('t2', 200), thread('t3', 100)])
|
||||
expect(sorted.map((t) => t.id)).toEqual(['t3', 't2', 't1'])
|
||||
})
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const input = [thread('t2', 20), thread('t1', 10)]
|
||||
sortThreadsForPreview(input)
|
||||
expect(input.map((t) => t.id)).toEqual(['t2', 't1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectPreviewCards', () => {
|
||||
it('pairs each thread with its opening comment', () => {
|
||||
const threads = [thread('t1', 10), thread('t2', 20)]
|
||||
const { cards, overflow } = selectPreviewCards(threads, arrived('t1', 't2'))
|
||||
expect(cards.map((c) => [c.thread.id, c.first.id])).toEqual([
|
||||
['t1', 'comment:t1'],
|
||||
['t2', 'comment:t2'],
|
||||
])
|
||||
expect(overflow).toBe(0)
|
||||
})
|
||||
|
||||
// A thread record can sync ahead of its first comment. It has nothing to show, and rendering it
|
||||
// anyway would paint a blank card — or, for a single-pin preview, a blank panel.
|
||||
it('drops threads whose opening comment has not arrived', () => {
|
||||
const threads = [thread('t1', 10), thread('t2', 20), thread('t3', 30)]
|
||||
const { cards } = selectPreviewCards(threads, arrived('t1', 't3'))
|
||||
expect(cards.map((c) => c.thread.id)).toEqual(['t1', 't3'])
|
||||
})
|
||||
|
||||
it('reports nothing to show when no comment has arrived', () => {
|
||||
const { cards, overflow } = selectPreviewCards([thread('t1', 10)], arrived())
|
||||
expect(cards).toEqual([])
|
||||
expect(overflow).toBe(0)
|
||||
})
|
||||
|
||||
it('caps the cards and counts the rest as overflow', () => {
|
||||
const threads = ['t1', 't2', 't3', 't4'].map((id, i) => thread(id, i))
|
||||
const { cards, overflow } = selectPreviewCards(threads, arrived('t1', 't2', 't3', 't4'), 2)
|
||||
expect(cards.map((c) => c.thread.id)).toEqual(['t1', 't2'])
|
||||
expect(overflow).toBe(2)
|
||||
})
|
||||
|
||||
// "+N more" should promise N readable threads, so unarrived ones can't pad the count.
|
||||
it('excludes unarrived threads from the overflow tally', () => {
|
||||
const threads = ['t1', 't2', 't3', 't4'].map((id, i) => thread(id, i))
|
||||
const { cards, overflow } = selectPreviewCards(threads, arrived('t1', 't2', 't3'), 2)
|
||||
expect(cards.map((c) => c.thread.id)).toEqual(['t1', 't2'])
|
||||
expect(overflow).toBe(1)
|
||||
})
|
||||
})
|
||||
294
packages/commenting/src/canvas/thread-preview.tsx
Normal file
294
packages/commenting/src/canvas/thread-preview.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import { useEffect, useMemo, useRef, type CSSProperties } from 'react'
|
||||
import {
|
||||
Editor,
|
||||
EditorAtom,
|
||||
EditorPortal,
|
||||
TLComment,
|
||||
TLCommentThread,
|
||||
usePassThroughWheelEvents,
|
||||
useTranslation,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { CommentCard } from '../ui/comment-card'
|
||||
import { replyCountLabel } from '../ui/reply-count'
|
||||
import { type CommentingContext } from './context'
|
||||
import { useComments } from './hooks'
|
||||
import { useCommentingOptions } from './options'
|
||||
import { openStackId, openThreadId } from './state'
|
||||
import { POPOVER_OFFSET, toCardProps, useResolveName } from './thread-view'
|
||||
|
||||
/**
|
||||
* Hover previews for every canvas marker — a single pin, a coincident stack, or a cluster badge.
|
||||
* Hovering shows the thread(s) behind the marker as cards; the marker's own click is unchanged.
|
||||
*
|
||||
* The panel is live, not a passive tooltip: the pointer can travel into it and click a card to open
|
||||
* that thread. Two pieces make that work — the close delay survives the trip across the gap, and
|
||||
* the panel carries an invisible bridge over it (see `.tlui-cmt-canvas-preview::before`).
|
||||
*
|
||||
* Opening a thread from a card needs nothing more than setting `openThreadId`, even for one folded
|
||||
* inside a badge: `collectClusterLeaves` skips the open thread, so it drops out and renders itself.
|
||||
*/
|
||||
|
||||
/** How long the pointer must rest on a marker before its preview appears. */
|
||||
const PREVIEW_OPEN_DELAY_MS = 180
|
||||
/**
|
||||
* Grace period after the pointer leaves the marker *or* the panel — long enough to cross the gap
|
||||
* between them, which the bridge element covers geometrically.
|
||||
*/
|
||||
const PREVIEW_CLOSE_DELAY_MS = 220
|
||||
/** Cards shown before the panel falls back to a "+N more" line. */
|
||||
const PREVIEW_MAX_THREADS = 5
|
||||
|
||||
/** A thread paired with the comment that opens it — one card of a preview. */
|
||||
export interface ThreadPreviewCard {
|
||||
thread: TLCommentThread
|
||||
first: TLComment
|
||||
}
|
||||
|
||||
/**
|
||||
* The cards a marker's preview will show, and how many threads it will summarise as "+N more".
|
||||
*
|
||||
* A thread can exist before its opening comment does — a collaborator's, mid-sync. Those are
|
||||
* dropped rather than rendered blank, and don't count toward the overflow tally.
|
||||
*/
|
||||
export function selectPreviewCards(
|
||||
threads: readonly TLCommentThread[],
|
||||
firstCommentOf: (thread: TLCommentThread) => TLComment | undefined,
|
||||
max = PREVIEW_MAX_THREADS
|
||||
): { cards: ThreadPreviewCard[]; overflow: number } {
|
||||
const readable: ThreadPreviewCard[] = []
|
||||
for (const thread of threads) {
|
||||
const first = firstCommentOf(thread)
|
||||
if (first) readable.push({ thread, first })
|
||||
}
|
||||
return { cards: readable.slice(0, max), overflow: Math.max(0, readable.length - max) }
|
||||
}
|
||||
|
||||
/**
|
||||
* What a marker's click opens, which is what its preview imitates — the panel's surface, its
|
||||
* width, and how it lays a comment out.
|
||||
*
|
||||
* `'thread'` previews a single pin: a thread panel without the header. `'list'` previews a
|
||||
* coincident stack or a cluster: one card per thread, like the stack list.
|
||||
*/
|
||||
export type ThreadPreviewVariant = 'thread' | 'list'
|
||||
|
||||
/**
|
||||
* Which marker's preview is showing, or null. One atom for the whole layer, so previews are
|
||||
* mutually exclusive: the close delay can leave an outgoing marker's timer pending when the next
|
||||
* opens, and per-component state would briefly show both.
|
||||
*/
|
||||
const hoveredMarkerId = new EditorAtom<string | null>('commentHoveredMarkerId', () => null)
|
||||
|
||||
/**
|
||||
* Hover state for one marker. Returns whether its preview should render, plus the pointer handlers
|
||||
* to spread onto the marker element. `markerId` must be stable and unique per marker across the
|
||||
* layer — prefix by kind, or a stack collides with its oldest member's own pin.
|
||||
*/
|
||||
export function useMarkerPreview(editor: Editor, markerId: string) {
|
||||
const openTimer = useRef(0)
|
||||
const closeTimer = useRef(0)
|
||||
|
||||
// Previews are a resting-state affordance. While a thread or stack list is open, that view is
|
||||
// the thing being read — a preview floating over it would just compete with it.
|
||||
const suppressed = useValue(
|
||||
'marker preview suppressed',
|
||||
() => openThreadId.get(editor) !== null || openStackId.get(editor) !== null,
|
||||
[editor]
|
||||
)
|
||||
const shown = useValue('marker preview shown', () => hoveredMarkerId.get(editor) === markerId, [
|
||||
editor,
|
||||
markerId,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const open = openTimer
|
||||
const close = closeTimer
|
||||
return () => {
|
||||
window.clearTimeout(open.current)
|
||||
window.clearTimeout(close.current)
|
||||
// Don't strand the atom on a marker that has unmounted — zoomed into a cluster, deleted,
|
||||
// or folded away — or no other marker's preview could ever show.
|
||||
if (hoveredMarkerId.get(editor) === markerId) hoveredMarkerId.set(editor, null)
|
||||
}
|
||||
}, [editor, markerId])
|
||||
|
||||
// Suppression can start *while* a preview is up: clicking the hovered pin opens its thread.
|
||||
// Retract the preview rather than leaving it stranded behind the popover that just opened.
|
||||
useEffect(() => {
|
||||
if (suppressed && hoveredMarkerId.get(editor) === markerId) {
|
||||
hoveredMarkerId.set(editor, null)
|
||||
}
|
||||
}, [suppressed, editor, markerId])
|
||||
|
||||
const onPointerEnter = () => {
|
||||
window.clearTimeout(openTimer.current)
|
||||
window.clearTimeout(closeTimer.current)
|
||||
if (suppressed) return
|
||||
openTimer.current = window.setTimeout(
|
||||
() => hoveredMarkerId.set(editor, markerId),
|
||||
PREVIEW_OPEN_DELAY_MS
|
||||
)
|
||||
}
|
||||
|
||||
const onPointerLeave = () => {
|
||||
window.clearTimeout(openTimer.current)
|
||||
window.clearTimeout(closeTimer.current)
|
||||
closeTimer.current = window.setTimeout(() => {
|
||||
if (hoveredMarkerId.get(editor) === markerId) hoveredMarkerId.set(editor, null)
|
||||
}, PREVIEW_CLOSE_DELAY_MS)
|
||||
}
|
||||
|
||||
return {
|
||||
previewShown: shown && !suppressed,
|
||||
previewHandlers: { onPointerEnter, onPointerLeave },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The hover panel: each thread's opening comment as a read-only card, capped with a "+N more" line.
|
||||
* Mounted only while hovering, so its store subscription costs nothing at rest.
|
||||
*/
|
||||
export function ThreadPreview({
|
||||
editor,
|
||||
threads,
|
||||
variant,
|
||||
point,
|
||||
onSelectThread,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
...props
|
||||
}: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'> & {
|
||||
editor: Editor
|
||||
/** The marker's threads, in the order they should read (oldest first). */
|
||||
threads: readonly TLCommentThread[]
|
||||
/** What the marker's click opens, which this panel imitates. */
|
||||
variant: ThreadPreviewVariant
|
||||
/** The marker's anchor point in viewport space — the same origin its popover is placed from. */
|
||||
point: { x: number; y: number }
|
||||
/** Open a thread from its card. Omit to leave the cards inert. */
|
||||
onSelectThread?(thread: TLCommentThread): void
|
||||
/** The owning marker's hover handlers, so the panel counts as part of its hover region. */
|
||||
onPointerEnter?(): void
|
||||
onPointerLeave?(): void
|
||||
}) {
|
||||
const options = useCommentingOptions()
|
||||
const msg = useTranslation()
|
||||
const comments = useComments(editor)
|
||||
const resolveName = useResolveName(props.resolveAuthor)
|
||||
|
||||
// The panel floats over the canvas and scrolls nothing of its own, so a wheel on it should zoom
|
||||
// and pan the canvas underneath — like every other tldraw panel, and like the popover it
|
||||
// previews. Without this the canvas would freeze wherever the preview happened to be.
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
usePassThroughWheelEvents(ref)
|
||||
|
||||
// Each thread's opening comment and its total comment count. `useComments` is oldest-first, so
|
||||
// the first hit per thread is that thread's first comment. One pass over every comment beats a
|
||||
// per-thread hook — the thread count here is driven by cluster size, which has no fixed bound.
|
||||
const { firstByThread, countByThread } = useMemo(() => {
|
||||
const first = new Map<string, TLComment>()
|
||||
const count = new Map<string, number>()
|
||||
for (const comment of comments) {
|
||||
if (!first.has(comment.threadId)) first.set(comment.threadId, comment)
|
||||
count.set(comment.threadId, (count.get(comment.threadId) ?? 0) + 1)
|
||||
}
|
||||
return { firstByThread: first, countByThread: count }
|
||||
}, [comments])
|
||||
|
||||
const { cards, overflow } = useMemo(
|
||||
() => selectPreviewCards(threads, (thread) => firstByThread.get(thread.id)),
|
||||
[threads, firstByThread]
|
||||
)
|
||||
|
||||
// Nothing readable yet — render no panel rather than an empty one. It matters most for the
|
||||
// thread variant, where the panel *is* the card's surface: an empty one would paint as a blank
|
||||
// box floating on the canvas.
|
||||
if (cards.length === 0) return null
|
||||
|
||||
// Placed at the same origin as the popover this previews, so the two only differ by what their
|
||||
// stylesheets do — see `.tlui-cmt-canvas-preview__panel--thread` for the header compensation.
|
||||
const offset = POPOVER_OFFSET[variant]
|
||||
// A thread preview is one comment on the panel itself, so the panel carries the hover and the
|
||||
// click. A list preview puts each thread on its own card, as the stack list does.
|
||||
const isThread = variant === 'thread'
|
||||
const panelClass = [
|
||||
'tlui-cmt-canvas-preview__panel',
|
||||
`tlui-cmt-canvas-preview__panel--${variant}`,
|
||||
isThread && onSelectThread ? 'tlui-cmt-canvas-preview__panel--selectable' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<EditorPortal>
|
||||
{/* The root is the hover region — it carries the bridge back to the marker — while the panel
|
||||
inside it is the visible surface. Keeping them apart is what lets the surface light up on
|
||||
its own hover without the bridge (which reaches back over the marker) lighting it up too. */}
|
||||
<div
|
||||
ref={ref}
|
||||
className={`tlui-cmt-canvas-preview tlui-cmt-canvas-preview--${variant}`}
|
||||
style={
|
||||
{
|
||||
left: point.x + offset.x,
|
||||
top: point.y + offset.y,
|
||||
// The stylesheet sizes the hover bridge against this, so the gap it spans follows
|
||||
// the offset rather than being restated as a second magic number.
|
||||
'--tlui-cmt-preview-offset': `${offset.x}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
// The panel sits over the canvas; a press on it is not a canvas press.
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={panelClass}>
|
||||
{cards.map(({ thread, first }) => {
|
||||
// The preview shows only the opening comment, so its reply count is what tells the
|
||||
// reader the thread continues past what they see.
|
||||
const replies = replyCountLabel(msg, (countByThread.get(thread.id) ?? 1) - 1)
|
||||
return (
|
||||
<div
|
||||
key={thread.id}
|
||||
className={
|
||||
isThread
|
||||
? undefined
|
||||
: onSelectThread
|
||||
? 'tlui-cmt-preview-card tlui-cmt-preview-card--selectable'
|
||||
: 'tlui-cmt-preview-card'
|
||||
}
|
||||
onClick={
|
||||
onSelectThread
|
||||
? (e) => {
|
||||
e.stopPropagation()
|
||||
onSelectThread(thread)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CommentCard
|
||||
{...toCardProps(first, props, options.components, resolveName)}
|
||||
footer={
|
||||
replies ? <span className="tlui-cmt-card__replies">{replies}</span> : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<div className="tlui-cmt-preview-more">
|
||||
{msg('comments.preview-more').replace('{count}', String(overflow))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</EditorPortal>
|
||||
)
|
||||
}
|
||||
|
||||
/** Order a marker's threads for reading: oldest first, id as the tiebreak so it's stable. */
|
||||
export function sortThreadsForPreview(
|
||||
threads: readonly TLCommentThread[]
|
||||
): readonly TLCommentThread[] {
|
||||
return [...threads].sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1))
|
||||
}
|
||||
238
packages/commenting/src/canvas/thread-stack.tsx
Normal file
238
packages/commenting/src/canvas/thread-stack.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { type MouseEvent as ReactMouseEvent, memo, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Editor,
|
||||
TLCommentThread,
|
||||
usePassThroughWheelEvents,
|
||||
useTranslation,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { CommentCard } from '../ui/comment-card'
|
||||
import { CountBadge } from '../ui/count-badge'
|
||||
import { UNKNOWN_AUTHOR } from './comment-render'
|
||||
import { type CommentingContext } from './context'
|
||||
import { useThreadComments } from './hooks'
|
||||
import { useCommentingOptions } from './options'
|
||||
import { pinStackKey } from './pin-stacking'
|
||||
import { openStackId, openThreadId } from './state'
|
||||
import { ThreadPreview, useMarkerPreview } from './thread-preview'
|
||||
import { anchorPagePoint, anchorViewportPoint } from './thread-state'
|
||||
import {
|
||||
POPOVER_OFFSET,
|
||||
ThreadPopover,
|
||||
ThreadView,
|
||||
toCardProps,
|
||||
useResolveName,
|
||||
} from './thread-view'
|
||||
|
||||
/**
|
||||
* The pin for threads whose anchors resolve to the same page point — pins zooming can never
|
||||
* separate, so they share one count badge. Clicking it lists each thread as a card; clicking a card
|
||||
* expands that thread in place, via the single open-thread state.
|
||||
*/
|
||||
export const ThreadStackPin = memo(function ThreadStackPin({
|
||||
editor,
|
||||
threads,
|
||||
...props
|
||||
}: CommentingContext & {
|
||||
editor: Editor
|
||||
/** The stack's threads, oldest first. All resolve to the same anchor point. */
|
||||
threads: readonly TLCommentThread[]
|
||||
}) {
|
||||
const msg = useTranslation()
|
||||
const badgeRef = useRef<HTMLButtonElement>(null)
|
||||
// The badge takes pointer events (to open on click), so wheel input over it would otherwise be
|
||||
// swallowed instead of zooming — pass it through to the canvas, as the pin and cluster badge do.
|
||||
usePassThroughWheelEvents(badgeRef)
|
||||
// Hovering the badge previews its threads; clicking still opens them as the interactive list.
|
||||
// The preview is what makes the badge legible before you commit to opening it.
|
||||
const { previewShown, previewHandlers } = useMarkerPreview(editor, `stack:${threads[0].id}`)
|
||||
// The list stays open while a member thread is expanded, so Escape steps back: expanded thread ->
|
||||
// card list -> closed. Held in editor state because this pin remounts as its owning render path
|
||||
// changes, and keyed by the coincident page point so the open state survives losing a member.
|
||||
const stackId = useValue(
|
||||
'stack id',
|
||||
() => {
|
||||
const pagePoint = anchorPagePoint(editor, threads[0].anchor)
|
||||
return pagePoint ? pinStackKey(pagePoint) : threads[0].id
|
||||
},
|
||||
[editor, threads]
|
||||
)
|
||||
const listOpen = useValue('stack list open', () => openStackId.get(editor) === stackId, [
|
||||
editor,
|
||||
stackId,
|
||||
])
|
||||
const openId = useValue('open thread id', () => openThreadId.get(editor), [editor])
|
||||
const openMember = threads.find((thread) => thread.id === openId)
|
||||
const open = listOpen || openMember !== undefined
|
||||
|
||||
const point = useValue(
|
||||
'stack point',
|
||||
() => {
|
||||
const first = threads[0]
|
||||
if (first.pageId !== editor.getCurrentPageId()) return null
|
||||
// The badge hangs off its anchor point bottom-left like a pin, and applies the same imprecise-shape
|
||||
// inset the pins it stands in for would (see ThreadPin) — otherwise the marker would snap from tucked
|
||||
// inside the shape to the raw corner the moment a second comment turns a pin into a stack.
|
||||
return anchorViewportPoint(editor, first.anchor)
|
||||
},
|
||||
[editor, threads]
|
||||
)
|
||||
|
||||
// Clicking outside the popover (and off the badge) closes the whole stack — mirrors the
|
||||
// single pin's dismiss. Capture phase + class checks, since the popover portals elsewhere.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
const target = e.target as HTMLElement | null
|
||||
if (!target) return
|
||||
if (target.closest('.tlui-cmt-canvas-popover')) return
|
||||
const badge = badgeRef.current
|
||||
if (badge && badge.contains(target)) return
|
||||
// A click inside a menu/popover layered above us belongs to that layer; defer to its
|
||||
// own dismissal instead of closing the stack out from under it.
|
||||
if (
|
||||
target.closest('.tlui-menu, [data-radix-popper-content-wrapper], .tlui-cmt-mention-popup')
|
||||
)
|
||||
return
|
||||
openStackId.set(editor, null)
|
||||
openThreadId.set(editor, null)
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true)
|
||||
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
||||
}, [open, editor])
|
||||
|
||||
// Escape with only the card list showing closes it. When a member thread is expanded, the
|
||||
// layer's Escape handler collapses that first and marks the event consumed — stepping back
|
||||
// to the list rather than closing everything at once.
|
||||
useEffect(() => {
|
||||
if (!listOpen) return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape' || e.defaultPrevented) return
|
||||
openStackId.set(editor, null)
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
return () => document.removeEventListener('keydown', onKeyDown, true)
|
||||
}, [listOpen, editor])
|
||||
|
||||
if (!point) return null
|
||||
|
||||
const toggle = () => {
|
||||
if (open) {
|
||||
openStackId.set(editor, null)
|
||||
openThreadId.set(editor, null)
|
||||
} else {
|
||||
openStackId.set(editor, stackId)
|
||||
}
|
||||
}
|
||||
|
||||
// The expanded thread gains a header that pushes its first comment down from where the hover preview
|
||||
// showed it. When that thread is the list's first, lift the whole list so its "You" holds position
|
||||
// across hover -> open. Only the first entry — lifting can't also hold a lower thread's neighbours.
|
||||
const liftForHeader = threads[0].id === openId ? 42 : 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="tlui-cmt-canvas-pin" style={{ left: point.x, top: point.y }}>
|
||||
<button
|
||||
ref={badgeRef}
|
||||
type="button"
|
||||
className="tlui-cmt-button tlui-cmt-canvas-stack-badge"
|
||||
aria-label={msg('comments.stack-label').replace('{count}', String(threads.length))}
|
||||
aria-expanded={open}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggle()
|
||||
}}
|
||||
{...previewHandlers}
|
||||
onFocus={previewHandlers.onPointerEnter}
|
||||
onBlur={previewHandlers.onPointerLeave}
|
||||
>
|
||||
<CountBadge count={threads.length} open={open} />
|
||||
</button>
|
||||
</div>
|
||||
{previewShown && !open && (
|
||||
<ThreadPreview
|
||||
editor={editor}
|
||||
threads={threads}
|
||||
// Lines the preview up with the stack list itself, so opening it leaves the cards
|
||||
// exactly where the preview had them.
|
||||
variant="list"
|
||||
point={point}
|
||||
// Picking a card from the preview lands in the same place clicking the badge and
|
||||
// then the card would: the list open, that thread expanded within it.
|
||||
onSelectThread={(thread) => {
|
||||
openStackId.set(editor, stackId)
|
||||
openThreadId.set(editor, thread.id)
|
||||
}}
|
||||
{...previewHandlers}
|
||||
currentUserId={props.currentUserId}
|
||||
resolveAuthor={props.resolveAuthor}
|
||||
/>
|
||||
)}
|
||||
{open && (
|
||||
<ThreadPopover
|
||||
base={{
|
||||
x: point.x + POPOVER_OFFSET.list.x,
|
||||
y: point.y + POPOVER_OFFSET.list.y - liftForHeader,
|
||||
}}
|
||||
>
|
||||
<div className="tlui-cmt-stack-list">
|
||||
{threads.map((thread) =>
|
||||
thread.id === openId ? (
|
||||
<div key={thread.id} className="tlui-cmt-stack-list__thread">
|
||||
<ThreadView editor={editor} thread={thread} {...props} />
|
||||
</div>
|
||||
) : (
|
||||
<StackThreadCard
|
||||
key={thread.id}
|
||||
editor={editor}
|
||||
thread={thread}
|
||||
{...props}
|
||||
onOpen={() => openThreadId.set(editor, thread.id)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ThreadPopover>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
/** A collapsed stack entry: the thread's first comment as a card; clicking expands the thread. */
|
||||
function StackThreadCard({
|
||||
editor,
|
||||
thread,
|
||||
onOpen,
|
||||
...props
|
||||
}: CommentingContext & { editor: Editor; thread: TLCommentThread; onOpen(): void }) {
|
||||
const msg = useTranslation()
|
||||
const options = useCommentingOptions()
|
||||
const comments = useThreadComments(editor, thread.id)
|
||||
const resolveName = useResolveName(props.resolveAuthor)
|
||||
const first = comments[0]
|
||||
if (!first) return null
|
||||
const open = (e: ReactMouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onOpen()
|
||||
}
|
||||
return (
|
||||
<div className="tlui-cmt-stack-list__card" onClick={open}>
|
||||
{/* The card's keyboard affordance. A button *wrapping* the card would put the comment
|
||||
body inside it, and a body renders its links as real anchors — interactive content
|
||||
nested in a button, which is an invalid content model and reads to assistive tech as a
|
||||
broken control. So the button covers the card as a sibling instead, leaving the body's
|
||||
own links above it and still reachable. */}
|
||||
<button
|
||||
type="button"
|
||||
className="tlui-cmt-button tlui-cmt-stack-list__card-action"
|
||||
aria-label={msg(
|
||||
thread.resolved ? 'comments.pin-label-resolved' : 'comments.pin-label'
|
||||
).replace('{name}', props.resolveAuthor(thread.createdBy)?.name ?? UNKNOWN_AUTHOR)}
|
||||
onClick={open}
|
||||
/>
|
||||
<CommentCard {...toCardProps(first, props, options.components, resolveName)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
295
packages/commenting/src/canvas/thread-state.test.ts
Normal file
295
packages/commenting/src/canvas/thread-state.test.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
Box,
|
||||
commentSchemaRecords,
|
||||
createCommentThread,
|
||||
createShapeId,
|
||||
createTLSchema,
|
||||
createTLStore,
|
||||
defaultBindingUtils,
|
||||
defaultShapeUtils,
|
||||
defaultTools,
|
||||
Editor,
|
||||
PageRecordType,
|
||||
TLShapeId,
|
||||
} from 'tldraw'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { commentsSidebarOpen } from './state'
|
||||
import {
|
||||
anchorPagePoint,
|
||||
commentCenterScreenOffset,
|
||||
commentTargetShapeAt,
|
||||
focusThread,
|
||||
impreciseShapePinInset,
|
||||
IMPRECISE_PIN_INSET_PX,
|
||||
isBoxInInflatedViewport,
|
||||
isInInflatedViewport,
|
||||
shapeAnchorAt,
|
||||
} from './thread-state'
|
||||
|
||||
/**
|
||||
* A real editor: the behavior under test is hit-testing against real shape geometry, which is
|
||||
* exactly what a stub can't model.
|
||||
*/
|
||||
let editor: Editor
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
store: createTLStore({ schema: createTLSchema({ records: commentSchemaRecords }) }),
|
||||
shapeUtils: defaultShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
tools: defaultTools,
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
editor.updateViewportScreenBounds(new Box(0, 0, 1000, 1000))
|
||||
})
|
||||
|
||||
describe('commentTargetShapeAt', () => {
|
||||
it('hits an arrow pointed at near its line', () => {
|
||||
const id = createShapeId()
|
||||
editor.createShape({
|
||||
id,
|
||||
type: 'arrow',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: { start: { x: 0, y: 0 }, end: { x: 200, y: 0 } },
|
||||
})
|
||||
|
||||
// Dead on the line, and a couple of pixels off it — both are a hit. Without a hit-test
|
||||
// margin only the pixel-perfect click would land, which made arrows uncommentable.
|
||||
expect(commentTargetShapeAt(editor, { x: 200, y: 100 })?.id).toBe(id)
|
||||
expect(commentTargetShapeAt(editor, { x: 200, y: 102 })?.id).toBe(id)
|
||||
})
|
||||
|
||||
it('hits a line shape near its segment', () => {
|
||||
const id = createShapeId()
|
||||
editor.createShape({ id, type: 'line', x: 100, y: 100 })
|
||||
|
||||
const bounds = editor.getShapePageBounds(id)!
|
||||
expect(commentTargetShapeAt(editor, { x: bounds.center.x, y: bounds.center.y })?.id).toBe(id)
|
||||
})
|
||||
|
||||
it('hits inside a hollow shape', () => {
|
||||
const id = createShapeId()
|
||||
editor.createShape({
|
||||
id,
|
||||
type: 'geo',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: { w: 100, h: 100, fill: 'none' },
|
||||
})
|
||||
|
||||
expect(commentTargetShapeAt(editor, { x: 150, y: 150 })?.id).toBe(id)
|
||||
})
|
||||
|
||||
it('returns undefined on empty canvas', () => {
|
||||
editor.createShape({
|
||||
id: createShapeId(),
|
||||
type: 'arrow',
|
||||
x: 100,
|
||||
y: 100,
|
||||
props: { start: { x: 0, y: 0 }, end: { x: 200, y: 0 } },
|
||||
})
|
||||
|
||||
expect(commentTargetShapeAt(editor, { x: 600, y: 600 })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
/** A 100×100 square whose page bounds are (100,100)–(200,200), unrotated. */
|
||||
function createSquare(): TLShapeId {
|
||||
const id = createShapeId()
|
||||
editor.createShape({ id, type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } })
|
||||
return id
|
||||
}
|
||||
|
||||
describe('shape anchors under a shape transform', () => {
|
||||
it('carries a precise pin around when the shape rotates', () => {
|
||||
const id = createSquare()
|
||||
// Top-right corner of the square.
|
||||
const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, true)
|
||||
expect(anchor).toMatchObject({ type: 'shape', shapeId: id, x: 1, y: 0, isPrecise: true })
|
||||
expect(anchorPagePoint(editor, anchor)).toMatchObject({ x: 200, y: 100 })
|
||||
|
||||
// A quarter turn about the square's centre takes that corner to the bottom-right. The
|
||||
// square's page bounds don't change, so a pin normalized against them would sit still.
|
||||
editor.rotateShapesBy([id], Math.PI / 2)
|
||||
const rotated = anchorPagePoint(editor, anchor)!
|
||||
expect(rotated.x).toBeCloseTo(200)
|
||||
expect(rotated.y).toBeCloseTo(200)
|
||||
})
|
||||
|
||||
it('records the spot a pin was dropped on a rotated shape', () => {
|
||||
const id = createSquare()
|
||||
editor.rotateShapesBy([id], Math.PI / 2)
|
||||
|
||||
// After the quarter turn the square's local top-right corner sits at the page's bottom-right.
|
||||
const anchor = shapeAnchorAt(editor, id, { x: 200, y: 200 }, true)
|
||||
expect(anchor.type).toBe('shape')
|
||||
if (anchor.type !== 'shape') return
|
||||
expect(anchor.x).toBeCloseTo(1)
|
||||
expect(anchor.y).toBeCloseTo(0)
|
||||
|
||||
const point = anchorPagePoint(editor, anchor)!
|
||||
expect(point.x).toBeCloseTo(200)
|
||||
expect(point.y).toBeCloseTo(200)
|
||||
})
|
||||
|
||||
it('still tracks moves and resizes', () => {
|
||||
const id = createSquare()
|
||||
const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, true)
|
||||
|
||||
editor.updateShape({ id, type: 'geo', x: 300, props: { w: 200 } })
|
||||
expect(anchorPagePoint(editor, anchor)).toMatchObject({ x: 500, y: 100 })
|
||||
})
|
||||
|
||||
it('turns an imprecise pin inset with the shape so it keeps stepping inward', () => {
|
||||
const id = createSquare()
|
||||
const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, false)
|
||||
// The default top-right spot: step left and down, into the square.
|
||||
expect(impreciseShapePinInset(editor, anchor)).toMatchObject({
|
||||
x: -IMPRECISE_PIN_INSET_PX,
|
||||
y: IMPRECISE_PIN_INSET_PX,
|
||||
})
|
||||
|
||||
editor.rotateShapesBy([id], Math.PI / 2)
|
||||
// The corner is now bottom-right, so stepping inward means left and up.
|
||||
const inset = impreciseShapePinInset(editor, anchor)!
|
||||
expect(inset.x).toBeCloseTo(-IMPRECISE_PIN_INSET_PX)
|
||||
expect(inset.y).toBeCloseTo(-IMPRECISE_PIN_INSET_PX)
|
||||
})
|
||||
|
||||
it('hides the pin for a shape that no longer exists', () => {
|
||||
const id = createSquare()
|
||||
const anchor = shapeAnchorAt(editor, id, { x: 200, y: 100 }, true)
|
||||
editor.deleteShape(id)
|
||||
expect(anchorPagePoint(editor, anchor)).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('commentCenterScreenOffset', () => {
|
||||
/** A sidebar element whose left edge sits at `left` client px, in the editor's container. */
|
||||
function mountSidebar(left: number) {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'tlui-cmt-canvas-sidebar'
|
||||
el.getBoundingClientRect = () => ({ left }) as DOMRect
|
||||
document.body.appendChild(el)
|
||||
return el
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelector('.tlui-cmt-canvas-sidebar')?.remove()
|
||||
})
|
||||
|
||||
it('is zero while the sidebar is closed', () => {
|
||||
mountSidebar(712)
|
||||
expect(commentCenterScreenOffset(editor)).toBe(0)
|
||||
})
|
||||
|
||||
it('is half the covered width, so the pin centers in the uncovered area', () => {
|
||||
commentsSidebarOpen.set(editor, true)
|
||||
// Viewport is 1000px wide; the sidebar covers the rightmost 288px. The centered pin (at
|
||||
// 356px) leaves the thread UI clear of the sidebar, so no nudge is needed.
|
||||
mountSidebar(712)
|
||||
expect(commentCenterScreenOffset(editor)).toBe(144)
|
||||
})
|
||||
|
||||
it('nudges the pin further left when the thread UI would reach the sidebar', () => {
|
||||
commentsSidebarOpen.set(editor, true)
|
||||
// Centered in the uncovered area the pin sits at 300px, and the thread UI (334px wide,
|
||||
// plus the 8px gap) would cross the sidebar's edge — so the pin backs off to 258px.
|
||||
mountSidebar(600)
|
||||
expect(commentCenterScreenOffset(editor)).toBe(500 - 258)
|
||||
})
|
||||
|
||||
it('never nudges the pin past the left edge', () => {
|
||||
commentsSidebarOpen.set(editor, true)
|
||||
// Clearing the sidebar would need a negative pin position; the left inset wins.
|
||||
mountSidebar(300)
|
||||
expect(commentCenterScreenOffset(editor)).toBe(500 - 8)
|
||||
})
|
||||
|
||||
it('is zero while the sidebar element is not mounted', () => {
|
||||
commentsSidebarOpen.set(editor, true)
|
||||
expect(commentCenterScreenOffset(editor)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isInInflatedViewport', () => {
|
||||
// The viewport is 1000x1000 (see beforeEach) and the margin is 120 screen px.
|
||||
it('accepts a point on screen', () => {
|
||||
expect(isInInflatedViewport(editor, { x: 500, y: 500 })).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a point just off screen, so a pan finds it already mounted', () => {
|
||||
expect(isInInflatedViewport(editor, { x: -119, y: 500 })).toBe(true)
|
||||
expect(isInInflatedViewport(editor, { x: 1119, y: 500 })).toBe(true)
|
||||
expect(isInInflatedViewport(editor, { x: 500, y: -119 })).toBe(true)
|
||||
expect(isInInflatedViewport(editor, { x: 500, y: 1119 })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a point past the margin on any side', () => {
|
||||
expect(isInInflatedViewport(editor, { x: -121, y: 500 })).toBe(false)
|
||||
expect(isInInflatedViewport(editor, { x: 1121, y: 500 })).toBe(false)
|
||||
expect(isInInflatedViewport(editor, { x: 500, y: -121 })).toBe(false)
|
||||
expect(isInInflatedViewport(editor, { x: 500, y: 1121 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isBoxInInflatedViewport', () => {
|
||||
it('accepts a box wholly on screen', () => {
|
||||
expect(isBoxInInflatedViewport(editor, { x: 100, y: 100, w: 200, h: 200 })).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a box straddling the edge, whose pin corner alone would be culled', () => {
|
||||
// This is the case the point test gets wrong: the box's visible edge is on screen while
|
||||
// its far corner — where a region thread's pin can sit — is thousands of px away.
|
||||
expect(isBoxInInflatedViewport(editor, { x: -5000, y: -5000, w: 5100, h: 5100 })).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a box larger than the viewport on every side', () => {
|
||||
expect(isBoxInInflatedViewport(editor, { x: -5000, y: -5000, w: 10000, h: 10000 })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a box wholly past the margin', () => {
|
||||
expect(isBoxInInflatedViewport(editor, { x: 1200, y: 100, w: 100, h: 100 })).toBe(false)
|
||||
expect(isBoxInInflatedViewport(editor, { x: 100, y: -400, w: 100, h: 100 })).toBe(false)
|
||||
})
|
||||
|
||||
it('follows the camera rather than page coordinates', () => {
|
||||
const box = { x: 2000, y: 0, w: 100, h: 100 }
|
||||
expect(isBoxInInflatedViewport(editor, box)).toBe(false)
|
||||
editor.setCamera({ x: -2000, y: 0, z: 1 })
|
||||
expect(isBoxInInflatedViewport(editor, box)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('focusThread', () => {
|
||||
it('records a cross-page jump as its own undo step', () => {
|
||||
const page1Id = editor.getCurrentPageId()
|
||||
const page2Id = PageRecordType.createId()
|
||||
const boxId = createShapeId()
|
||||
|
||||
// Set up the second page outside of history so the only undoable steps are the ones the
|
||||
// test creates: one edit on page 1, then the jump to the thread on page 2.
|
||||
editor.run(() => editor.createPage({ id: page2Id, name: 'Page 2' }), { history: 'ignore' })
|
||||
editor.clearHistory()
|
||||
|
||||
editor.markHistoryStoppingPoint('create box')
|
||||
editor.createShape({ id: boxId, type: 'geo', x: 0, y: 0, props: { w: 100, h: 100 } })
|
||||
|
||||
const thread = createCommentThread({
|
||||
pageId: page2Id,
|
||||
anchor: { type: 'point', x: 50, y: 50 },
|
||||
createdBy: 'me',
|
||||
})
|
||||
focusThread(editor, thread)
|
||||
expect(editor.getCurrentPageId()).toBe(page2Id)
|
||||
|
||||
// The first undo only takes us back to page 1; the edit made there is still intact.
|
||||
editor.undo()
|
||||
expect(editor.getCurrentPageId()).toBe(page1Id)
|
||||
expect(editor.getShape(boxId)).toBeDefined()
|
||||
|
||||
editor.undo()
|
||||
expect(editor.getShape(boxId)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
252
packages/commenting/src/canvas/thread-state.ts
Normal file
252
packages/commenting/src/canvas/thread-state.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
BoxModel,
|
||||
Editor,
|
||||
Mat,
|
||||
TLCommentAnchor,
|
||||
TLCommentThread,
|
||||
TLShape,
|
||||
TLShapeId,
|
||||
Vec,
|
||||
VecLike,
|
||||
} from 'tldraw'
|
||||
import { getCommentingOptions } from './options'
|
||||
import { commentsSidebarOpen, openThreadId } from './state'
|
||||
import { POPOVER_OFFSET } from './thread-view'
|
||||
|
||||
/** How far an imprecise shape pin steps inside the shape from its anchor spot, in screen px —
|
||||
* most of the marker sits within the shape, with a small overhang past the corner. */
|
||||
export const IMPRECISE_PIN_INSET_PX = 20
|
||||
|
||||
/** Screen-pixel margin by which the viewport is inflated when culling canvas markers (thread pins
|
||||
* and cluster badges), so a marker just off-screen is already mounted when a pan brings it in. */
|
||||
const MARKER_CULL_MARGIN_PX = 120
|
||||
|
||||
/**
|
||||
* Whether a viewport-space point sits within the viewport inflated by
|
||||
* {@link MARKER_CULL_MARGIN_PX}. The cull test for screen-fixed markers: off-screen markers
|
||||
* return null from their position signal and unmount instead of tracking every camera frame.
|
||||
* @internal
|
||||
*/
|
||||
export function isInInflatedViewport(editor: Editor, point: VecLike): boolean {
|
||||
const viewport = editor.getViewportScreenBounds()
|
||||
const margin = MARKER_CULL_MARGIN_PX
|
||||
return (
|
||||
point.x >= -margin &&
|
||||
point.y >= -margin &&
|
||||
point.x <= viewport.w + margin &&
|
||||
point.y <= viewport.h + margin
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any part of a page-space box overlaps the viewport inflated by
|
||||
* {@link MARKER_CULL_MARGIN_PX}. The cull test for region-anchored threads, whose dashed box can
|
||||
* be on screen while the pin corner itself is not.
|
||||
* @internal
|
||||
*/
|
||||
export function isBoxInInflatedViewport(editor: Editor, box: BoxModel): boolean {
|
||||
const viewport = editor.getViewportScreenBounds()
|
||||
const margin = MARKER_CULL_MARGIN_PX
|
||||
// Zoom is positive, so the page box's corners keep their order through the transform.
|
||||
const min = editor.pageToViewport({ x: box.x, y: box.y })
|
||||
const max = editor.pageToViewport({ x: box.x + box.w, y: box.y + box.h })
|
||||
return (
|
||||
max.x >= -margin &&
|
||||
max.y >= -margin &&
|
||||
min.x <= viewport.w + margin &&
|
||||
min.y <= viewport.h + margin
|
||||
)
|
||||
}
|
||||
|
||||
/** Imprecise shape pins tuck inside the shape rather than hanging off its edge: the marker
|
||||
* extends up-right of its anchor point, so step it toward the shape's centre. Screen px — the
|
||||
* pin is screen-fixed while the shape scales with zoom. Null for anchors that need no inset. */
|
||||
export function impreciseShapePinInset(
|
||||
editor: Editor,
|
||||
anchor: TLCommentThread['anchor']
|
||||
): { x: number; y: number } | null {
|
||||
if (anchor.type !== 'shape' || anchor.isPrecise) return null
|
||||
const spot = getCommentingOptions(editor).impreciseShapeAnchor
|
||||
const inset = {
|
||||
x: Math.sign(0.5 - spot.x) * IMPRECISE_PIN_INSET_PX,
|
||||
y: Math.sign(0.5 - spot.y) * IMPRECISE_PIN_INSET_PX,
|
||||
}
|
||||
// The spot is a corner of the shape's own bounds, so which way "toward the centre" points turns
|
||||
// with the shape: without this a rotated shape's pin would step out of the shape, not into it.
|
||||
const rotation = editor.getShapePageTransform(anchor.shapeId as TLShapeId)?.rotation() ?? 0
|
||||
return rotation === 0 ? inset : Vec.Rot(inset, rotation)
|
||||
}
|
||||
|
||||
/** The corner a region's pin and composer sit on, as a normalized 0–1 offset (bottom-right). Pin
|
||||
* position, composer placement, region move, and which corner has no resize handle all derive
|
||||
* from it. */
|
||||
export const REGION_PIN_CORNER: VecLike = { x: 1, y: 1 }
|
||||
|
||||
/** A region anchor's pin corner: the corner its creating drag released on, when recorded, else
|
||||
* {@link REGION_PIN_CORNER}. @internal */
|
||||
export function regionAnchorPinCorner(
|
||||
anchor: Extract<TLCommentAnchor, { type: 'region' }>
|
||||
): VecLike {
|
||||
if (anchor.pinX !== undefined && anchor.pinY !== undefined) {
|
||||
return { x: anchor.pinX, y: anchor.pinY }
|
||||
}
|
||||
return REGION_PIN_CORNER
|
||||
}
|
||||
|
||||
/** The page point of a region's pin corner. */
|
||||
export function regionPinPoint(region: BoxModel, corner: VecLike = REGION_PIN_CORNER): VecLike {
|
||||
return {
|
||||
x: region.x + corner.x * region.w,
|
||||
y: region.y + corner.y * region.h,
|
||||
}
|
||||
}
|
||||
|
||||
/** The inverse of {@link regionPinPoint}: a region translated so its pin corner lands on `pinPage`;
|
||||
* size unchanged. */
|
||||
export function regionWithPinAt(
|
||||
region: Extract<TLCommentAnchor, { type: 'region' }>,
|
||||
pinCorner: VecLike,
|
||||
pinPage: VecLike
|
||||
): Extract<TLCommentAnchor, { type: 'region' }> {
|
||||
return { ...region, x: pinPage.x - pinCorner.x * region.w, y: pinPage.y - pinCorner.y * region.h }
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a thread's pin sits on the page, for each anchor kind. Null hides the pin. Imprecise shape
|
||||
* anchors use {@link CommentingOptions.impreciseShapeAnchor} rather than the stored `x`/`y`.
|
||||
*
|
||||
* A shape anchor's `x`/`y` are normalized within the shape's bounds and resolved through its page
|
||||
* transform, so the pin rides rotation instead of being left behind in the bounding box.
|
||||
* @public
|
||||
*/
|
||||
export function anchorPagePoint(
|
||||
editor: Editor,
|
||||
anchor: TLCommentAnchor
|
||||
): { x: number; y: number } | null {
|
||||
switch (anchor.type) {
|
||||
case 'shape': {
|
||||
const shape = editor.getShape(anchor.shapeId as TLShapeId)
|
||||
if (!shape) return null
|
||||
const transform = editor.getShapePageTransform(shape)
|
||||
if (!transform) return null
|
||||
const { point, size } = editor.getShapeGeometry(shape).bounds
|
||||
// Precise pins sit at their stored x/y; imprecise ones at the editor's configured spot.
|
||||
const spot = anchor.isPrecise ? anchor : getCommentingOptions(editor).impreciseShapeAnchor
|
||||
return Mat.applyToPoint(transform, Vec.Add(point, Vec.MulV(spot, size)))
|
||||
}
|
||||
case 'point':
|
||||
return { x: anchor.x, y: anchor.y }
|
||||
case 'region':
|
||||
return regionPinPoint(anchor, regionAnchorPinCorner(anchor))
|
||||
case 'page':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a thread's marker is drawn, in viewport space: {@link anchorPagePoint} projected through
|
||||
* the camera, plus the imprecise-shape inset. Null when the anchor resolves to no point.
|
||||
* @internal
|
||||
*/
|
||||
export function anchorViewportPoint(
|
||||
editor: Editor,
|
||||
anchor: TLCommentAnchor
|
||||
): { x: number; y: number } | null {
|
||||
const pagePoint = anchorPagePoint(editor, anchor)
|
||||
if (!pagePoint) return null
|
||||
const viewportPoint = editor.pageToViewport(pagePoint)
|
||||
const inset = impreciseShapePinInset(editor, anchor)
|
||||
return inset ? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y } : viewportPoint
|
||||
}
|
||||
|
||||
/**
|
||||
* The shape a comment placed at a page point should anchor to, or undefined for empty canvas.
|
||||
*
|
||||
* Uses the editor's hit-test margin, the same slack select and hover use — without it, open-path
|
||||
* shapes (arrows, lines, draw strokes) are unhittable in practice.
|
||||
*
|
||||
* `hitFrameInside` lets a click inside a frame's body anchor to the frame, which it otherwise
|
||||
* wouldn't (the select-tool convention hits only the edge/label). A child shape under the pointer
|
||||
* still wins, so only a frame's empty interior anchors the frame.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function commentTargetShapeAt(editor: Editor, page: VecLike): TLShape | undefined {
|
||||
return editor.getShapeAtPoint(page, {
|
||||
hitInside: true,
|
||||
hitFrameInside: true,
|
||||
margin: editor.getHitTestMargin(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A shape anchor for a page point. `x`/`y` are the point's normalized (0–1) offset within the
|
||||
* shape's own bounds, taken in the shape's own space, so a pin on a rotated shape records the spot
|
||||
* it was dropped on. Remembered either way: when `precise` the pin sits at exactly `x`/`y`,
|
||||
* otherwise at the consumer's imprecise default.
|
||||
* @public
|
||||
*/
|
||||
export function shapeAnchorAt(
|
||||
editor: Editor,
|
||||
shapeId: TLShapeId,
|
||||
page: { x: number; y: number },
|
||||
precise: boolean
|
||||
): TLCommentAnchor {
|
||||
const shape = editor.getShape(shapeId)
|
||||
const bounds = shape && editor.getShapeGeometry(shape).bounds
|
||||
if (!shape || !bounds || bounds.w === 0 || bounds.h === 0) {
|
||||
return { type: 'shape', shapeId, x: 0.5, y: 0.5, isPrecise: precise }
|
||||
}
|
||||
const local = editor.getPointInShapeSpace(shape, page)
|
||||
return {
|
||||
type: 'shape',
|
||||
shapeId,
|
||||
x: (local.x - bounds.minX) / bounds.w,
|
||||
y: (local.y - bounds.minY) / bounds.h,
|
||||
isPrecise: precise,
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a thread and bring it into view — switch to its page if needed, then center its pin. @public */
|
||||
export function focusThread(editor: Editor, thread: TLCommentThread): void {
|
||||
if (thread.pageId !== editor.getCurrentPageId()) {
|
||||
editor.markHistoryStoppingPoint('change-page')
|
||||
editor.setCurrentPage(thread.pageId as any)
|
||||
}
|
||||
openThreadId.set(editor, thread.id)
|
||||
const point = anchorPagePoint(editor, thread.anchor)
|
||||
if (!point) return
|
||||
const offset = commentCenterScreenOffset(editor) / editor.getZoomLevel()
|
||||
editor.centerOnPoint({ x: point.x + offset, y: point.y }, { animation: { duration: 200 } })
|
||||
}
|
||||
|
||||
/** The left inset a nudged pin never crosses, and the gap kept between thread UI and sidebar. */
|
||||
const CENTER_MARGIN_PX = 8
|
||||
|
||||
/** How far the open thread UI reaches right of its pin, in screen px: the popover's offset plus
|
||||
* the thread panel's fixed width (300px, `.tlui-cmt-thread`). */
|
||||
const THREAD_UI_EXTENT_PX = POPOVER_OFFSET.thread.x + 300
|
||||
|
||||
/**
|
||||
* How far right of a centered-on pin the true viewport center should sit, in screen px. While the
|
||||
* comments sidebar covers the viewport's right edge, dead-centering a pin would put its thread
|
||||
* popover under the sidebar — so centering aims at the middle of the uncovered area instead, never
|
||||
* past the viewport's left edge. Zero when the sidebar is closed or not on screen.
|
||||
* @internal
|
||||
*/
|
||||
export function commentCenterScreenOffset(editor: Editor): number {
|
||||
if (!commentsSidebarOpen.get(editor)) return 0
|
||||
const sidebar = editor.getContainer().querySelector('.tlui-cmt-canvas-sidebar')
|
||||
if (!sidebar) return 0
|
||||
const viewport = editor.getViewportScreenBounds()
|
||||
// Viewport screen bounds are the container's client rect, so this is container-local.
|
||||
const sidebarLeft = sidebar.getBoundingClientRect().left - viewport.x
|
||||
if (sidebarLeft >= viewport.w) return 0
|
||||
// Aim the pin at the middle of the uncovered area, nudged left until the thread UI clears the
|
||||
// sidebar — but never past the left edge (the edge wins when there's no room for both).
|
||||
const pinX = Math.max(
|
||||
Math.min(sidebarLeft / 2, sidebarLeft - CENTER_MARGIN_PX - THREAD_UI_EXTENT_PX),
|
||||
CENTER_MARGIN_PX
|
||||
)
|
||||
return viewport.w / 2 - pinX
|
||||
}
|
||||
72
packages/commenting/src/canvas/thread-view.test.ts
Normal file
72
packages/commenting/src/canvas/thread-view.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { CommentAuthor } from '@tldraw/mentions'
|
||||
import type { TLComment } from 'tldraw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { absoluteThreadLink, toCardProps } from './thread-view'
|
||||
|
||||
const AUTHOR: CommentAuthor = { name: 'Ada' }
|
||||
|
||||
function comment(overrides: Partial<TLComment> = {}): TLComment {
|
||||
return {
|
||||
id: 'comment:1',
|
||||
typeName: 'comment',
|
||||
threadId: 'comment-thread:1',
|
||||
pageId: 'page:one',
|
||||
authorId: 'user:1',
|
||||
body: { type: 'doc', content: [] },
|
||||
createdAt: Date.parse('2026-07-30T12:00:00.000Z'),
|
||||
editedAt: null,
|
||||
meta: {},
|
||||
...overrides,
|
||||
} as unknown as TLComment
|
||||
}
|
||||
|
||||
const context = {
|
||||
currentUserId: 'user:1',
|
||||
resolveAuthor: (id: string) => (id === 'user:1' ? AUTHOR : undefined),
|
||||
}
|
||||
|
||||
const resolveName = (id: string) => context.resolveAuthor(id)?.name
|
||||
|
||||
describe('toCardProps', () => {
|
||||
it('marks your own comments and resolves the author', () => {
|
||||
const card = toCardProps(comment(), context, {}, resolveName)
|
||||
expect(card.you).toBe(true)
|
||||
expect(card.author).toBe(AUTHOR)
|
||||
expect(card.date).toBe('2026-07-30T12:00:00.000Z')
|
||||
expect(card.edited).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the unknown author when the id does not resolve', () => {
|
||||
const card = toCardProps(comment({ authorId: 'user:gone' }), context, {}, resolveName)
|
||||
expect(card.you).toBe(false)
|
||||
expect(card.author).not.toBe(AUTHOR)
|
||||
})
|
||||
})
|
||||
|
||||
describe('absoluteThreadLink', () => {
|
||||
const BASE = 'https://example.com/f/abc123?page=2'
|
||||
|
||||
// A host's `getThreadHref` is an href — the sidebar rows use it as one — so it's allowed to be
|
||||
// relative. Copying that verbatim would put "/f/abc123?comment=…" on someone's clipboard.
|
||||
it('resolves a root-relative href against the current document', () => {
|
||||
expect(absoluteThreadLink('/f/abc123?comment=comment-thread%3A1', BASE)).toBe(
|
||||
'https://example.com/f/abc123?comment=comment-thread%3A1'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves an already absolute href alone', () => {
|
||||
const href = 'https://other.example/f/xyz?comment=comment-thread%3A1'
|
||||
expect(absoluteThreadLink(href, BASE)).toBe(href)
|
||||
})
|
||||
|
||||
it('resolves a path-relative href', () => {
|
||||
expect(absoluteThreadLink('?comment=comment-thread%3A1', BASE)).toBe(
|
||||
'https://example.com/f/abc123?comment=comment-thread%3A1'
|
||||
)
|
||||
})
|
||||
|
||||
// Better a link that's odd than a copy button that silently does nothing.
|
||||
it('copies an unparseable href as-is', () => {
|
||||
expect(absoluteThreadLink('::not a url::', '::also not::')).toBe('::not a url::')
|
||||
})
|
||||
})
|
||||
612
packages/commenting/src/canvas/thread-view.tsx
Normal file
612
packages/commenting/src/canvas/thread-view.tsx
Normal file
@@ -0,0 +1,612 @@
|
||||
import { memo, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
createComment,
|
||||
Editor,
|
||||
EditorPortal,
|
||||
TLComment,
|
||||
TLCommentThread,
|
||||
TLRichText,
|
||||
TldrawUiButton,
|
||||
TldrawUiDropdownMenuContent,
|
||||
TldrawUiDropdownMenuGroup,
|
||||
TldrawUiDropdownMenuItem,
|
||||
TldrawUiDropdownMenuRoot,
|
||||
TldrawUiDropdownMenuTrigger,
|
||||
TldrawUiIcon,
|
||||
useContainer,
|
||||
usePassThroughWheelEvents,
|
||||
useTranslation,
|
||||
useValue,
|
||||
} from 'tldraw'
|
||||
import { CommentCard, CommentCardProps } from '../ui/comment-card'
|
||||
import { CommentComposer } from '../ui/comment-composer'
|
||||
import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
|
||||
import { CommentThread } from '../ui/comment-thread'
|
||||
import { CommentBody } from './comment-body'
|
||||
import {
|
||||
clearCommentDraft,
|
||||
getCommentDraft,
|
||||
replyDraftSlot,
|
||||
saveCommentDraft,
|
||||
} from './comment-drafts'
|
||||
import {
|
||||
commitCommentMutation,
|
||||
deleteComment,
|
||||
deleteThread,
|
||||
editComment,
|
||||
reopenThread,
|
||||
resolveThread,
|
||||
} from './comment-mutations'
|
||||
import { CommentReactionPicker, CommentReactions } from './comment-reactions'
|
||||
import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
|
||||
import { type CommentingContext } from './context'
|
||||
import { useThreadComments } from './hooks'
|
||||
import { useIsMobileCommenting, useMobilePlacement } from './mobile-placement'
|
||||
import {
|
||||
type CommentingComponents,
|
||||
getCanModifyComment,
|
||||
useCanComment,
|
||||
useCanModifyComment,
|
||||
useCommentingOptions,
|
||||
} from './options'
|
||||
import { openThreadId } from './state'
|
||||
|
||||
const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
|
||||
|
||||
/** The "⋯" dropdown used for both a comment's and the thread's actions. */
|
||||
function MoreOptionsMenu({
|
||||
id,
|
||||
moreFor,
|
||||
children,
|
||||
}: {
|
||||
id: string
|
||||
/** The comment whose ⋯ button this is, so `startEdit` can find it again to return focus. */
|
||||
moreFor?: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const msg = useTranslation()
|
||||
return (
|
||||
<TldrawUiDropdownMenuRoot id={id}>
|
||||
<TldrawUiDropdownMenuTrigger>
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
tooltip={msg('comments.more-options')}
|
||||
title={msg('comments.more-options')}
|
||||
className="tlui-cmt-thread__action"
|
||||
data-cmt-more-for={moreFor}
|
||||
>
|
||||
<TldrawUiIcon icon="dots-vertical" label={msg('comments.more-options')} small />
|
||||
</TldrawUiButton>
|
||||
</TldrawUiDropdownMenuTrigger>
|
||||
<TldrawUiDropdownMenuContent
|
||||
className="tlui-cmt-menu"
|
||||
side="bottom"
|
||||
align="start"
|
||||
alignOffset={0}
|
||||
sideOffset={4}
|
||||
>
|
||||
<TldrawUiDropdownMenuGroup>{children}</TldrawUiDropdownMenuGroup>
|
||||
</TldrawUiDropdownMenuContent>
|
||||
</TldrawUiDropdownMenuRoot>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
label,
|
||||
danger,
|
||||
noClose,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
danger?: boolean
|
||||
noClose?: boolean
|
||||
onClick(): void
|
||||
}) {
|
||||
return (
|
||||
<TldrawUiDropdownMenuItem noClose={noClose}>
|
||||
<button
|
||||
type="button"
|
||||
className={danger ? 'tlui-cmt-menu-item tlui-cmt-menu-item--danger' : 'tlui-cmt-menu-item'}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
</TldrawUiDropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A name-only view of an author resolver, for the mention/rich-text paths. Stable identity, so
|
||||
* `CommentBody`'s memoized render doesn't recompute on every render of its host.
|
||||
*/
|
||||
export function useResolveName(resolveAuthor: CommentingContext['resolveAuthor']) {
|
||||
return useCallback((id: string) => resolveAuthor(id)?.name, [resolveAuthor])
|
||||
}
|
||||
|
||||
/** How long the copy-link item reads "Link copied" before reverting. */
|
||||
const LINK_COPIED_MS = 2000
|
||||
|
||||
/**
|
||||
* What a thread's `getThreadHref` should put on the clipboard. Hosts may hand back a relative href,
|
||||
* which is meaningless once pasted elsewhere, so it's resolved against the current document first.
|
||||
* An href the URL parser can't make sense of is copied as-is rather than dropped.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function absoluteThreadLink(href: string, base = window.location.href): string {
|
||||
try {
|
||||
return new URL(href, base).toString()
|
||||
} catch {
|
||||
return href
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a thread's link, with a flag that stays set briefly afterwards so the control can confirm.
|
||||
* Only set once the write resolves, so a withheld clipboard doesn't claim a copy that didn't happen.
|
||||
*/
|
||||
function useCopyLink(href: string | undefined) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
const timeout = window.setTimeout(() => setCopied(false), LINK_COPIED_MS)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [copied])
|
||||
const copy = useCallback(() => {
|
||||
if (href === undefined) return
|
||||
navigator.clipboard?.writeText(absoluteThreadLink(href)).then(
|
||||
() => setCopied(true),
|
||||
() => setCopied(false)
|
||||
)
|
||||
}, [href])
|
||||
return [copied, copy] as const
|
||||
}
|
||||
|
||||
export function toCardProps(
|
||||
comment: TLComment,
|
||||
props: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'>,
|
||||
components: CommentingComponents,
|
||||
resolveName: (id: string) => string | undefined
|
||||
): CommentCardProps {
|
||||
const Body = components.CommentBody
|
||||
// The `CommentBody` component slot overrides the built-in rich-text default (which resolves
|
||||
// mention ids to names).
|
||||
const body = Body ? (
|
||||
<Body comment={comment} />
|
||||
) : (
|
||||
<CommentBody richText={comment.body} resolveName={resolveName} />
|
||||
)
|
||||
return {
|
||||
author: props.resolveAuthor(comment.authorId) ?? UNKNOWN_COMMENT_AUTHOR,
|
||||
body,
|
||||
date: new Date(comment.createdAt).toISOString(),
|
||||
you: comment.authorId === props.currentUserId,
|
||||
edited: comment.editedAt != null,
|
||||
}
|
||||
}
|
||||
|
||||
/** A pin is this square (mirrors `--tlui-cmt-pin-size`). The two marker kinds differ in size *and*
|
||||
* anchor point, so lining their previews up means correcting for both. Keep in sync with the CSS. */
|
||||
const PIN_SIZE = 28
|
||||
|
||||
/** A cluster/stack badge is this square (`--tlui-cmt-marker-size`) — pin-sized, so the marker
|
||||
* kinds read as one family. Keep in sync with the stylesheet. */
|
||||
const MARKER_SIZE = PIN_SIZE
|
||||
|
||||
/** Where a popover's top card sits vertically, measured from the marker visual's middle. */
|
||||
const CARD_TOP_Y = -28
|
||||
|
||||
/** The horizontal gap between a marker visual's right edge and its popover/preview panel. The
|
||||
* preview's hover bridge spans exactly this (see `--tlui-cmt-preview-bridge`). */
|
||||
const PREVIEW_GAP = 6
|
||||
|
||||
/**
|
||||
* Where a marker's popover sits relative to the marker's anchor point. The hover preview uses the
|
||||
* same origins, so moving a popover here moves its preview with it.
|
||||
*
|
||||
* Both marker kinds hang off their point the same way (`translate(0, -100%)`), differing only in
|
||||
* size — so each offset clears its own width plus the shared gap, and re-bases the shared card-top
|
||||
* measure from its bottom anchor to its middle.
|
||||
*/
|
||||
export const POPOVER_OFFSET = {
|
||||
thread: { x: PIN_SIZE + PREVIEW_GAP, y: CARD_TOP_Y - PIN_SIZE / 2 },
|
||||
list: { x: MARKER_SIZE + PREVIEW_GAP, y: CARD_TOP_Y - MARKER_SIZE / 2 },
|
||||
} as const
|
||||
|
||||
/** The open thread's popover container, portaled above the UI panels. A wheel over it passes
|
||||
* through to the canvas (unless it scrolls its own content), like tldraw's panels. */
|
||||
export function ThreadPopover({
|
||||
base,
|
||||
children,
|
||||
}: {
|
||||
base: { x: number; y: number }
|
||||
children: ReactNode
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
usePassThroughWheelEvents(ref)
|
||||
// On mobile the popover slides off its fixed offset to stay above the software keyboard and
|
||||
// on-screen; desktop keeps the fixed offset (base returned unchanged).
|
||||
const isMobile = useIsMobileCommenting()
|
||||
const placed = useMobilePlacement(ref, base, isMobile)
|
||||
return (
|
||||
<EditorPortal>
|
||||
{/* contextmenu also stops here: portals bubble React events to the canvas's context-menu
|
||||
trigger (the layer mounts inside it), which would open the canvas menu over this panel. */}
|
||||
<div
|
||||
ref={ref}
|
||||
className="tlui-cmt-canvas-popover"
|
||||
style={{ left: placed.left, top: placed.top }}
|
||||
onPointerDown={stop}
|
||||
onContextMenu={stop}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</EditorPortal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One thread's interactive view: its comments, the reply composer, edit-in-place on your own
|
||||
* comments, and the resolve/delete actions. Read receipts are reported for every unread comment
|
||||
* while mounted, so only mount it where the thread is actually being shown.
|
||||
*
|
||||
* Memoized because the popover position rides the pin's per-frame render point, so the pin
|
||||
* re-renders on every camera frame while a thread is open.
|
||||
*/
|
||||
export const ThreadView = memo(function ThreadView({
|
||||
editor,
|
||||
thread,
|
||||
...props
|
||||
}: CommentingContext & { editor: Editor; thread: TLCommentThread }) {
|
||||
const {
|
||||
currentUserId,
|
||||
resolveAuthor,
|
||||
onPostComment,
|
||||
isCommentUnread,
|
||||
onCommentsRead,
|
||||
getMentionSuggestions,
|
||||
renderMentionSuggestion,
|
||||
getThreadHref,
|
||||
} = props
|
||||
const options = useCommentingOptions()
|
||||
const comments = useThreadComments(editor, thread.id)
|
||||
const msg = useTranslation()
|
||||
const resolveName = useResolveName(resolveAuthor)
|
||||
// Rebuilt only when the comments change, not on every render — each of which would otherwise
|
||||
// re-allocate a date string and body element per comment.
|
||||
const cards = useMemo(
|
||||
() =>
|
||||
comments.map((c) =>
|
||||
toCardProps(c, { currentUserId, resolveAuthor }, options.components, resolveName)
|
||||
),
|
||||
[comments, currentUserId, resolveAuthor, options.components, resolveName]
|
||||
)
|
||||
const me = currentUserId ? resolveAuthor(currentUserId) : undefined
|
||||
// Composing, editing, deleting, and resolving are all gated on the viewer's permission. Where it's
|
||||
// withheld the composer gives way to the ComposerFallback slot and the affordances are hidden.
|
||||
const canComment = useCanComment(currentUserId)
|
||||
// Editing and deleting are further per-record: the author's to do by default, wider or narrower
|
||||
// under a `canModifyComment` callback. Computed for the thread's comments in one pass, since
|
||||
// `renderComment` is a callback rather than a component and can't call a hook per comment.
|
||||
const commentPermissions = useValue(
|
||||
'comment permissions',
|
||||
() =>
|
||||
new Map(
|
||||
comments.map((comment) => [
|
||||
comment.id,
|
||||
{
|
||||
edit: getCanModifyComment(editor, currentUserId, { action: 'edit-comment', comment }),
|
||||
delete: getCanModifyComment(editor, currentUserId, {
|
||||
action: 'delete-comment',
|
||||
comment,
|
||||
}),
|
||||
},
|
||||
])
|
||||
),
|
||||
[editor, currentUserId, comments]
|
||||
)
|
||||
// Deleting a thread is its creator's by default (and server-enforced); `canModifyComment` is what
|
||||
// widens that. Still a commenting write, so `canComment` stays the outer gate.
|
||||
const canModifyThread = useCanModifyComment(currentUserId, { action: 'delete-thread', thread })
|
||||
const canDeleteThread = canComment && canModifyThread
|
||||
const ComposerFallback = options.components.ComposerFallback
|
||||
// An unsent reply survives closing the thread (saved on every change, keyed by thread id) —
|
||||
// the flip side of dismissing without a discard warning.
|
||||
const [reply, setReply] = useState<TLRichText>(
|
||||
() => getCommentDraft(replyDraftSlot(thread.id)) ?? EMPTY_COMMENT
|
||||
)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editText, setEditText] = useState<TLRichText>(EMPTY_COMMENT)
|
||||
const canReply = canComment && !thread.resolved
|
||||
const container = useContainer()
|
||||
// Resolved when the composer closes rather than held as an element: the card unmounts while the
|
||||
// composer stands in its place, so the node captured on the way in is gone by the way out.
|
||||
const editReturnFocus = useRef<(() => HTMLElement | null) | null>(null)
|
||||
|
||||
// Tab from the canvas drops the caret in the reply box: a pin click leaves focus on the editor
|
||||
// container, so the first Tab would otherwise walk the app's UI. Capture phase, once per open thread.
|
||||
const [focusReply, setFocusReply] = useState(false)
|
||||
const tabTaken = useRef(false)
|
||||
const swallowTabUp = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!canReply) {
|
||||
// Resolving unmounts the reply box under an open thread. Reopening it should feel like a
|
||||
// freshly opened thread: no autoFocus left armed from the last Tab, and Tab available again.
|
||||
setFocusReply(false)
|
||||
tabTaken.current = false
|
||||
return
|
||||
}
|
||||
const doc = container.ownerDocument
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab' || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
||||
if (e.defaultPrevented || tabTaken.current) return
|
||||
// Focus rests on the container (or nothing) after a pin click. Anywhere else is deliberate, and
|
||||
// Tab belongs to whatever holds it.
|
||||
if (e.target !== container && e.target !== doc.body) return
|
||||
tabTaken.current = true
|
||||
swallowTabUp.current = true
|
||||
setFocusReply(true)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
// The select tool navigates shapes on Tab's *keyup*, so a quick tap would both focus the reply and
|
||||
// step the selection. Swallow the release of the press we took, and only that one.
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab' || !swallowTabUp.current) return
|
||||
swallowTabUp.current = false
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
doc.addEventListener('keydown', onKeyDown, true)
|
||||
doc.addEventListener('keyup', onKeyUp, true)
|
||||
return () => {
|
||||
doc.removeEventListener('keydown', onKeyDown, true)
|
||||
doc.removeEventListener('keyup', onKeyUp, true)
|
||||
}
|
||||
}, [canReply, container])
|
||||
|
||||
// Leaving the edit composer unmounts it while it holds focus, dropping focus to the editor
|
||||
// container — outside the thread, so a Tab from there walks the app's UI. Hand focus back to
|
||||
// whatever opened the composer instead.
|
||||
useEffect(() => {
|
||||
if (editingId !== null) return
|
||||
const resolve = editReturnFocus.current
|
||||
editReturnFocus.current = null
|
||||
// Runs after React has swapped the card back in, so the target is mounted again by now.
|
||||
resolve?.()?.focus()
|
||||
}, [editingId])
|
||||
|
||||
// Reported as one batch so the host can record the receipts in a single write. The write flips
|
||||
// isCommentUnread to false, so re-runs find nothing to report.
|
||||
useEffect(() => {
|
||||
if (!isCommentUnread || !onCommentsRead) return
|
||||
const unreadIds = comments.filter((comment) => isCommentUnread(comment.id)).map((c) => c.id)
|
||||
if (unreadIds.length > 0) {
|
||||
onCommentsRead(unreadIds)
|
||||
}
|
||||
}, [comments, isCommentUnread, onCommentsRead])
|
||||
|
||||
const postReply = () => {
|
||||
if (isCommentEmpty(reply) || !currentUserId) return
|
||||
const comment = commitCommentMutation(editor, ({ put }) => {
|
||||
const comment = createComment({
|
||||
threadId: thread.id,
|
||||
pageId: thread.pageId,
|
||||
authorId: currentUserId,
|
||||
body: reply,
|
||||
})
|
||||
put([comment])
|
||||
return comment
|
||||
})
|
||||
setReply(EMPTY_COMMENT)
|
||||
clearCommentDraft(replyDraftSlot(thread.id))
|
||||
// The host's callback is its own operation, not part of the post's history scope. It runs
|
||||
// last so a throwing host can't strand the composer holding a draft of a posted reply.
|
||||
onPostComment?.(comment)
|
||||
}
|
||||
|
||||
const toggleResolve = () => {
|
||||
if (!currentUserId) return
|
||||
if (thread.resolved) reopenThread(editor, thread)
|
||||
else resolveThread(editor, thread, currentUserId)
|
||||
}
|
||||
|
||||
// No `currentUserId` check: unlike a resolve, a delete stamps nothing on the record, so who may
|
||||
// make it is `canModifyComment`'s call alone (which withholds it from an unidentified viewer by
|
||||
// default).
|
||||
const removeThread = () => deleteThread(editor, thread)
|
||||
|
||||
const ThreadActions = options.components.ThreadActions
|
||||
// The host's URL for this thread. Its presence is what puts "copy link" in the header menu.
|
||||
const threadHref = getThreadHref?.(thread.id)
|
||||
const [linkCopied, copyThreadLink] = useCopyLink(threadHref)
|
||||
|
||||
const startEdit = (comment: TLComment, { fromMoreMenu = false } = {}) => {
|
||||
// Edit from the ⋯ menu comes back to that button, looked up again on the way out since the card
|
||||
// unmounts. Otherwise return to whatever held focus, ignoring the body and the editor container.
|
||||
const active = container.ownerDocument.activeElement
|
||||
editReturnFocus.current = fromMoreMenu
|
||||
? () => container.querySelector<HTMLElement>(`[data-cmt-more-for="${comment.id}"]`)
|
||||
: active instanceof HTMLElement && active !== container && active !== document.body
|
||||
? () => (active.isConnected ? active : null)
|
||||
: null
|
||||
setEditingId(comment.id)
|
||||
setEditText(comment.body)
|
||||
}
|
||||
|
||||
const saveEdit = () => {
|
||||
const comment = comments.find((c) => c.id === editingId)
|
||||
if (!comment || isCommentEmpty(editText)) return
|
||||
editComment(editor, comment, editText)
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
// Swap a comment for a pre-filled composer while it's being edited; otherwise show the card,
|
||||
// with the affordances the viewer is allowed on it.
|
||||
const renderComment = (card: CommentCardProps, index: number): ReactNode => {
|
||||
const comment = comments[index]
|
||||
const permissions = commentPermissions.get(comment.id)
|
||||
if (editingId === comment.id) {
|
||||
return (
|
||||
<div
|
||||
className="tlui-cmt-editing"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setEditingId(null)
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CommentComposer
|
||||
author={card.author}
|
||||
placeholder={msg('comments.edit-placeholder')}
|
||||
value={editText}
|
||||
onChange={setEditText}
|
||||
onSubmit={saveEdit}
|
||||
sendLabel={msg('comments.save')}
|
||||
disabled={isCommentEmpty(editText)}
|
||||
getMentionSuggestions={getMentionSuggestions}
|
||||
renderMentionSuggestion={renderMentionSuggestion}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<CommentCard
|
||||
{...card}
|
||||
footer={
|
||||
<CommentReactions
|
||||
comment={comment}
|
||||
// Reacting is a commenting write: without `canComment` the tally renders
|
||||
// read-only (no identity → pills aren't clickable).
|
||||
currentUserId={canComment ? currentUserId : null}
|
||||
resolveName={resolveName}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
canComment && (
|
||||
<>
|
||||
{(permissions?.edit || permissions?.delete) && (
|
||||
<MoreOptionsMenu id={`comment-actions-${comment.id}`} moreFor={comment.id}>
|
||||
{permissions?.edit && (
|
||||
<MenuItem
|
||||
label={msg('comments.edit')}
|
||||
onClick={() => startEdit(comment, { fromMoreMenu: true })}
|
||||
/>
|
||||
)}
|
||||
{permissions?.delete && (
|
||||
<MenuItem
|
||||
label={msg('action.delete')}
|
||||
danger
|
||||
onClick={() => deleteComment(editor, comment)}
|
||||
/>
|
||||
)}
|
||||
</MoreOptionsMenu>
|
||||
)}
|
||||
{/* Last so react is always the rightmost pill. */}
|
||||
<CommentReactionPicker comment={comment} currentUserId={currentUserId} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve and delete are commenting writes: behind `canComment`, plus the `currentUserId` a
|
||||
// resolve stamps into `resolved.by`.
|
||||
const resolveLabel = msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')
|
||||
const headerActions = (
|
||||
<>
|
||||
{/* Host verbs — assign, link a ticket — sit ahead of the built-in actions. */}
|
||||
{ThreadActions && <ThreadActions thread={thread} comments={comments} />}
|
||||
{(threadHref !== undefined || canDeleteThread) && (
|
||||
<MoreOptionsMenu id={`comment-thread-actions-${thread.id}`}>
|
||||
{/* A link is a read affordance: offered to anyone who can see the thread,
|
||||
including a viewer who can't comment. `noClose` keeps the menu up so the
|
||||
item can confirm the copy in place. */}
|
||||
{threadHref !== undefined && (
|
||||
<MenuItem
|
||||
label={msg(linkCopied ? 'comments.link-copied' : 'comments.copy-link')}
|
||||
noClose
|
||||
onClick={copyThreadLink}
|
||||
/>
|
||||
)}
|
||||
{canDeleteThread && (
|
||||
<MenuItem label={msg('comments.delete')} danger onClick={removeThread} />
|
||||
)}
|
||||
</MoreOptionsMenu>
|
||||
)}
|
||||
{canComment && currentUserId && (
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
tooltip={resolveLabel}
|
||||
title={resolveLabel}
|
||||
className="tlui-cmt-thread__action"
|
||||
onClick={toggleResolve}
|
||||
>
|
||||
<TldrawUiIcon icon="check" label={resolveLabel} small />
|
||||
</TldrawUiButton>
|
||||
)}
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
tooltip={msg('comments.dismiss')}
|
||||
title={msg('comments.dismiss')}
|
||||
className="tlui-cmt-thread__action"
|
||||
onClick={() => openThreadId.set(editor, null)}
|
||||
>
|
||||
<TldrawUiIcon icon="cross-2" label={msg('comments.dismiss')} small />
|
||||
</TldrawUiButton>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<CommentThread
|
||||
header={msg('comments.thread-title')}
|
||||
headerActions={headerActions}
|
||||
renderComment={renderComment}
|
||||
comments={cards}
|
||||
resolvedBanner={
|
||||
thread.resolved
|
||||
? msg('comments.resolved-by').replace(
|
||||
'{name}',
|
||||
resolveAuthor(thread.resolved.by)?.name ?? UNKNOWN_AUTHOR
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
composer={
|
||||
canReply
|
||||
? {
|
||||
author: me ?? UNKNOWN_COMMENT_AUTHOR,
|
||||
placeholder: msg('comments.reply-placeholder'),
|
||||
sendLabel: msg('comments.send'),
|
||||
value: reply,
|
||||
onChange: (value: TLRichText) => {
|
||||
setReply(value)
|
||||
saveCommentDraft(replyDraftSlot(thread.id), value)
|
||||
},
|
||||
onSubmit: postReply,
|
||||
// Up in the empty reply box edits the comment directly above it, chat-style —
|
||||
// only when you're allowed to edit it (the same gate as the Edit link).
|
||||
onArrowUpWhenEmpty: () => {
|
||||
const last = comments[comments.length - 1]
|
||||
if (last && commentPermissions.get(last.id)?.edit) startEdit(last)
|
||||
},
|
||||
// No user, no author for the record — dead send button.
|
||||
disabled: isCommentEmpty(reply) || !currentUserId,
|
||||
getMentionSuggestions,
|
||||
renderMentionSuggestion,
|
||||
// Reuses the composer's own focus path, so the caret lands at the end of a
|
||||
// restored draft rather than in front of it.
|
||||
autoFocus: focusReply,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
footer={
|
||||
!canComment && !thread.resolved && ComposerFallback ? (
|
||||
<ComposerFallback context="thread" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
300
packages/commenting/src/clustering/computeClusterTable.test.ts
Normal file
300
packages/commenting/src/clustering/computeClusterTable.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeClusterTable } from './computeClusterTable'
|
||||
import { mstEdges } from './mst'
|
||||
import { cappedReplay } from './replay'
|
||||
import { createClusterRuntime } from './runtime'
|
||||
import { contract, finalize } from './schedule'
|
||||
import { ClusterTable, LeafInput, MergeEvent } from './types'
|
||||
|
||||
function leaf(id: string, x: number, y: number): LeafInput {
|
||||
return { id, point: { x, y } }
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function shuffled<T>(items: readonly T[], rand: () => number): T[] {
|
||||
const out = items.slice()
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1))
|
||||
;[out[i], out[j]] = [out[j], out[i]]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
||||
const rand = mulberry32(seed)
|
||||
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
||||
}
|
||||
|
||||
/** The pipeline composed by hand — computeClusterTable must match it exactly. */
|
||||
function manualPipeline(
|
||||
leaves: readonly LeafInput[],
|
||||
opts: {
|
||||
Tc: number
|
||||
Tu: number
|
||||
eps: number
|
||||
Dmax: number
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
maxSplitZoom?: number
|
||||
}
|
||||
): MergeEvent[] {
|
||||
const raw = cappedReplay(leaves, mstEdges(leaves), { Tc: opts.Tc, Dmax: opts.Dmax })
|
||||
return finalize(contract(raw, opts.eps), {
|
||||
Tc: opts.Tc,
|
||||
Tu: opts.Tu,
|
||||
minZoom: opts.minZoom,
|
||||
maxZoom: opts.maxZoom,
|
||||
maxSplitZoom: opts.maxSplitZoom ?? 6,
|
||||
})
|
||||
}
|
||||
|
||||
function eventShape(ev: MergeEvent) {
|
||||
return {
|
||||
zMerge: ev.zMerge,
|
||||
zSplit: ev.zSplit,
|
||||
children: ev.children.map((c) => c.id),
|
||||
result: ev.result.id,
|
||||
members: ev.result.members,
|
||||
}
|
||||
}
|
||||
|
||||
function tableShapes(table: ClusterTable) {
|
||||
return table.events.map(eventShape)
|
||||
}
|
||||
|
||||
const ZOOM_BOUNDS = { minZoom: 0.05, maxZoom: 8 }
|
||||
|
||||
describe('computeClusterTable composition', () => {
|
||||
it('equals the hand-composed pipeline with all options explicit', () => {
|
||||
const opts = { Tc: 40, Tu: 60, eps: 0.12, Dmax: 120, ...ZOOM_BOUNDS }
|
||||
for (const seed of [1, 42]) {
|
||||
for (const n of [2, 10, 60]) {
|
||||
const leaves = randomLeaves(n, seed * 19 + n)
|
||||
const table = computeClusterTable(leaves, opts)
|
||||
expect(tableShapes(table)).toEqual(manualPipeline(leaves, opts).map(eventShape))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves all defaults from an empty option set (Tc 22, Tu 26.4, eps 0.7, Dmax 82.5)', () => {
|
||||
const leaves = randomLeaves(30, 7)
|
||||
const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
||||
const expected = manualPipeline(leaves, {
|
||||
Tc: 22,
|
||||
Tu: 26.4,
|
||||
eps: 0.7,
|
||||
Dmax: 82.5,
|
||||
...ZOOM_BOUNDS,
|
||||
})
|
||||
expect(tableShapes(table)).toEqual(expected.map(eventShape))
|
||||
})
|
||||
|
||||
it('derives Tu and Dmax from a caller-supplied Tc (Tu = 1.2·Tc, Dmax = 3.75·Tc)', () => {
|
||||
const leaves = randomLeaves(30, 13)
|
||||
const table = computeClusterTable(leaves, { Tc: 50, ...ZOOM_BOUNDS })
|
||||
const expected = manualPipeline(leaves, {
|
||||
Tc: 50,
|
||||
Tu: 60,
|
||||
eps: 0.7,
|
||||
Dmax: 187.5,
|
||||
...ZOOM_BOUNDS,
|
||||
})
|
||||
expect(tableShapes(table)).toEqual(expected.map(eventShape))
|
||||
})
|
||||
|
||||
it('caps coincident pairs at the default maxSplitZoom of 6 (600%)', () => {
|
||||
// Two threads at the same point: raw merge zoom is Infinity (never split), but the
|
||||
// default cap schedules them to merge at 5 (maxSplitZoom / (Tu/Tc) = 6 / 1.2) and split
|
||||
// at 6 like any ultra-tight pair.
|
||||
const leaves = [leaf('a', 50, 50), leaf('b', 50, 50)]
|
||||
const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
||||
expect(table.events).toHaveLength(1)
|
||||
expect(table.events[0].zMerge).toBe(5)
|
||||
expect(table.events[0].zSplit).toBe(6)
|
||||
})
|
||||
|
||||
it('honors partial overrides without disturbing the other defaults', () => {
|
||||
const leaves = randomLeaves(30, 29)
|
||||
const table = computeClusterTable(leaves, { Tu: 100, eps: 0, ...ZOOM_BOUNDS })
|
||||
const expected = manualPipeline(leaves, {
|
||||
Tc: 22,
|
||||
Tu: 100,
|
||||
eps: 0,
|
||||
Dmax: 82.5,
|
||||
...ZOOM_BOUNDS,
|
||||
})
|
||||
expect(tableShapes(table)).toEqual(expected.map(eventShape))
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeClusterTable leaves output', () => {
|
||||
it('returns leaf nodes in input order with exact leaf shape', () => {
|
||||
const leaves = [leaf('z-last', 5, 6), leaf('a-first', 1, 2), leaf('m-mid', 3, 4)]
|
||||
const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
||||
expect(table.leaves).toEqual([
|
||||
{ id: 'z-last', centroid: { x: 5, y: 6 }, count: 1, members: ['z-last'] },
|
||||
{ id: 'a-first', centroid: { x: 1, y: 2 }, count: 1, members: ['a-first'] },
|
||||
{ id: 'm-mid', centroid: { x: 3, y: 4 }, count: 1, members: ['m-mid'] },
|
||||
])
|
||||
})
|
||||
|
||||
it('handles n = 0 and n = 1 (empty events, leaves still produced)', () => {
|
||||
expect(computeClusterTable([], { ...ZOOM_BOUNDS })).toEqual({ events: [], leaves: [] })
|
||||
const table = computeClusterTable([leaf('only', 9, 9)], { ...ZOOM_BOUNDS })
|
||||
expect(table.events).toEqual([])
|
||||
expect(table.leaves).toEqual([
|
||||
{ id: 'only', centroid: { x: 9, y: 9 }, count: 1, members: ['only'] },
|
||||
])
|
||||
})
|
||||
|
||||
it('is usable directly by the runtime (n = 1 and n = 0 tables)', () => {
|
||||
const table = computeClusterTable([leaf('only', 9, 9)], { ...ZOOM_BOUNDS })
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(1)
|
||||
rt.onCamera(0.1)
|
||||
expect([...rt.getVisible().keys()]).toEqual(['only'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeClusterTable determinism', () => {
|
||||
it('is invariant under input permutation, events as id-keyed structures', () => {
|
||||
const base = randomLeaves(50, 23)
|
||||
const expected = tableShapes(computeClusterTable(base, { ...ZOOM_BOUNDS }))
|
||||
for (const shuffleSeed of [5, 17]) {
|
||||
const perm = shuffled(base, mulberry32(shuffleSeed))
|
||||
const table = computeClusterTable(perm, { ...ZOOM_BOUNDS })
|
||||
expect(tableShapes(table)).toEqual(expected)
|
||||
// leaves follow the (new) input order
|
||||
expect(table.leaves.map((l) => l.id)).toEqual(perm.map((l) => l.id))
|
||||
}
|
||||
})
|
||||
|
||||
it('returns identical output for repeated calls', () => {
|
||||
const leaves = randomLeaves(20, 3)
|
||||
expect(computeClusterTable(leaves, { ...ZOOM_BOUNDS })).toEqual(
|
||||
computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeClusterTable validation', () => {
|
||||
const leaves = [leaf('a', 0, 0), leaf('b', 10, 0)]
|
||||
|
||||
it('rejects every invalid option combination', () => {
|
||||
expect(() => computeClusterTable(leaves, { Tc: 0, ...ZOOM_BOUNDS })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { Tc: -5, ...ZOOM_BOUNDS })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { Tc: 40, Tu: 40, ...ZOOM_BOUNDS })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { Tu: 20, ...ZOOM_BOUNDS })).toThrow() // < default Tc 22
|
||||
expect(() => computeClusterTable(leaves, { eps: -0.01, ...ZOOM_BOUNDS })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { Dmax: 20, ...ZOOM_BOUNDS })).toThrow() // < default Tc 22
|
||||
expect(() => computeClusterTable(leaves, { Tc: 50, Dmax: 40, ...ZOOM_BOUNDS })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { minZoom: 0, maxZoom: 8 })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { minZoom: 8, maxZoom: 8 })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { minZoom: 9, maxZoom: 8 })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { minZoom: 0.05, maxZoom: NaN })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { minZoom: 0.05, maxZoom: Infinity })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { eps: NaN, ...ZOOM_BOUNDS })).toThrow()
|
||||
expect(() => computeClusterTable(leaves, { Tc: NaN, ...ZOOM_BOUNDS })).toThrow()
|
||||
})
|
||||
|
||||
it('validates even when there is nothing to cluster', () => {
|
||||
expect(() => computeClusterTable([], { minZoom: 0, maxZoom: 8 })).toThrow()
|
||||
expect(() => computeClusterTable([leaf('x', 0, 0)], { Tc: -1, ...ZOOM_BOUNDS })).toThrow()
|
||||
})
|
||||
|
||||
it('propagates input errors from the MST stage', () => {
|
||||
expect(() =>
|
||||
computeClusterTable([leaf('dup', 0, 0), leaf('dup', 1, 1)], { ...ZOOM_BOUNDS })
|
||||
).toThrow(/dup/)
|
||||
expect(() =>
|
||||
computeClusterTable([leaf('ok', 0, 0), leaf('bad', NaN, 0)], { ...ZOOM_BOUNDS })
|
||||
).toThrow(/bad/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeClusterTable end-to-end scenarios', () => {
|
||||
it('produces the appendix A.2 zoom bands for the 8-pin chain, via the runtime', () => {
|
||||
const leaves = Array.from({ length: 8 }, (_, i) => leaf(`p${i + 1}`, i * 10, 0))
|
||||
const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
||||
|
||||
// events (centroid pricing): four pair births at zMerge 2.2 (zSplit 2.64), two
|
||||
// quads at zMerge 1.1 (zSplit 1.32), the bridge at zMerge 0.55 (zSplit 0.66)
|
||||
const seedAt = (zoom: number) => {
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(zoom)
|
||||
return [...rt.getVisible().keys()].sort()
|
||||
}
|
||||
// zoom 8: above every band midpoint → 8 separate pins
|
||||
expect(seedAt(8)).toEqual(['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8'])
|
||||
// zoom 2: below the pair midpoints (√5.81 ≈ 2.41), above the quad midpoint (√1.45 ≈ 1.2)
|
||||
expect(seedAt(2)).toEqual(['cluster:2:p1', 'cluster:2:p3', 'cluster:2:p5', 'cluster:2:p7'])
|
||||
// zoom 1: quads merged, above the bridge midpoint (√0.363 ≈ 0.6)
|
||||
expect(seedAt(1)).toEqual(['cluster:4:p1', 'cluster:4:p5'])
|
||||
// zoom 0.5: below everything → one badge of 8
|
||||
expect(seedAt(0.5)).toEqual(['cluster:8:p1'])
|
||||
})
|
||||
|
||||
it('keeps far-apart groups exactly independent at eps = 0 (bridge pruned by minZoom)', () => {
|
||||
// Two groups 10,000 page units apart: the bridging merge would fire at
|
||||
// z ≈ 40/10000 = 0.004 < minZoom → pruned. With eps = 0 no contraction
|
||||
// window can couple the groups, so the combined table is exactly the two
|
||||
// solo tables' events merged in threshold order.
|
||||
const groupA = randomLeaves(12, 5, 300)
|
||||
const groupB = randomLeaves(12, 9, 300).map((l) => ({
|
||||
id: `far-${l.id}`,
|
||||
point: { x: l.point.x + 10000, y: l.point.y },
|
||||
}))
|
||||
const opts = { eps: 0, ...ZOOM_BOUNDS }
|
||||
|
||||
const combined = tableShapes(computeClusterTable([...groupA, ...groupB], opts))
|
||||
const soloA = tableShapes(computeClusterTable(groupA, opts))
|
||||
const soloB = tableShapes(computeClusterTable(groupB, opts))
|
||||
|
||||
const merged = [...soloA, ...soloB].sort((a, b) => b.zMerge - a.zMerge)
|
||||
expect(combined.map((e) => e.result).sort()).toEqual(merged.map((e) => e.result).sort())
|
||||
expect(combined).toHaveLength(soloA.length + soloB.length)
|
||||
|
||||
// no event ever mixes members across the gap
|
||||
for (const ev of combined) {
|
||||
const far = ev.members.filter((m) => m.startsWith('far-')).length
|
||||
expect(far === 0 || far === ev.members.length).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps group memberships unmixed at default eps too (thresholds may synchronize)', () => {
|
||||
// Global contraction windows may pull the two groups' thresholds together
|
||||
// (synchronized firing is by design) but must never fuse their clusters.
|
||||
const groupA = randomLeaves(10, 55, 300)
|
||||
const groupB = randomLeaves(10, 66, 300).map((l) => ({
|
||||
id: `far-${l.id}`,
|
||||
point: { x: l.point.x + 10000, y: l.point.y },
|
||||
}))
|
||||
const combined = computeClusterTable([...groupA, ...groupB], { ...ZOOM_BOUNDS })
|
||||
for (const ev of combined.events) {
|
||||
const far = ev.result.members.filter((m) => m.startsWith('far-')).length
|
||||
expect(far === 0 || far === ev.result.members.length).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeClusterTable performance', () => {
|
||||
it('builds 2,000 leaves well within budget', () => {
|
||||
const leaves = randomLeaves(2000, 2026)
|
||||
const start = performance.now()
|
||||
const table = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
||||
const elapsed = performance.now() - start
|
||||
expect(table.leaves).toHaveLength(2000)
|
||||
// generous bound to stay non-flaky on slow CI
|
||||
expect(elapsed).toBeLessThan(2500)
|
||||
})
|
||||
})
|
||||
74
packages/commenting/src/clustering/computeClusterTable.ts
Normal file
74
packages/commenting/src/clustering/computeClusterTable.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { mstEdges } from './mst'
|
||||
import { cappedReplay } from './replay'
|
||||
import { contract, finalize } from './schedule'
|
||||
import type {
|
||||
ClusterNode,
|
||||
ClusterOptions,
|
||||
ClusterTable,
|
||||
LeafInput,
|
||||
LeafScreenOffsets,
|
||||
} from './types'
|
||||
|
||||
/** @internal */
|
||||
export function computeClusterTable(
|
||||
leaves: readonly LeafInput[],
|
||||
options: ClusterOptions,
|
||||
// Render offsets for markers that draw off their anchor (imprecise pins), keyed by leaf id.
|
||||
// Omitted or empty, the run is identical — output and code paths — to an offset-unaware one.
|
||||
screenOffsets?: LeafScreenOffsets
|
||||
): ClusterTable {
|
||||
const opts = resolveOptions(options)
|
||||
const leafNodes = leaves.map(leafToNode)
|
||||
const edges = mstEdges(leaves)
|
||||
|
||||
if (leaves.length < 2) {
|
||||
return { events: [], leaves: leafNodes }
|
||||
}
|
||||
|
||||
const raw = cappedReplay(leaves, edges, opts, screenOffsets)
|
||||
const events = finalize(contract(raw, opts.eps), opts)
|
||||
|
||||
return { events, leaves: leafNodes }
|
||||
}
|
||||
|
||||
function resolveOptions(options: ClusterOptions): Required<ClusterOptions> {
|
||||
const Tc = options.Tc ?? 22
|
||||
const Tu = options.Tu ?? 1.2 * Tc
|
||||
const eps = options.eps ?? 0.7
|
||||
const Dmax = options.Dmax ?? 3.75 * Tc
|
||||
const maxSplitZoom = options.maxSplitZoom ?? 6
|
||||
const { minZoom, maxZoom } = options
|
||||
|
||||
if (!Number.isFinite(Tc) || Tc <= 0) {
|
||||
throw new Error('Tc must be finite and greater than 0')
|
||||
}
|
||||
if (!Number.isFinite(Tu) || Tu <= Tc) {
|
||||
throw new Error('Tu must be finite and greater than Tc')
|
||||
}
|
||||
if (!Number.isFinite(eps) || eps < 0) {
|
||||
throw new Error('eps must be finite and greater than or equal to 0')
|
||||
}
|
||||
if (!Number.isFinite(Dmax) || Dmax < Tc) {
|
||||
throw new Error('Dmax must be finite and greater than or equal to Tc')
|
||||
}
|
||||
if (!Number.isFinite(minZoom) || minZoom <= 0) {
|
||||
throw new Error('minZoom must be finite and greater than 0')
|
||||
}
|
||||
if (!Number.isFinite(maxZoom) || maxZoom <= minZoom) {
|
||||
throw new Error('maxZoom must be finite and greater than minZoom')
|
||||
}
|
||||
if (!Number.isFinite(maxSplitZoom) || maxSplitZoom <= 0) {
|
||||
throw new Error('maxSplitZoom must be finite and greater than 0')
|
||||
}
|
||||
|
||||
return { Tc, Tu, eps, Dmax, minZoom, maxZoom, maxSplitZoom }
|
||||
}
|
||||
|
||||
function leafToNode(leaf: LeafInput): ClusterNode {
|
||||
return {
|
||||
id: leaf.id,
|
||||
centroid: { x: leaf.point.x, y: leaf.point.y },
|
||||
count: 1,
|
||||
members: [leaf.id],
|
||||
}
|
||||
}
|
||||
189
packages/commenting/src/clustering/locality.test.ts
Normal file
189
packages/commenting/src/clustering/locality.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeClusterTable } from './computeClusterTable'
|
||||
import { createClusterRuntime } from './runtime'
|
||||
import { ClusterTable, LeafInput, MergeEvent } from './types'
|
||||
|
||||
// The locality contract the overlay's rendering relies on (CLUSTERING.md §8.4):
|
||||
// a visible cluster's members never sit farther than (1+eps)·r·Dmax screen px
|
||||
// from its centroid, so culling with that margin never hides a badge whose
|
||||
// member anchors are on screen. The bound stacks all three mechanisms:
|
||||
// cap — zEff·pageDiag ≤ Dmax at every raw merge
|
||||
// contraction— fusing to the window anchor overshoots by ≤ (1+eps)
|
||||
// hysteresis — a merged cluster survives up to zSplit = r·zMerge
|
||||
// and uses the FULL extent, not half: a count-weighted centroid of a skewed
|
||||
// cluster (many pins on one side, one far out) can sit a full extent from its
|
||||
// farthest member.
|
||||
|
||||
function leaf(id: string, x: number, y: number): LeafInput {
|
||||
return { id, point: { x, y } }
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
||||
const rand = mulberry32(seed)
|
||||
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
||||
}
|
||||
|
||||
const OPTS = { Tc: 40, Tu: 60, eps: 0.12, Dmax: 120, minZoom: 0.05, maxZoom: 8, maxSplitZoom: 1e9 }
|
||||
const R = OPTS.Tu / OPTS.Tc
|
||||
const SLACK = 1 + 1e-9
|
||||
|
||||
function pointsOf(leaves: readonly LeafInput[]): Map<string, { x: number; y: number }> {
|
||||
return new Map(leaves.map((l) => [l.id, l.point]))
|
||||
}
|
||||
|
||||
/** Bbox diagonal of a member set, recomputed from the original leaf anchors. */
|
||||
function memberDiag(
|
||||
members: readonly string[],
|
||||
pts: Map<string, { x: number; y: number }>
|
||||
): number {
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const id of members) {
|
||||
const p = pts.get(id)!
|
||||
minX = Math.min(minX, p.x)
|
||||
minY = Math.min(minY, p.y)
|
||||
maxX = Math.max(maxX, p.x)
|
||||
maxY = Math.max(maxY, p.y)
|
||||
}
|
||||
return Math.hypot(maxX - minX, maxY - minY)
|
||||
}
|
||||
|
||||
function zoomWalk(steps: number, seed: number): number[] {
|
||||
const rand = mulberry32(seed)
|
||||
const lnMin = Math.log(OPTS.minZoom)
|
||||
const lnMax = Math.log(OPTS.maxZoom)
|
||||
let ln = (lnMin + lnMax) / 2
|
||||
const walk: number[] = []
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const r = rand()
|
||||
if (r < 0.15) ln = lnMin + rand() * (lnMax - lnMin)
|
||||
else ln = Math.min(lnMax, Math.max(lnMin, ln + (rand() - 0.5) * 0.4))
|
||||
walk.push(Math.exp(ln))
|
||||
}
|
||||
return walk
|
||||
}
|
||||
|
||||
describe('locality bounds (the culling contract)', () => {
|
||||
it('bounds every finite event at birth: zMerge · pageDiag(result) ≤ (1+eps) · Dmax', () => {
|
||||
for (const seed of [3, 42, 77]) {
|
||||
const leaves = randomLeaves(50, seed)
|
||||
const table = computeClusterTable(leaves, OPTS)
|
||||
const pts = pointsOf(leaves)
|
||||
for (const ev of table.events) {
|
||||
if (!Number.isFinite(ev.zMerge)) continue
|
||||
const diag = memberDiag(ev.result.members, pts)
|
||||
expect(ev.zMerge * diag).toBeLessThanOrEqual((1 + OPTS.eps) * OPTS.Dmax * SLACK)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds every VISIBLE cluster during random zoom walks: member-to-centroid ≤ (1+eps)·r·Dmax screen px', () => {
|
||||
const margin = (1 + OPTS.eps) * R * OPTS.Dmax * SLACK
|
||||
for (const seed of [5, 21]) {
|
||||
const leaves = randomLeaves(40, seed * 7)
|
||||
const table = computeClusterTable(leaves, OPTS)
|
||||
const pts = pointsOf(leaves)
|
||||
const rt = createClusterRuntime(table)
|
||||
const walk = zoomWalk(200, seed)
|
||||
rt.seed(walk[0])
|
||||
for (const zoom of walk) {
|
||||
rt.onCamera(zoom)
|
||||
for (const cluster of rt.getVisible().values()) {
|
||||
if (cluster.count === 1) continue
|
||||
for (const id of cluster.members) {
|
||||
const p = pts.get(id)!
|
||||
const dist = Math.hypot(p.x - cluster.centroid.x, p.y - cluster.centroid.y)
|
||||
expect(zoom * dist).toBeLessThanOrEqual(margin)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('exercises the skewed-centroid worst case (many pins one side, one far out)', () => {
|
||||
// 9 pins tightly packed at the origin plus one at distance 35: the
|
||||
// count-weighted centroid sits near the pack, so the far member is almost
|
||||
// a FULL extent away — the case that breaks a Dmax/2-style margin.
|
||||
const leaves = [
|
||||
...Array.from({ length: 9 }, (_, i) => leaf(`pack-${i}`, (i % 3) * 2, Math.floor(i / 3) * 2)),
|
||||
leaf('far', 39, 2),
|
||||
]
|
||||
const table = computeClusterTable(leaves, OPTS)
|
||||
const pts = pointsOf(leaves)
|
||||
const full = table.events.find((ev) => ev.result.count === 10)
|
||||
expect(full).toBeDefined()
|
||||
// the far member really is beyond half the extent from the centroid…
|
||||
const diag = memberDiag(full!.result.members, pts)
|
||||
const p = pts.get('far')!
|
||||
const dist = Math.hypot(p.x - full!.result.centroid.x, p.y - full!.result.centroid.y)
|
||||
expect(dist).toBeGreaterThan(diag / 2)
|
||||
// …and still within the full-extent bound at any zoom the cluster survives
|
||||
expect(full!.zSplit * dist).toBeLessThanOrEqual((1 + OPTS.eps) * R * OPTS.Dmax * SLACK)
|
||||
})
|
||||
})
|
||||
|
||||
describe('move lifecycle (pick up = delete, drop = insert — CLUSTERING.md §9.2)', () => {
|
||||
function tableShapes(table: ClusterTable) {
|
||||
return table.events.map((ev: MergeEvent) => ({
|
||||
zMerge: ev.zMerge,
|
||||
zSplit: ev.zSplit,
|
||||
children: ev.children.map((c) => c.id),
|
||||
result: ev.result.id,
|
||||
}))
|
||||
}
|
||||
|
||||
it('drop-then-rebuild equals a fresh build of the final positions', () => {
|
||||
const before = randomLeaves(20, 11)
|
||||
// pick up leaf-5 (delete)…
|
||||
const during = before.filter((l) => l.id !== 'leaf-5')
|
||||
// …drop it somewhere else (insert at the end — array order differs from a
|
||||
// fresh build, which must not matter)
|
||||
const after = [...during, leaf('leaf-5', 777, 333)]
|
||||
const fresh = before.map((l) => (l.id === 'leaf-5' ? leaf('leaf-5', 777, 333) : l))
|
||||
|
||||
expect(tableShapes(computeClusterTable(after, OPTS))).toEqual(
|
||||
tableShapes(computeClusterTable(fresh, OPTS))
|
||||
)
|
||||
})
|
||||
|
||||
it('the mid-drag table is simply the table without the picked-up comment', () => {
|
||||
const before = randomLeaves(12, 13)
|
||||
const during = before.filter((l) => l.id !== 'leaf-3')
|
||||
const table = computeClusterTable(during, OPTS)
|
||||
expect(table.leaves.some((l) => l.id === 'leaf-3')).toBe(false)
|
||||
for (const ev of table.events) {
|
||||
expect(ev.result.members).not.toContain('leaf-3')
|
||||
}
|
||||
})
|
||||
|
||||
it('re-seeding after the rebuild matches seeding the fresh table directly', () => {
|
||||
const before = randomLeaves(20, 17)
|
||||
const after = [...before.filter((l) => l.id !== 'leaf-5'), leaf('leaf-5', 777, 333)]
|
||||
const rebuilt = createClusterRuntime(computeClusterTable(after, OPTS))
|
||||
const freshRt = createClusterRuntime(createFreshTableSameShape(after))
|
||||
for (const zoom of [0.1, 0.5, 1, 2, 4, 7.9]) {
|
||||
rebuilt.seed(zoom)
|
||||
freshRt.seed(zoom)
|
||||
expect([...rebuilt.getVisible().keys()].sort()).toEqual(
|
||||
[...freshRt.getVisible().keys()].sort()
|
||||
)
|
||||
}
|
||||
|
||||
function createFreshTableSameShape(leaves: LeafInput[]): ClusterTable {
|
||||
return computeClusterTable(leaves.slice().reverse(), OPTS)
|
||||
}
|
||||
})
|
||||
})
|
||||
383
packages/commenting/src/clustering/mst.test.ts
Normal file
383
packages/commenting/src/clustering/mst.test.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mstEdges } from './mst'
|
||||
import { LeafInput, MstEdge } from './types'
|
||||
|
||||
function leaf(id: string, x: number, y: number): LeafInput {
|
||||
return { id, point: { x, y } }
|
||||
}
|
||||
|
||||
/** An edge as a canonical `idLo|idHi` string (output edges are already id-normalized). */
|
||||
function pairKey(e: MstEdge, leaves: readonly LeafInput[]): string {
|
||||
return `${leaves[e.a].id}|${leaves[e.b].id}`
|
||||
}
|
||||
|
||||
function pairKeySet(edges: MstEdge[], leaves: readonly LeafInput[]): Set<string> {
|
||||
return new Set(edges.map((e) => pairKey(e, leaves)))
|
||||
}
|
||||
|
||||
/** Map of `idLo|idHi` → distance, for order-independent comparisons across shuffles. */
|
||||
function pairDistanceMap(edges: MstEdge[], leaves: readonly LeafInput[]): Map<string, number> {
|
||||
return new Map(edges.map((e) => [pairKey(e, leaves), e.d]))
|
||||
}
|
||||
|
||||
/** Deterministic PRNG so random tests are reproducible. */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function shuffled<T>(items: readonly T[], rand: () => number): T[] {
|
||||
const out = items.slice()
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1))
|
||||
;[out[i], out[j]] = [out[j], out[i]]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
||||
const rand = mulberry32(seed)
|
||||
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
||||
}
|
||||
|
||||
class UnionFind {
|
||||
private parent: number[]
|
||||
constructor(n: number) {
|
||||
this.parent = Array.from({ length: n }, (_, i) => i)
|
||||
}
|
||||
find(x: number): number {
|
||||
while (this.parent[x] !== x) {
|
||||
this.parent[x] = this.parent[this.parent[x]]
|
||||
x = this.parent[x]
|
||||
}
|
||||
return x
|
||||
}
|
||||
union(a: number, b: number): boolean {
|
||||
const ra = this.find(a)
|
||||
const rb = this.find(b)
|
||||
if (ra === rb) return false
|
||||
this.parent[ra] = rb
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference oracle: Kruskal over the complete graph under the contract's strict edge
|
||||
* order — (distance, idLo, idHi). Distances are compared as squares, which orders
|
||||
* identically to true distances and avoids sqrt rounding. This is the definition the
|
||||
* implementation must reproduce exactly, ties and all.
|
||||
*/
|
||||
function referenceKruskal(leaves: readonly LeafInput[]): Set<string> {
|
||||
const n = leaves.length
|
||||
if (n < 2) return new Set()
|
||||
const all: { i: number; j: number; d2: number; idLo: string; idHi: string }[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
for (let j = i + 1; j < n; j++) {
|
||||
const dx = leaves[i].point.x - leaves[j].point.x
|
||||
const dy = leaves[i].point.y - leaves[j].point.y
|
||||
const [idLo, idHi] =
|
||||
leaves[i].id < leaves[j].id ? [leaves[i].id, leaves[j].id] : [leaves[j].id, leaves[i].id]
|
||||
all.push({ i, j, d2: dx * dx + dy * dy, idLo, idHi })
|
||||
}
|
||||
}
|
||||
all.sort((a, b) => {
|
||||
if (a.d2 !== b.d2) return a.d2 - b.d2
|
||||
if (a.idLo !== b.idLo) return a.idLo < b.idLo ? -1 : 1
|
||||
return a.idHi < b.idHi ? -1 : 1
|
||||
})
|
||||
const uf = new UnionFind(n)
|
||||
const accepted = new Set<string>()
|
||||
for (const e of all) {
|
||||
if (uf.union(e.i, e.j)) {
|
||||
accepted.add(`${e.idLo}|${e.idHi}`)
|
||||
if (accepted.size === n - 1) break
|
||||
}
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
/** Assert the structural contract: n−1 edges, spanning, no duplicate pairs, valid endpoints. */
|
||||
function expectSpanningTree(edges: MstEdge[], leaves: readonly LeafInput[]) {
|
||||
const n = leaves.length
|
||||
expect(edges).toHaveLength(Math.max(0, n - 1))
|
||||
const uf = new UnionFind(n)
|
||||
const seen = new Set<string>()
|
||||
for (const e of edges) {
|
||||
expect(Number.isInteger(e.a)).toBe(true)
|
||||
expect(Number.isInteger(e.b)).toBe(true)
|
||||
expect(e.a).toBeGreaterThanOrEqual(0)
|
||||
expect(e.b).toBeGreaterThanOrEqual(0)
|
||||
expect(e.a).toBeLessThan(n)
|
||||
expect(e.b).toBeLessThan(n)
|
||||
expect(e.a).not.toBe(e.b)
|
||||
const key = pairKey(e, leaves)
|
||||
expect(seen.has(key)).toBe(false)
|
||||
seen.add(key)
|
||||
// n−1 accepted unions with no rejected ones ⇒ connected and acyclic
|
||||
expect(uf.union(e.a, e.b)).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert output ordering (d, then idLo, then idHi) and endpoint id-normalization. */
|
||||
function expectSortedAndNormalized(edges: MstEdge[], leaves: readonly LeafInput[]) {
|
||||
for (const e of edges) {
|
||||
expect(leaves[e.a].id < leaves[e.b].id).toBe(true)
|
||||
}
|
||||
for (let i = 1; i < edges.length; i++) {
|
||||
const prev = edges[i - 1]
|
||||
const curr = edges[i]
|
||||
if (prev.d !== curr.d) {
|
||||
expect(prev.d).toBeLessThan(curr.d)
|
||||
continue
|
||||
}
|
||||
const prevLo = leaves[prev.a].id
|
||||
const currLo = leaves[curr.a].id
|
||||
if (prevLo !== currLo) {
|
||||
expect(prevLo < currLo).toBe(true)
|
||||
continue
|
||||
}
|
||||
expect(leaves[prev.b].id < leaves[curr.b].id).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Assert each edge's distance is the exact Math.hypot of its endpoints. */
|
||||
function expectExactDistances(edges: MstEdge[], leaves: readonly LeafInput[]) {
|
||||
for (const e of edges) {
|
||||
const expected = Math.hypot(
|
||||
leaves[e.a].point.x - leaves[e.b].point.x,
|
||||
leaves[e.a].point.y - leaves[e.b].point.y
|
||||
)
|
||||
expect(e.d).toBe(expected)
|
||||
}
|
||||
}
|
||||
|
||||
describe('mstEdges fixtures', () => {
|
||||
it('returns [] for zero and one leaves', () => {
|
||||
expect(mstEdges([])).toEqual([])
|
||||
expect(mstEdges([leaf('only', 5, 5)])).toEqual([])
|
||||
})
|
||||
|
||||
it('connects two points with one exact-distance edge', () => {
|
||||
const leaves = [leaf('a', 0, 0), leaf('b', 3, 4)]
|
||||
expect(mstEdges(leaves)).toEqual([{ a: 0, b: 1, d: 5 }])
|
||||
})
|
||||
|
||||
it('normalizes endpoints by id, not input order', () => {
|
||||
// 'b' comes first in the array; the edge must still point a→'a', b→'b'
|
||||
const leaves = [leaf('b', 3, 4), leaf('a', 0, 0)]
|
||||
expect(mstEdges(leaves)).toEqual([{ a: 1, b: 0, d: 5 }])
|
||||
})
|
||||
|
||||
it('handles coincident points with a d = 0 edge', () => {
|
||||
const leaves = [leaf('a', 7, 7), leaf('b', 7, 7)]
|
||||
expect(mstEdges(leaves)).toEqual([{ a: 0, b: 1, d: 0 }])
|
||||
})
|
||||
|
||||
it('builds the path MST for collinear points with distinct gaps, sorted ascending', () => {
|
||||
// x positions 0, 1, 3, 7 → consecutive gaps 1, 2, 4; no skip edge can beat them
|
||||
const leaves = [leaf('a', 0, 0), leaf('b', 1, 0), leaf('c', 3, 0), leaf('d', 7, 0)]
|
||||
expect(mstEdges(leaves)).toEqual([
|
||||
{ a: 0, b: 1, d: 1 },
|
||||
{ a: 1, b: 2, d: 2 },
|
||||
{ a: 2, b: 3, d: 4 },
|
||||
])
|
||||
})
|
||||
|
||||
it('breaks four-way side ties of the unit square by id order', () => {
|
||||
// All four sides have d = 1. Kruskal under (d, idLo, idHi) accepts
|
||||
// (a,b), then (a,d), then (b,c); (c,d) would close the cycle.
|
||||
const leaves = [leaf('a', 0, 0), leaf('b', 1, 0), leaf('c', 1, 1), leaf('d', 0, 1)]
|
||||
expect(mstEdges(leaves)).toEqual([
|
||||
{ a: 0, b: 1, d: 1 },
|
||||
{ a: 0, b: 3, d: 1 },
|
||||
{ a: 1, b: 2, d: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
it('builds the path MST for the uniform chain (the clustering worst case)', () => {
|
||||
// 8 pins spaced 10 apart — CLUSTERING.md appendix A.2. All 7 edges d = 10.
|
||||
const leaves = Array.from({ length: 8 }, (_, i) => leaf(`p${i + 1}`, i * 10, 0))
|
||||
const edges = mstEdges(leaves)
|
||||
expect(edges.map((e) => pairKey(e, leaves))).toEqual([
|
||||
'p1|p2',
|
||||
'p2|p3',
|
||||
'p3|p4',
|
||||
'p4|p5',
|
||||
'p5|p6',
|
||||
'p6|p7',
|
||||
'p7|p8',
|
||||
])
|
||||
for (const e of edges) expect(e.d).toBe(10)
|
||||
})
|
||||
|
||||
it('crosses a dumbbell gap exactly once, via the closest pair (cut property)', () => {
|
||||
// Two tight clusters far apart; l2/r0 is the unique closest crossing pair.
|
||||
const leaves = [
|
||||
leaf('l0', 0, 0),
|
||||
leaf('l1', 2, 1),
|
||||
leaf('l2', 4, 0),
|
||||
leaf('r0', 100, 0),
|
||||
leaf('r1', 102, 1),
|
||||
leaf('r2', 104, 0),
|
||||
]
|
||||
const edges = mstEdges(leaves)
|
||||
const crossings = edges.filter(
|
||||
(e) => leaves[e.a].id.startsWith('l') !== leaves[e.b].id.startsWith('l')
|
||||
)
|
||||
expect(crossings).toHaveLength(1)
|
||||
expect(pairKey(crossings[0], leaves)).toBe('l2|r0')
|
||||
expect(crossings[0].d).toBe(96)
|
||||
})
|
||||
|
||||
it('chains coincident points with d = 0 edges plus one bridge', () => {
|
||||
const leaves = [leaf('a', 5, 5), leaf('b', 5, 5), leaf('c', 5, 5), leaf('far', 105, 5)]
|
||||
const edges = mstEdges(leaves)
|
||||
const zeros = edges.filter((e) => e.d === 0)
|
||||
const bridges = edges.filter((e) => e.d !== 0)
|
||||
expect(zeros).toHaveLength(2)
|
||||
expect(bridges).toHaveLength(1)
|
||||
expect(bridges[0].d).toBe(100)
|
||||
// zero edges come first in the ascending sort, tie-broken by id: (a,b) then (a,c)
|
||||
expect(edges.slice(0, 2).map((e) => pairKey(e, leaves))).toEqual(['a|b', 'a|c'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mstEdges vs reference Kruskal oracle', () => {
|
||||
it.each([2, 3, 5, 10, 50, 120])('matches the oracle on %i random points', (n) => {
|
||||
for (const seed of [1, 42, 1234]) {
|
||||
const leaves = randomLeaves(n, seed * 31 + n)
|
||||
const edges = mstEdges(leaves)
|
||||
expectSpanningTree(edges, leaves)
|
||||
expectSortedAndNormalized(edges, leaves)
|
||||
expectExactDistances(edges, leaves)
|
||||
expect(pairKeySet(edges, leaves)).toEqual(referenceKruskal(leaves))
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the oracle on a 5×5 integer grid (maximal distance ties)', () => {
|
||||
const leaves: LeafInput[] = []
|
||||
for (let y = 0; y < 5; y++) {
|
||||
for (let x = 0; x < 5; x++) {
|
||||
leaves.push(leaf(`g-${x}-${y}`, x, y))
|
||||
}
|
||||
}
|
||||
const edges = mstEdges(leaves)
|
||||
expectSpanningTree(edges, leaves)
|
||||
expectSortedAndNormalized(edges, leaves)
|
||||
// every grid MST edge is an axis-aligned unit step
|
||||
for (const e of edges) expect(e.d).toBe(1)
|
||||
expect(pairKeySet(edges, leaves)).toEqual(referenceKruskal(leaves))
|
||||
})
|
||||
|
||||
it('matches the oracle across realistic canvas coordinate regimes', () => {
|
||||
// Canvas pages routinely have negative coordinates (panning left/up of the
|
||||
// origin), large offsets, and tightly packed points — none of which the
|
||||
// uniform [0, 1000]² sets exercise.
|
||||
const regimes: { name: string; make(rand: () => number, i: number): LeafInput }[] = [
|
||||
{
|
||||
name: 'mixed-sign',
|
||||
make: (rand, i) => leaf(`m-${i}`, (rand() - 0.5) * 2000, (rand() - 0.5) * 2000),
|
||||
},
|
||||
{
|
||||
name: 'far-offset',
|
||||
make: (rand, i) => leaf(`f-${i}`, -5_000_000 + rand() * 1000, 3_000_000 + rand() * 1000),
|
||||
},
|
||||
{
|
||||
name: 'tiny-scale',
|
||||
make: (rand, i) => leaf(`t-${i}`, rand() * 0.001, rand() * 0.001),
|
||||
},
|
||||
]
|
||||
for (const regime of regimes) {
|
||||
const rand = mulberry32(101)
|
||||
const leaves = Array.from({ length: 60 }, (_, i) => regime.make(rand, i))
|
||||
const edges = mstEdges(leaves)
|
||||
expectSpanningTree(edges, leaves)
|
||||
expectSortedAndNormalized(edges, leaves)
|
||||
expectExactDistances(edges, leaves)
|
||||
expect(pairKeySet(edges, leaves)).toEqual(referenceKruskal(leaves))
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the oracle on random lattice points (ties + coincident points)', () => {
|
||||
const rand = mulberry32(7)
|
||||
const leaves = Array.from({ length: 60 }, (_, i) =>
|
||||
leaf(`lat-${i}`, Math.floor(rand() * 10), Math.floor(rand() * 10))
|
||||
)
|
||||
const edges = mstEdges(leaves)
|
||||
expectSpanningTree(edges, leaves)
|
||||
expectSortedAndNormalized(edges, leaves)
|
||||
expectExactDistances(edges, leaves)
|
||||
expect(pairKeySet(edges, leaves)).toEqual(referenceKruskal(leaves))
|
||||
})
|
||||
})
|
||||
|
||||
describe('mstEdges determinism', () => {
|
||||
it('is invariant under input permutation (as an id-pair → distance map)', () => {
|
||||
const base = randomLeaves(80, 99)
|
||||
const expected = pairDistanceMap(mstEdges(base), base)
|
||||
for (const shuffleSeed of [5, 17, 23]) {
|
||||
const perm = shuffled(base, mulberry32(shuffleSeed))
|
||||
expect(pairDistanceMap(mstEdges(perm), perm)).toEqual(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('is invariant under permutation even with heavy ties', () => {
|
||||
// Uniform chains have all-equal edge lengths — tie-breaking must be id-based,
|
||||
// never index-based, or a shuffled rebuild would produce a different tree.
|
||||
const base = Array.from({ length: 12 }, (_, i) =>
|
||||
leaf(`c${String(i).padStart(2, '0')}`, i * 10, 0)
|
||||
)
|
||||
const expected = pairDistanceMap(mstEdges(base), base)
|
||||
for (const shuffleSeed of [3, 11]) {
|
||||
const perm = shuffled(base, mulberry32(shuffleSeed))
|
||||
expect(pairDistanceMap(mstEdges(perm), perm)).toEqual(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns identical output for repeated calls on the same input', () => {
|
||||
const leaves = randomLeaves(40, 7)
|
||||
expect(mstEdges(leaves)).toEqual(mstEdges(leaves))
|
||||
})
|
||||
|
||||
it('does not mutate its input', () => {
|
||||
const leaves = randomLeaves(10, 3)
|
||||
const snapshot = JSON.parse(JSON.stringify(leaves))
|
||||
mstEdges(leaves)
|
||||
expect(leaves).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mstEdges validation', () => {
|
||||
it('throws on duplicate ids, naming the duplicate', () => {
|
||||
const leaves = [leaf('a', 0, 0), leaf('dup', 1, 0), leaf('dup', 2, 0)]
|
||||
expect(() => mstEdges(leaves)).toThrow(/dup/)
|
||||
})
|
||||
|
||||
it('throws on non-finite coordinates, naming the leaf', () => {
|
||||
expect(() => mstEdges([leaf('ok', 0, 0), leaf('bad-x', NaN, 0)])).toThrow(/bad-x/)
|
||||
expect(() => mstEdges([leaf('ok', 0, 0), leaf('bad-y', 0, Infinity)])).toThrow(/bad-y/)
|
||||
expect(() => mstEdges([leaf('ok', 0, 0), leaf('bad-neg', 0, -Infinity)])).toThrow(/bad-neg/)
|
||||
})
|
||||
|
||||
it('fails fast on invalid input even when n < 2', () => {
|
||||
expect(() => mstEdges([leaf('bad', NaN, NaN)])).toThrow(/bad/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mstEdges performance', () => {
|
||||
it('handles 4,000 points well within budget', () => {
|
||||
const leaves = randomLeaves(4000, 2026)
|
||||
const start = performance.now()
|
||||
const edges = mstEdges(leaves)
|
||||
const elapsed = performance.now() - start
|
||||
expect(edges).toHaveLength(3999)
|
||||
// generous bound to stay non-flaky on slow CI; typical runs are ~100× faster
|
||||
expect(elapsed).toBeLessThan(2000)
|
||||
})
|
||||
})
|
||||
134
packages/commenting/src/clustering/mst.ts
Normal file
134
packages/commenting/src/clustering/mst.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { LeafInput, MstEdge } from './types'
|
||||
|
||||
/**
|
||||
* Return the Euclidean MST over the leaves, ordered by the canonical edge key:
|
||||
* distance, then normalized endpoint ids.
|
||||
*/
|
||||
export function mstEdges(leaves: readonly LeafInput[]): MstEdge[] {
|
||||
const n = leaves.length
|
||||
const ids = new Array<string>(n)
|
||||
const xs = new Float64Array(n)
|
||||
const ys = new Float64Array(n)
|
||||
const seen = new Set<string>()
|
||||
|
||||
let root = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const leaf = leaves[i]
|
||||
if (seen.has(leaf.id)) {
|
||||
throw new Error(`Duplicate leaf id: ${leaf.id}`)
|
||||
}
|
||||
seen.add(leaf.id)
|
||||
if (!Number.isFinite(leaf.point.x) || !Number.isFinite(leaf.point.y)) {
|
||||
throw new Error(`Non-finite coordinate for leaf id: ${leaf.id}`)
|
||||
}
|
||||
ids[i] = leaf.id
|
||||
xs[i] = leaf.point.x
|
||||
ys[i] = leaf.point.y
|
||||
if (i > 0 && leaf.id < ids[root]) root = i
|
||||
}
|
||||
|
||||
if (n < 2) return []
|
||||
|
||||
const inTree = new Uint8Array(n)
|
||||
const bestD2 = new Float64Array(n)
|
||||
const bestFrom = new Int32Array(n)
|
||||
|
||||
inTree[root] = 1
|
||||
bestD2.fill(Number.POSITIVE_INFINITY)
|
||||
bestFrom.fill(-1)
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (i === root) continue
|
||||
bestD2[i] = squaredDistance(xs, ys, root, i)
|
||||
bestFrom[i] = root
|
||||
}
|
||||
|
||||
const edges: MstEdge[] = []
|
||||
|
||||
for (let edgeCount = 0; edgeCount < n - 1; edgeCount++) {
|
||||
let next = -1
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (inTree[i]) continue
|
||||
if (
|
||||
next === -1 ||
|
||||
edgeKeyLess(bestD2[i], bestFrom[i], i, bestD2[next], bestFrom[next], next, ids)
|
||||
) {
|
||||
next = i
|
||||
}
|
||||
}
|
||||
|
||||
const from = bestFrom[next]
|
||||
edges.push(createOutputEdge(xs, ys, ids, from, next))
|
||||
inTree[next] = 1
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (inTree[i]) continue
|
||||
const d2 = squaredDistance(xs, ys, next, i)
|
||||
if (edgeKeyLess(d2, next, i, bestD2[i], bestFrom[i], i, ids)) {
|
||||
bestD2[i] = d2
|
||||
bestFrom[i] = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
edges.sort((a, b) => outputEdgeCompare(a, b, leaves))
|
||||
return edges
|
||||
}
|
||||
|
||||
function squaredDistance(xs: Float64Array, ys: Float64Array, a: number, b: number): number {
|
||||
const dx = xs[a] - xs[b]
|
||||
const dy = ys[a] - ys[b]
|
||||
return dx * dx + dy * dy
|
||||
}
|
||||
|
||||
function edgeKeyLess(
|
||||
d2A: number,
|
||||
a0: number,
|
||||
a1: number,
|
||||
d2B: number,
|
||||
b0: number,
|
||||
b1: number,
|
||||
ids: readonly string[]
|
||||
): boolean {
|
||||
if (d2A !== d2B) return d2A < d2B
|
||||
|
||||
const aLo = ids[a0] < ids[a1] ? ids[a0] : ids[a1]
|
||||
const aHi = ids[a0] < ids[a1] ? ids[a1] : ids[a0]
|
||||
const bLo = ids[b0] < ids[b1] ? ids[b0] : ids[b1]
|
||||
const bHi = ids[b0] < ids[b1] ? ids[b1] : ids[b0]
|
||||
|
||||
if (aLo !== bLo) return aLo < bLo
|
||||
return aHi < bHi
|
||||
}
|
||||
|
||||
function createOutputEdge(
|
||||
xs: Float64Array,
|
||||
ys: Float64Array,
|
||||
ids: readonly string[],
|
||||
i: number,
|
||||
j: number
|
||||
): MstEdge {
|
||||
const a = ids[i] < ids[j] ? i : j
|
||||
const b = ids[i] < ids[j] ? j : i
|
||||
return {
|
||||
a,
|
||||
b,
|
||||
d: Math.hypot(xs[a] - xs[b], ys[a] - ys[b]),
|
||||
}
|
||||
}
|
||||
|
||||
function outputEdgeCompare(a: MstEdge, b: MstEdge, leaves: readonly LeafInput[]): number {
|
||||
if (a.d < b.d) return -1
|
||||
if (a.d > b.d) return 1
|
||||
|
||||
const aLo = leaves[a.a].id
|
||||
const aHi = leaves[a.b].id
|
||||
const bLo = leaves[b.a].id
|
||||
const bHi = leaves[b.b].id
|
||||
|
||||
if (aLo < bLo) return -1
|
||||
if (aLo > bLo) return 1
|
||||
if (aHi < bHi) return -1
|
||||
if (aHi > bHi) return 1
|
||||
return 0
|
||||
}
|
||||
540
packages/commenting/src/clustering/replay.test.ts
Normal file
540
packages/commenting/src/clustering/replay.test.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mstEdges } from './mst'
|
||||
// These imports are red until step 2 is implemented — that is intentional.
|
||||
// Implement `replay.ts` (and the step 2 additions to `types.ts`) per
|
||||
// CLUSTERING-STEPS.md until this suite passes, without modifying this file.
|
||||
import { cappedReplay, D_FLOOR } from './replay'
|
||||
import { ClusterNode, LeafInput, MstEdge, RawMergeEvent } from './types'
|
||||
|
||||
function leaf(id: string, x: number, y: number): LeafInput {
|
||||
return { id, point: { x, y } }
|
||||
}
|
||||
|
||||
/** Deterministic PRNG so random tests are reproducible. */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function shuffled<T>(items: readonly T[], rand: () => number): T[] {
|
||||
const out = items.slice()
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1))
|
||||
;[out[i], out[j]] = [out[j], out[i]]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
||||
const rand = mulberry32(seed)
|
||||
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
||||
}
|
||||
|
||||
/** Order-independent, index-free projection of an event for cross-run comparison. */
|
||||
function eventShape(ev: RawMergeEvent) {
|
||||
return {
|
||||
z: ev.z,
|
||||
children: ev.children.map((c) => c.id),
|
||||
result: ev.result.id,
|
||||
members: ev.result.members,
|
||||
count: ev.result.count,
|
||||
}
|
||||
}
|
||||
|
||||
function eventShapes(events: RawMergeEvent[]) {
|
||||
return events.map(eventShape)
|
||||
}
|
||||
|
||||
// Direct transcription of contract clause 4 (CLUSTERING-STEPS.md step 2): at
|
||||
// each of the n−1 steps, evaluate every unfired MST edge's zEff against the
|
||||
// CURRENT clusters and fire the highest, tie-broken by the edge's normalized
|
||||
// id pair. O(n · E) per build — slow and obviously correct.
|
||||
|
||||
interface RefCluster {
|
||||
members: string[] // sorted lexicographic; members[0] is the min-member id
|
||||
centroid: { x: number; y: number }
|
||||
count: number
|
||||
minX: number
|
||||
minY: number
|
||||
maxX: number
|
||||
maxY: number
|
||||
}
|
||||
|
||||
function refNode(c: RefCluster): ClusterNode {
|
||||
return {
|
||||
id: c.count === 1 ? c.members[0] : `cluster:${c.count}:${c.members[0]}`,
|
||||
centroid: { x: c.centroid.x, y: c.centroid.y },
|
||||
count: c.count,
|
||||
members: c.members.slice(),
|
||||
}
|
||||
}
|
||||
|
||||
function refDiag(a: RefCluster, b: RefCluster): number {
|
||||
const w = Math.max(a.maxX, b.maxX) - Math.min(a.minX, b.minX)
|
||||
const h = Math.max(a.maxY, b.maxY) - Math.min(a.minY, b.minY)
|
||||
return Math.hypot(w, h)
|
||||
}
|
||||
|
||||
function referenceReplay(
|
||||
leaves: readonly LeafInput[],
|
||||
edges: readonly MstEdge[],
|
||||
opts: { Tc: number; Dmax: number }
|
||||
): RawMergeEvent[] {
|
||||
const n = leaves.length
|
||||
const parent = Array.from({ length: n }, (_, i) => i)
|
||||
const find = (x: number): number => {
|
||||
while (parent[x] !== x) {
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
}
|
||||
return x
|
||||
}
|
||||
const clusters = new Map<number, RefCluster>()
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p = leaves[i].point
|
||||
clusters.set(i, {
|
||||
members: [leaves[i].id],
|
||||
centroid: { x: p.x, y: p.y },
|
||||
count: 1,
|
||||
minX: p.x,
|
||||
minY: p.y,
|
||||
maxX: p.x,
|
||||
maxY: p.y,
|
||||
})
|
||||
}
|
||||
|
||||
const zOf = (e: MstEdge): number => {
|
||||
// coincident anchors: permanently merged; the fit term is not consulted
|
||||
if (e.d < D_FLOOR) return Infinity
|
||||
const a = clusters.get(find(e.a))!
|
||||
const b = clusters.get(find(e.b))!
|
||||
// badge-anchored gap: priced by the clusters' centroid distance
|
||||
const gap = opts.Tc / Math.hypot(a.centroid.x - b.centroid.x, a.centroid.y - b.centroid.y)
|
||||
const fit = opts.Dmax / refDiag(a, b)
|
||||
return Math.min(gap, fit)
|
||||
}
|
||||
|
||||
const unfired = edges.slice()
|
||||
const events: RawMergeEvent[] = []
|
||||
let lastZ = Infinity // emitted thresholds are clamped non-increasing
|
||||
while (unfired.length > 0) {
|
||||
let best = 0
|
||||
let bestZ = zOf(unfired[0])
|
||||
for (let i = 1; i < unfired.length; i++) {
|
||||
const z = zOf(unfired[i])
|
||||
if (z > bestZ) {
|
||||
best = i
|
||||
bestZ = z
|
||||
continue
|
||||
}
|
||||
if (z === bestZ) {
|
||||
// tie-break by the edge's normalized id pair, ascending idLo then idHi
|
||||
const a = unfired[i]
|
||||
const b = unfired[best]
|
||||
const aLo = leaves[a.a].id
|
||||
const aHi = leaves[a.b].id
|
||||
const bLo = leaves[b.a].id
|
||||
const bHi = leaves[b.b].id
|
||||
if (aLo < bLo || (aLo === bLo && aHi < bHi)) {
|
||||
best = i
|
||||
bestZ = z
|
||||
}
|
||||
}
|
||||
}
|
||||
const e = unfired.splice(best, 1)[0]
|
||||
const ra = find(e.a)
|
||||
const rb = find(e.b)
|
||||
const ca = clusters.get(ra)!
|
||||
const cb = clusters.get(rb)!
|
||||
const [first, second] = ca.members[0] < cb.members[0] ? [ca, cb] : [cb, ca]
|
||||
const count = ca.count + cb.count
|
||||
const merged: RefCluster = {
|
||||
members: [...ca.members, ...cb.members].sort(),
|
||||
centroid: {
|
||||
x: (ca.count * ca.centroid.x + cb.count * cb.centroid.x) / count,
|
||||
y: (ca.count * ca.centroid.y + cb.count * cb.centroid.y) / count,
|
||||
},
|
||||
count,
|
||||
minX: Math.min(ca.minX, cb.minX),
|
||||
minY: Math.min(ca.minY, cb.minY),
|
||||
maxX: Math.max(ca.maxX, cb.maxX),
|
||||
maxY: Math.max(ca.maxY, cb.maxY),
|
||||
}
|
||||
parent[ra] = rb
|
||||
clusters.delete(ra)
|
||||
clusters.set(find(rb), merged)
|
||||
const z = Math.min(bestZ, lastZ)
|
||||
lastZ = z
|
||||
events.push({
|
||||
z,
|
||||
children: [refNode(first), refNode(second)],
|
||||
result: refNode(merged),
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
function expectNonIncreasingZ(events: RawMergeEvent[]) {
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
expect(events[i].z).toBeLessThanOrEqual(events[i - 1].z)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dendrogram integrity: children are consumed exactly once, results are well
|
||||
* formed unions, and replaying all events over the leaves ends in one root.
|
||||
*/
|
||||
function expectDendrogramIntegrity(events: RawMergeEvent[], leaves: readonly LeafInput[]) {
|
||||
const live = new Map<string, ClusterNode>()
|
||||
for (const l of leaves) {
|
||||
live.set(l.id, { id: l.id, centroid: { ...l.point }, count: 1, members: [l.id] })
|
||||
}
|
||||
for (const ev of events) {
|
||||
expect(ev.children).toHaveLength(2)
|
||||
// children must currently exist (consumed exactly once, produced before use)
|
||||
for (const child of ev.children) {
|
||||
expect(live.get(child.id)).toBeDefined()
|
||||
live.delete(child.id)
|
||||
}
|
||||
// children tuple ordered by ascending min-member id
|
||||
expect(ev.children[0].members[0] < ev.children[1].members[0]).toBe(true)
|
||||
// result members = sorted union of children members
|
||||
const union = [...ev.children[0].members, ...ev.children[1].members].sort()
|
||||
expect(ev.result.members).toEqual(union)
|
||||
expect(ev.result.count).toBe(ev.children[0].count + ev.children[1].count)
|
||||
expect(ev.result.id).toBe(`cluster:${ev.result.count}:${union[0]}`)
|
||||
expect(live.has(ev.result.id)).toBe(false)
|
||||
live.set(ev.result.id, ev.result)
|
||||
}
|
||||
// n−1 merges over a spanning tree always end in a single root
|
||||
if (leaves.length >= 2) {
|
||||
expect(live.size).toBe(1)
|
||||
const root = [...live.values()][0]
|
||||
expect(root.members).toEqual(leaves.map((l) => l.id).sort())
|
||||
}
|
||||
}
|
||||
|
||||
/** Locality invariant: at its birth zoom, a cluster spans at most Dmax screen px. */
|
||||
function expectLocality(events: RawMergeEvent[], leaves: readonly LeafInput[], Dmax: number) {
|
||||
const pointOf = new Map(leaves.map((l) => [l.id, l.point]))
|
||||
for (const ev of events) {
|
||||
if (!Number.isFinite(ev.z)) continue
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const id of ev.result.members) {
|
||||
const p = pointOf.get(id)!
|
||||
minX = Math.min(minX, p.x)
|
||||
minY = Math.min(minY, p.y)
|
||||
maxX = Math.max(maxX, p.x)
|
||||
maxY = Math.max(maxY, p.y)
|
||||
}
|
||||
const diag = Math.hypot(maxX - minX, maxY - minY)
|
||||
expect(ev.z * diag).toBeLessThanOrEqual(Dmax * (1 + 1e-9))
|
||||
}
|
||||
}
|
||||
|
||||
/** Centroids equal the count-weighted mean = plain mean of all member anchors. */
|
||||
function expectCentroids(events: RawMergeEvent[], leaves: readonly LeafInput[]) {
|
||||
const pointOf = new Map(leaves.map((l) => [l.id, l.point]))
|
||||
for (const ev of events) {
|
||||
let sx = 0
|
||||
let sy = 0
|
||||
for (const id of ev.result.members) {
|
||||
const p = pointOf.get(id)!
|
||||
sx += p.x
|
||||
sy += p.y
|
||||
}
|
||||
expect(ev.result.centroid.x).toBeCloseTo(sx / ev.result.count, 8)
|
||||
expect(ev.result.centroid.y).toBeCloseTo(sy / ev.result.count, 8)
|
||||
}
|
||||
}
|
||||
|
||||
describe('cappedReplay fixtures', () => {
|
||||
it('exports the pinned D_FLOOR constant', () => {
|
||||
expect(D_FLOOR).toBe(1e-9)
|
||||
})
|
||||
|
||||
it('returns [] for fewer than two leaves', () => {
|
||||
expect(cappedReplay([], [], { Tc: 40, Dmax: 120 })).toEqual([])
|
||||
const one = [leaf('only', 3, 3)]
|
||||
expect(cappedReplay(one, mstEdges(one), { Tc: 40, Dmax: 120 })).toEqual([])
|
||||
})
|
||||
|
||||
it('merges a gap-limited pair at z = Tc/d with exact node bookkeeping', () => {
|
||||
const leaves = [leaf('a', 0, 0), leaf('b', 10, 0)]
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
||||
expect(events).toEqual([
|
||||
{
|
||||
z: 4,
|
||||
children: [
|
||||
{ id: 'a', centroid: { x: 0, y: 0 }, count: 1, members: ['a'] },
|
||||
{ id: 'b', centroid: { x: 10, y: 0 }, count: 1, members: ['b'] },
|
||||
],
|
||||
result: { id: 'cluster:2:a', centroid: { x: 5, y: 0 }, count: 2, members: ['a', 'b'] },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('gives coincident anchors z = Infinity', () => {
|
||||
const leaves = [leaf('a', 7, 7), leaf('b', 7, 7)]
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0].z).toBe(Infinity)
|
||||
expect(events[0].result.id).toBe('cluster:2:a')
|
||||
})
|
||||
|
||||
it('treats sub-D_FLOOR distances as coincident, but not distances above it', () => {
|
||||
const below = [leaf('a', 0, 0), leaf('b', 1e-12, 0)]
|
||||
expect(cappedReplay(below, mstEdges(below), { Tc: 40, Dmax: 120 })[0].z).toBe(Infinity)
|
||||
const above = [leaf('a', 0, 0), leaf('b', 1e-6, 0)]
|
||||
const [ev] = cappedReplay(above, mstEdges(above), { Tc: 40, Dmax: 120 })
|
||||
expect(ev.z).toBe(40 / 1e-6)
|
||||
expect(Number.isFinite(ev.z)).toBe(true)
|
||||
})
|
||||
|
||||
it('fires the coincident merge first, then the bridge (Infinity sorts to the head)', () => {
|
||||
const leaves = [leaf('a', 5, 5), leaf('b', 5, 5), leaf('c', 105, 5)]
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
||||
expect(eventShapes(events)).toEqual([
|
||||
{ z: Infinity, children: ['a', 'b'], result: 'cluster:2:a', members: ['a', 'b'], count: 2 },
|
||||
{
|
||||
z: 40 / 100,
|
||||
children: ['cluster:2:a', 'c'],
|
||||
result: 'cluster:3:a',
|
||||
members: ['a', 'b', 'c'],
|
||||
count: 3,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('replays the ABCDE path of CLUSTERING.md appendix A.1 (cap not binding)', () => {
|
||||
// a —10— b —25— c —12— d —40— e, Tc = 300, cap effectively off.
|
||||
// The MST edges still decide WHICH clusters merge; the gap term prices
|
||||
// each merge by the clusters' CENTROID distance:
|
||||
// a+b at 300/10 (leaves: centroid dist = edge length)
|
||||
// c+d at 300/12... no — c–d edge, leaves, d = 12 → 300/12 = 25
|
||||
// {a,b}+{c,d} via b–c: centroids (5,0) vs (41,0) → 300/36
|
||||
// +e via d–e: centroids (23,0) vs (87,0) → 300/64
|
||||
const leaves = [
|
||||
leaf('a', 0, 0),
|
||||
leaf('b', 10, 0),
|
||||
leaf('c', 35, 0),
|
||||
leaf('d', 47, 0),
|
||||
leaf('e', 87, 0),
|
||||
]
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 300, Dmax: 1e9 })
|
||||
expect(eventShapes(events)).toEqual([
|
||||
{ z: 30, children: ['a', 'b'], result: 'cluster:2:a', members: ['a', 'b'], count: 2 },
|
||||
{ z: 25, children: ['c', 'd'], result: 'cluster:2:c', members: ['c', 'd'], count: 2 },
|
||||
{
|
||||
z: 300 / 36,
|
||||
children: ['cluster:2:a', 'cluster:2:c'],
|
||||
result: 'cluster:4:a',
|
||||
members: ['a', 'b', 'c', 'd'],
|
||||
count: 4,
|
||||
},
|
||||
{
|
||||
z: 300 / 64,
|
||||
children: ['cluster:4:a', 'e'],
|
||||
result: 'cluster:5:a',
|
||||
members: ['a', 'b', 'c', 'd', 'e'],
|
||||
count: 5,
|
||||
},
|
||||
])
|
||||
// exact incremental centroids: (5,0), (41,0), (23,0), (35.8,0)
|
||||
expect(events.map((e) => e.result.centroid.x)).toEqual([5, 41, 23, 35.8])
|
||||
})
|
||||
|
||||
it('prices cluster merges by badge (centroid) distance, not nearest members', () => {
|
||||
// Two tight pairs whose facing members are only 14 apart but whose
|
||||
// centroids are 20 apart. Nearest-member pricing would join the badges at
|
||||
// z = 40/14 ≈ 2.86 — while the rendered badges still look far apart.
|
||||
// Centroid pricing fires at 40/20 = 2.
|
||||
const leaves = [leaf('a', 0, 0), leaf('b', 6, 0), leaf('c', 20, 0), leaf('d', 26, 0)]
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 1000 })
|
||||
expect(events.map((e) => e.z)).toEqual([40 / 6, 40 / 6, 2])
|
||||
expect(events[2].children.map((c) => c.id)).toEqual(['cluster:2:a', 'cluster:2:c'])
|
||||
})
|
||||
|
||||
it('builds the 8-pin chain hierarchically: pairs, then quads, then the octet', () => {
|
||||
// p1..p8 spaced 10 apart, Tc = 40, Dmax = 120 (CLUSTERING.md appendix A.2).
|
||||
// Leaf pairs all price at 40/10 = 4. Once a pair forms, extending it is
|
||||
// priced from the pair's CENTROID (dist 15 → z ≈ 2.67), which loses ties
|
||||
// against the remaining leaf pairs — so the chain forms 4 pairs first.
|
||||
// Pair-pair merges price at centroid dist 20 → z = 2 (quads), and the
|
||||
// final quad-quad bridge at centroid dist 40 (centroids 15 and 55) → z = 1.
|
||||
// Centroid pricing alone now produces the progressive doubling the cap
|
||||
// used to force; the cap remains as the hard extent guarantee.
|
||||
const leaves = Array.from({ length: 8 }, (_, i) => leaf(`p${i + 1}`, i * 10, 0))
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
||||
expect(events.map((e) => e.z)).toEqual([4, 4, 4, 4, 2, 2, 1])
|
||||
expect(events.map((e) => e.result.id)).toEqual([
|
||||
'cluster:2:p1',
|
||||
'cluster:2:p3',
|
||||
'cluster:2:p5',
|
||||
'cluster:2:p7',
|
||||
'cluster:4:p1',
|
||||
'cluster:4:p5',
|
||||
'cluster:8:p1',
|
||||
])
|
||||
expect(events[4].children.map((c) => c.id)).toEqual(['cluster:2:p1', 'cluster:2:p3'])
|
||||
expect(events[6].children.map((c) => c.id)).toEqual(['cluster:4:p1', 'cluster:4:p5'])
|
||||
expect(events[6].result.members).toEqual(['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8'])
|
||||
})
|
||||
|
||||
it('computes the fit term from the union bbox DIAGONAL, not an axis span', () => {
|
||||
// {a,b} spans (0,0)–(0,30); adding c(40,30) makes the union bbox 40×30,
|
||||
// diag hypot(40,30) = 50. With Tc = 100, Dmax = 110:
|
||||
// a–b: leaves at dist 30 → min(100/30, 110/30) = 100/30 (gap-limited)
|
||||
// b–c after a+b: centroid (0,15) to c(40,30) → gap = 100/√1825 ≈ 2.34;
|
||||
// fit via the diagonal = 110/50 = 2.2 wins (an axis-span implementation
|
||||
// would give 110/40 = 2.75 and the gap term would win instead)
|
||||
const leaves = [leaf('a', 0, 0), leaf('b', 0, 30), leaf('c', 40, 30)]
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 100, Dmax: 110 })
|
||||
expect(events.map((e) => e.z)).toEqual([100 / 30, 2.2])
|
||||
expect(events[1].result.id).toBe('cluster:3:a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cappedReplay vs reference simulator', () => {
|
||||
const OPTION_SETS = [
|
||||
{ Tc: 40, Dmax: 120 }, // default shape
|
||||
{ Tc: 40, Dmax: 40 }, // tightest legal cap — maximally fit-limited
|
||||
{ Tc: 5, Dmax: 1000 }, // cap effectively off
|
||||
]
|
||||
|
||||
it.each([2, 3, 5, 10, 40, 80])('matches the oracle on %i random points', (n) => {
|
||||
for (const seed of [1, 42]) {
|
||||
const leaves = randomLeaves(n, seed * 31 + n)
|
||||
const edges = mstEdges(leaves)
|
||||
for (const opts of OPTION_SETS) {
|
||||
const actual = cappedReplay(leaves, edges, opts)
|
||||
const expected = referenceReplay(leaves, edges, opts)
|
||||
expect(eventShapes(actual)).toEqual(eventShapes(expected))
|
||||
// pinned formulas (hypot diag, incremental centroid) ⇒ exact floats
|
||||
expect(actual.map((e) => e.z)).toEqual(expected.map((e) => e.z))
|
||||
for (let i = 0; i < actual.length; i++) {
|
||||
expect(actual[i].result.centroid).toEqual(expected[i].result.centroid)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the oracle on mixed-sign and far-offset coordinates', () => {
|
||||
const rand = mulberry32(101)
|
||||
const mixed = Array.from({ length: 40 }, (_, i) =>
|
||||
leaf(`m-${i}`, (rand() - 0.5) * 2000, (rand() - 0.5) * 2000)
|
||||
)
|
||||
const offset = Array.from({ length: 40 }, (_, i) =>
|
||||
leaf(`f-${i}`, -5_000_000 + rand() * 1000, 3_000_000 + rand() * 1000)
|
||||
)
|
||||
for (const leaves of [mixed, offset]) {
|
||||
const edges = mstEdges(leaves)
|
||||
for (const opts of OPTION_SETS) {
|
||||
expect(eventShapes(cappedReplay(leaves, edges, opts))).toEqual(
|
||||
eventShapes(referenceReplay(leaves, edges, opts))
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the oracle on lattice points (exact ties + coincident anchors)', () => {
|
||||
const rand = mulberry32(7)
|
||||
const leaves = Array.from({ length: 50 }, (_, i) =>
|
||||
leaf(`lat-${i}`, Math.floor(rand() * 10) * 10, Math.floor(rand() * 10) * 10)
|
||||
)
|
||||
const edges = mstEdges(leaves)
|
||||
for (const opts of OPTION_SETS) {
|
||||
expect(eventShapes(cappedReplay(leaves, edges, opts))).toEqual(
|
||||
eventShapes(referenceReplay(leaves, edges, opts))
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('cappedReplay invariants', () => {
|
||||
it('satisfies all structural invariants on random inputs', () => {
|
||||
for (const seed of [3, 99]) {
|
||||
for (const n of [2, 7, 30, 60]) {
|
||||
const leaves = randomLeaves(n, seed * 17 + n)
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
||||
expect(events).toHaveLength(n - 1)
|
||||
expectNonIncreasingZ(events)
|
||||
expectDendrogramIntegrity(events, leaves)
|
||||
expectLocality(events, leaves, 120)
|
||||
expectCentroids(events, leaves)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('holds the locality invariant even at the tightest legal cap', () => {
|
||||
const leaves = randomLeaves(50, 13)
|
||||
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 40 })
|
||||
expectNonIncreasingZ(events)
|
||||
expectDendrogramIntegrity(events, leaves)
|
||||
expectLocality(events, leaves, 40)
|
||||
})
|
||||
|
||||
it('is invariant under input permutation (as id-keyed event structures)', () => {
|
||||
const base = randomLeaves(40, 21)
|
||||
const expected = eventShapes(cappedReplay(base, mstEdges(base), { Tc: 40, Dmax: 120 }))
|
||||
for (const shuffleSeed of [5, 23]) {
|
||||
const perm = shuffled(base, mulberry32(shuffleSeed))
|
||||
expect(eventShapes(cappedReplay(perm, mstEdges(perm), { Tc: 40, Dmax: 120 }))).toEqual(
|
||||
expected
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('is pure: identical repeat output, no input mutation', () => {
|
||||
const leaves = randomLeaves(20, 5)
|
||||
const edges = mstEdges(leaves)
|
||||
const leavesSnapshot = JSON.parse(JSON.stringify(leaves))
|
||||
const edgesSnapshot = JSON.parse(JSON.stringify(edges))
|
||||
const first = cappedReplay(leaves, edges, { Tc: 40, Dmax: 120 })
|
||||
const second = cappedReplay(leaves, edges, { Tc: 40, Dmax: 120 })
|
||||
expect(first).toEqual(second)
|
||||
expect(leaves).toEqual(leavesSnapshot)
|
||||
expect(edges).toEqual(edgesSnapshot)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cappedReplay validation', () => {
|
||||
const leaves = [leaf('a', 0, 0), leaf('b', 10, 0)]
|
||||
const edges = mstEdges(leaves)
|
||||
|
||||
it('throws on non-positive Tc, naming the option', () => {
|
||||
expect(() => cappedReplay(leaves, edges, { Tc: 0, Dmax: 120 })).toThrow(/Tc/)
|
||||
expect(() => cappedReplay(leaves, edges, { Tc: -5, Dmax: 120 })).toThrow(/Tc/)
|
||||
})
|
||||
|
||||
it('throws on non-positive Dmax, naming the option', () => {
|
||||
expect(() => cappedReplay(leaves, edges, { Tc: 40, Dmax: 0 })).toThrow(/Dmax/)
|
||||
expect(() => cappedReplay(leaves, edges, { Tc: 40, Dmax: -1 })).toThrow(/Dmax/)
|
||||
})
|
||||
|
||||
it('throws when Dmax < Tc, naming the option', () => {
|
||||
expect(() => cappedReplay(leaves, edges, { Tc: 40, Dmax: 30 })).toThrow(/Dmax/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cappedReplay performance', () => {
|
||||
it('replays 2,000 points well within budget', () => {
|
||||
const leaves = randomLeaves(2000, 2026)
|
||||
const edges = mstEdges(leaves)
|
||||
const start = performance.now()
|
||||
const events = cappedReplay(leaves, edges, { Tc: 40, Dmax: 120 })
|
||||
const elapsed = performance.now() - start
|
||||
expect(events).toHaveLength(1999)
|
||||
// generous bound to stay non-flaky on slow CI
|
||||
expect(elapsed).toBeLessThan(2000)
|
||||
})
|
||||
})
|
||||
412
packages/commenting/src/clustering/replay.ts
Normal file
412
packages/commenting/src/clustering/replay.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import type { ClusterNode, LeafInput, LeafScreenOffsets, MstEdge, RawMergeEvent } from './types'
|
||||
|
||||
export const D_FLOOR = 1e-9
|
||||
|
||||
export function cappedReplay(
|
||||
leaves: readonly LeafInput[],
|
||||
edges: readonly MstEdge[],
|
||||
opts: { Tc: number; Dmax: number },
|
||||
// Render offsets for markers that draw off their anchor (imprecise pins). Omitted or empty,
|
||||
// every code path below is the offset-unaware original — pricing, events, and floats alike.
|
||||
screenOffsets?: LeafScreenOffsets
|
||||
): RawMergeEvent[] {
|
||||
validateOptions(opts)
|
||||
|
||||
if (edges.length === 0) return []
|
||||
|
||||
const clusters = new ClusterState(leaves, screenOffsets)
|
||||
const heap = new EdgeMaxHeap(edges, leaves)
|
||||
// Edges incident to each current cluster root, for eager repricing: a merge
|
||||
// moves the result's centroid, which can RAISE an incident edge's key (the
|
||||
// centroid may move toward a neighbor), so the lazy pop-time recheck alone
|
||||
// is not enough — every price change pushes a fresh heap entry.
|
||||
const incident: number[][] = leaves.map(() => [])
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
heap.push({ edgeIndex: i, z: zForEdge(edges[i], clusters, opts) })
|
||||
incident[edges[i].a].push(i)
|
||||
incident[edges[i].b].push(i)
|
||||
}
|
||||
|
||||
const events: RawMergeEvent[] = []
|
||||
// Emitted thresholds are clamped non-increasing: with centroid pricing a
|
||||
// later merge's raw price can in principle exceed an earlier one's, and the
|
||||
// table's descending sort is load-bearing for the runtime cursor.
|
||||
let lastZ = Number.POSITIVE_INFINITY
|
||||
while (events.length < edges.length) {
|
||||
const entry = heap.pop()
|
||||
if (!entry) break
|
||||
|
||||
const edge = edges[entry.edgeIndex]
|
||||
const aRoot = clusters.find(edge.a)
|
||||
const bRoot = clusters.find(edge.b)
|
||||
if (aRoot === bRoot) continue // stale duplicate of an already-fired edge
|
||||
|
||||
const current = { edgeIndex: entry.edgeIndex, z: zForRoots(edge, aRoot, bRoot, clusters, opts) }
|
||||
const next = heap.peek()
|
||||
if (next && heap.higherPriority(next, current)) {
|
||||
heap.push(current)
|
||||
continue
|
||||
}
|
||||
|
||||
const z = Math.min(current.z, lastZ)
|
||||
lastZ = z
|
||||
events.push(clusters.merge(aRoot, bRoot, z))
|
||||
|
||||
// eager reprice: the new cluster's centroid and bbox changed, so every
|
||||
// surviving incident edge gets a fresh entry at its current price
|
||||
const root = clusters.find(edge.a)
|
||||
const other = root === aRoot ? bRoot : aRoot
|
||||
const survivors: number[] = []
|
||||
for (const idx of [...incident[root], ...incident[other]]) {
|
||||
const e2 = edges[idx]
|
||||
const ra = clusters.find(e2.a)
|
||||
const rb = clusters.find(e2.b)
|
||||
if (ra === rb) continue
|
||||
survivors.push(idx)
|
||||
heap.push({ edgeIndex: idx, z: zForRoots(e2, ra, rb, clusters, opts) })
|
||||
}
|
||||
incident[root] = survivors
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
function validateOptions(opts: { Tc: number; Dmax: number }) {
|
||||
if (!Number.isFinite(opts.Tc) || opts.Tc <= 0) {
|
||||
throw new Error('Tc must be greater than 0')
|
||||
}
|
||||
if (!Number.isFinite(opts.Dmax) || opts.Dmax <= 0) {
|
||||
throw new Error('Dmax must be greater than 0')
|
||||
}
|
||||
if (opts.Dmax < opts.Tc) {
|
||||
throw new Error('Dmax must be greater than or equal to Tc')
|
||||
}
|
||||
}
|
||||
|
||||
function zForEdge(
|
||||
edge: MstEdge,
|
||||
clusters: ClusterState,
|
||||
opts: { Tc: number; Dmax: number }
|
||||
): number {
|
||||
return zForRoots(edge, clusters.find(edge.a), clusters.find(edge.b), clusters, opts)
|
||||
}
|
||||
|
||||
function zForRoots(
|
||||
edge: MstEdge,
|
||||
aRoot: number,
|
||||
bRoot: number,
|
||||
clusters: ClusterState,
|
||||
opts: { Tc: number; Dmax: number }
|
||||
): number {
|
||||
// Null unless offset pricing is active AND this pair's mean render offsets differ. Every
|
||||
// other pair — including every pair when no offsets were passed — takes the branch below,
|
||||
// which is the offset-unaware pricing verbatim.
|
||||
const off = clusters.offsetDelta(aRoot, bRoot)
|
||||
if (off === null) {
|
||||
if (edge.d < D_FLOOR) return Number.POSITIVE_INFINITY
|
||||
// Badge-anchored gap pricing: clusters render as badges at their centroids,
|
||||
// so the merge is priced by the distance between the rendered centers, not
|
||||
// the nearest members. For leaves the two are identical; for clusters the
|
||||
// centroid distance is larger, so groups merge later than their closest
|
||||
// members would suggest — matching what the user actually sees.
|
||||
// (Coincident CENTROIDS with non-coincident members leave the gap term
|
||||
// Infinity and the fit term finite — the min stays finite, no special case.)
|
||||
const gap = opts.Tc / clusters.centroidDistance(aRoot, bRoot)
|
||||
const fit = opts.Dmax / clusters.unionBboxDiag(aRoot, bRoot)
|
||||
return Math.min(gap, fit)
|
||||
}
|
||||
|
||||
// Offset-aware gap pricing: these markers render at `z·centroid + meanOffset`, so their
|
||||
// visual distance at zoom z is |z·ΔC + Δō| and the merge prices at its Tc crossing. When
|
||||
// that distance never reaches Tc (the constant offsets hold the visuals apart harder than
|
||||
// the anchors close), the pair prices at 0 — visually never mergeable, pruned by finalize's
|
||||
// minZoom cut. No D_FLOOR fast path here: coincident anchors with differing offsets are a
|
||||
// constant |Δō| apart on screen, which is exactly the ΔC = 0 case below.
|
||||
const dc = clusters.centroidDelta(aRoot, bRoot)
|
||||
if (dc.x === 0 && dc.y === 0) {
|
||||
return Math.hypot(off.x, off.y) < opts.Tc ? Number.POSITIVE_INFINITY : 0
|
||||
}
|
||||
const gap = largestVisualCrossing(dc.x, dc.y, off.x, off.y, opts.Tc)
|
||||
if (gap === null) return 0
|
||||
const fit = opts.Dmax / clusters.unionBboxDiag(aRoot, bRoot)
|
||||
return Math.min(gap, fit)
|
||||
}
|
||||
|
||||
/**
|
||||
* The largest positive root of `|z·ΔC + Δō| = level`, i.e. of
|
||||
* `|ΔC|²·z² + 2(ΔC·Δō)·z + (|Δō|² − level²) = 0` — the zoom at which two offset markers are
|
||||
* exactly `level` screen px apart, with the distance below the level for every smaller zoom
|
||||
* (matching the "merged at z ≤ threshold" model; a lower second crossing, where opposed offsets
|
||||
* push the visuals back above the level near z = 0, is deliberately collapsed). Null when the
|
||||
* visual distance never reaches the level. Callers handle ΔC = 0 (constant distance) themselves.
|
||||
*/
|
||||
function largestVisualCrossing(
|
||||
dcx: number,
|
||||
dcy: number,
|
||||
dox: number,
|
||||
doy: number,
|
||||
level: number
|
||||
): number | null {
|
||||
const a = dcx * dcx + dcy * dcy
|
||||
const b = 2 * (dcx * dox + dcy * doy)
|
||||
const c = dox * dox + doy * doy - level * level
|
||||
const disc = b * b - 4 * a * c
|
||||
if (disc < 0) return null
|
||||
const z = (-b + Math.sqrt(disc)) / (2 * a)
|
||||
return z > 0 ? z : null
|
||||
}
|
||||
|
||||
class ClusterState {
|
||||
private readonly parent: Int32Array
|
||||
private readonly minX: Float64Array
|
||||
private readonly minY: Float64Array
|
||||
private readonly maxX: Float64Array
|
||||
private readonly maxY: Float64Array
|
||||
private readonly centroidX: Float64Array
|
||||
private readonly centroidY: Float64Array
|
||||
private readonly counts: Int32Array
|
||||
private readonly nodes: ClusterNode[]
|
||||
// Per-cluster sums of member render offsets (screen px), maintained like the centroid sums.
|
||||
// Null when no offsets were passed — offsetDelta() then answers null unconditionally, which
|
||||
// routes every pricing call down the offset-unaware path.
|
||||
private readonly offsetX: Float64Array | null
|
||||
private readonly offsetY: Float64Array | null
|
||||
|
||||
constructor(leaves: readonly LeafInput[], screenOffsets?: LeafScreenOffsets) {
|
||||
const n = leaves.length
|
||||
this.parent = new Int32Array(n)
|
||||
this.minX = new Float64Array(n)
|
||||
this.minY = new Float64Array(n)
|
||||
this.maxX = new Float64Array(n)
|
||||
this.maxY = new Float64Array(n)
|
||||
this.centroidX = new Float64Array(n)
|
||||
this.centroidY = new Float64Array(n)
|
||||
this.counts = new Int32Array(n)
|
||||
this.nodes = new Array(n)
|
||||
|
||||
if (screenOffsets !== undefined && screenOffsets.size > 0) {
|
||||
this.offsetX = new Float64Array(n)
|
||||
this.offsetY = new Float64Array(n)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const offset = screenOffsets.get(leaves[i].id)
|
||||
if (offset) {
|
||||
this.offsetX[i] = offset.x
|
||||
this.offsetY[i] = offset.y
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.offsetX = null
|
||||
this.offsetY = null
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const leaf = leaves[i]
|
||||
this.parent[i] = i
|
||||
this.minX[i] = leaf.point.x
|
||||
this.minY[i] = leaf.point.y
|
||||
this.maxX[i] = leaf.point.x
|
||||
this.maxY[i] = leaf.point.y
|
||||
this.centroidX[i] = leaf.point.x
|
||||
this.centroidY[i] = leaf.point.y
|
||||
this.counts[i] = 1
|
||||
this.nodes[i] = {
|
||||
id: leaf.id,
|
||||
centroid: { x: leaf.point.x, y: leaf.point.y },
|
||||
count: 1,
|
||||
members: [leaf.id],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
find(index: number): number {
|
||||
let root = index
|
||||
while (this.parent[root] !== root) {
|
||||
root = this.parent[root]
|
||||
}
|
||||
while (this.parent[index] !== index) {
|
||||
const next = this.parent[index]
|
||||
this.parent[index] = root
|
||||
index = next
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/** Allocation-free: this is the hot path when no offsets are passed. */
|
||||
centroidDistance(aRoot: number, bRoot: number): number {
|
||||
return Math.hypot(
|
||||
this.centroidX[aRoot] - this.centroidX[bRoot],
|
||||
this.centroidY[aRoot] - this.centroidY[bRoot]
|
||||
)
|
||||
}
|
||||
|
||||
centroidDelta(aRoot: number, bRoot: number): { x: number; y: number } {
|
||||
return {
|
||||
x: this.centroidX[aRoot] - this.centroidX[bRoot],
|
||||
y: this.centroidY[aRoot] - this.centroidY[bRoot],
|
||||
}
|
||||
}
|
||||
|
||||
/** The difference of the two clusters' mean render offsets (screen px), or null when it's
|
||||
* zero — including always when no offsets were passed. Null routes pricing down the
|
||||
* offset-unaware path. */
|
||||
offsetDelta(aRoot: number, bRoot: number): { x: number; y: number } | null {
|
||||
if (this.offsetX === null || this.offsetY === null) return null
|
||||
const x = this.offsetX[aRoot] / this.counts[aRoot] - this.offsetX[bRoot] / this.counts[bRoot]
|
||||
const y = this.offsetY[aRoot] / this.counts[aRoot] - this.offsetY[bRoot] / this.counts[bRoot]
|
||||
if (x === 0 && y === 0) return null
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
unionBboxDiag(aRoot: number, bRoot: number): number {
|
||||
const minX = Math.min(this.minX[aRoot], this.minX[bRoot])
|
||||
const minY = Math.min(this.minY[aRoot], this.minY[bRoot])
|
||||
const maxX = Math.max(this.maxX[aRoot], this.maxX[bRoot])
|
||||
const maxY = Math.max(this.maxY[aRoot], this.maxY[bRoot])
|
||||
return Math.hypot(maxX - minX, maxY - minY)
|
||||
}
|
||||
|
||||
merge(aRoot: number, bRoot: number, z: number): RawMergeEvent {
|
||||
const leftRoot = this.nodes[aRoot].members[0] < this.nodes[bRoot].members[0] ? aRoot : bRoot
|
||||
const rightRoot = leftRoot === aRoot ? bRoot : aRoot
|
||||
const left = this.nodes[leftRoot]
|
||||
const right = this.nodes[rightRoot]
|
||||
const count = this.counts[leftRoot] + this.counts[rightRoot]
|
||||
const members = mergeSortedMembers(left.members, right.members)
|
||||
|
||||
const minX = Math.min(this.minX[leftRoot], this.minX[rightRoot])
|
||||
const minY = Math.min(this.minY[leftRoot], this.minY[rightRoot])
|
||||
const maxX = Math.max(this.maxX[leftRoot], this.maxX[rightRoot])
|
||||
const maxY = Math.max(this.maxY[leftRoot], this.maxY[rightRoot])
|
||||
const centroidX =
|
||||
(this.counts[leftRoot] * this.centroidX[leftRoot] +
|
||||
this.counts[rightRoot] * this.centroidX[rightRoot]) /
|
||||
count
|
||||
const centroidY =
|
||||
(this.counts[leftRoot] * this.centroidY[leftRoot] +
|
||||
this.counts[rightRoot] * this.centroidY[rightRoot]) /
|
||||
count
|
||||
|
||||
// Leaf ids are assumed not to start with `cluster:`; see the step 2 contract.
|
||||
const result: ClusterNode = {
|
||||
id: `cluster:${count}:${members[0]}`,
|
||||
centroid: { x: centroidX, y: centroidY },
|
||||
count,
|
||||
members,
|
||||
}
|
||||
|
||||
this.parent[rightRoot] = leftRoot
|
||||
this.minX[leftRoot] = minX
|
||||
this.minY[leftRoot] = minY
|
||||
this.maxX[leftRoot] = maxX
|
||||
this.maxY[leftRoot] = maxY
|
||||
this.centroidX[leftRoot] = centroidX
|
||||
this.centroidY[leftRoot] = centroidY
|
||||
this.counts[leftRoot] = count
|
||||
this.nodes[leftRoot] = result
|
||||
if (this.offsetX !== null && this.offsetY !== null) {
|
||||
this.offsetX[leftRoot] += this.offsetX[rightRoot]
|
||||
this.offsetY[leftRoot] += this.offsetY[rightRoot]
|
||||
}
|
||||
|
||||
return { z, children: [left, right], result }
|
||||
}
|
||||
}
|
||||
|
||||
function mergeSortedMembers(a: readonly string[], b: readonly string[]): string[] {
|
||||
const out = new Array<string>(a.length + b.length)
|
||||
let i = 0
|
||||
let j = 0
|
||||
let k = 0
|
||||
while (i < a.length && j < b.length) {
|
||||
if (a[i] < b[j]) {
|
||||
out[k++] = a[i++]
|
||||
} else {
|
||||
out[k++] = b[j++]
|
||||
}
|
||||
}
|
||||
while (i < a.length) out[k++] = a[i++]
|
||||
while (j < b.length) out[k++] = b[j++]
|
||||
return out
|
||||
}
|
||||
|
||||
interface HeapEntry {
|
||||
edgeIndex: number
|
||||
z: number
|
||||
}
|
||||
|
||||
class EdgeMaxHeap {
|
||||
private readonly items: HeapEntry[] = []
|
||||
// Per-edge (lo, hi) id pair for the z tie-break, precomputed once so comparisons allocate
|
||||
// nothing. Relies on MstEdge's invariant that leaves[a].id < leaves[b].id; the heap does not
|
||||
// re-normalize. With coincident anchors every edge prices to the same z (+Infinity), so the
|
||||
// tie-break runs on nearly every comparison of a rebuild: allocating the pair there churned
|
||||
// millions of short-lived tuples.
|
||||
private readonly loIds: string[]
|
||||
private readonly hiIds: string[]
|
||||
|
||||
constructor(edges: readonly MstEdge[], leaves: readonly LeafInput[]) {
|
||||
const n = edges.length
|
||||
this.loIds = new Array(n)
|
||||
this.hiIds = new Array(n)
|
||||
for (let i = 0; i < n; i++) {
|
||||
this.loIds[i] = leaves[edges[i].a].id
|
||||
this.hiIds[i] = leaves[edges[i].b].id
|
||||
}
|
||||
}
|
||||
|
||||
peek(): HeapEntry | undefined {
|
||||
return this.items[0]
|
||||
}
|
||||
|
||||
push(entry: HeapEntry) {
|
||||
this.items.push(entry)
|
||||
this.siftUp(this.items.length - 1)
|
||||
}
|
||||
|
||||
pop(): HeapEntry | undefined {
|
||||
if (this.items.length === 0) return undefined
|
||||
const first = this.items[0]
|
||||
const last = this.items.pop()!
|
||||
if (this.items.length > 0) {
|
||||
this.items[0] = last
|
||||
this.siftDown(0)
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
higherPriority(a: HeapEntry, b: HeapEntry): boolean {
|
||||
if (a.z > b.z) return true
|
||||
if (a.z < b.z) return false
|
||||
// Tie-break on the normalized leaf-id pair, ascending.
|
||||
const aLo = this.loIds[a.edgeIndex]
|
||||
const bLo = this.loIds[b.edgeIndex]
|
||||
if (aLo !== bLo) return aLo < bLo
|
||||
return this.hiIds[a.edgeIndex] < this.hiIds[b.edgeIndex]
|
||||
}
|
||||
|
||||
private siftUp(index: number) {
|
||||
while (index > 0) {
|
||||
const parent = (index - 1) >> 1
|
||||
if (!this.higherPriority(this.items[index], this.items[parent])) break
|
||||
;[this.items[index], this.items[parent]] = [this.items[parent], this.items[index]]
|
||||
index = parent
|
||||
}
|
||||
}
|
||||
|
||||
private siftDown(index: number) {
|
||||
while (true) {
|
||||
const left = index * 2 + 1
|
||||
const right = left + 1
|
||||
let best = index
|
||||
if (left < this.items.length && this.higherPriority(this.items[left], this.items[best])) {
|
||||
best = left
|
||||
}
|
||||
if (right < this.items.length && this.higherPriority(this.items[right], this.items[best])) {
|
||||
best = right
|
||||
}
|
||||
if (best === index) break
|
||||
;[this.items[index], this.items[best]] = [this.items[best], this.items[index]]
|
||||
index = best
|
||||
}
|
||||
}
|
||||
}
|
||||
850
packages/commenting/src/clustering/runtime.test.ts
Normal file
850
packages/commenting/src/clustering/runtime.test.ts
Normal file
@@ -0,0 +1,850 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mstEdges } from './mst'
|
||||
import { cappedReplay } from './replay'
|
||||
import { createClusterRuntime } from './runtime'
|
||||
import { contract, finalize } from './schedule'
|
||||
import { ClusterNode, ClusterTable, LeafInput, MergeEvent } from './types'
|
||||
|
||||
function node(ids: string[], x = 0, y = 0): ClusterNode {
|
||||
const members = ids.slice().sort()
|
||||
return {
|
||||
id: members.length === 1 ? members[0] : `cluster:${members.length}:${members[0]}`,
|
||||
centroid: { x, y },
|
||||
count: members.length,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
function mev(
|
||||
zMerge: number,
|
||||
zSplit: number,
|
||||
children: ClusterNode[],
|
||||
result: ClusterNode
|
||||
): MergeEvent {
|
||||
return { zMerge, zSplit, children, result }
|
||||
}
|
||||
|
||||
function visibleIds(runtime: { getVisible(): ReadonlyMap<string, ClusterNode> }): string[] {
|
||||
return [...runtime.getVisible().keys()].sort()
|
||||
}
|
||||
|
||||
/** Reference derivation: start from all leaves, apply events[0..k) in order. */
|
||||
function referenceVisible(table: ClusterTable, k: number): Map<string, ClusterNode> {
|
||||
const map = new Map(table.leaves.map((l) => [l.id, l]))
|
||||
for (let i = 0; i < k; i++) {
|
||||
const ev = table.events[i]
|
||||
for (const child of ev.children) map.delete(child.id)
|
||||
map.set(ev.result.id, ev.result)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** Contract clause 3 postconditions, asserted after every seed/onCamera. */
|
||||
function expectPostconditions(
|
||||
runtime: { readonly k: number; getVisible(): ReadonlyMap<string, ClusterNode> },
|
||||
table: ClusterTable,
|
||||
zoom: number
|
||||
) {
|
||||
const k = runtime.k
|
||||
expect(k).toBeGreaterThanOrEqual(0)
|
||||
expect(k).toBeLessThanOrEqual(table.events.length)
|
||||
if (k < table.events.length) expect(zoom).toBeGreaterThan(table.events[k].zMerge)
|
||||
if (k > 0) expect(zoom).toBeLessThan(table.events[k - 1].zSplit)
|
||||
const expected = referenceVisible(table, k)
|
||||
expect(visibleIds(runtime)).toEqual([...expected.keys()].sort())
|
||||
for (const [id, nodeRef] of expected) {
|
||||
// deep equality: a restored leaf may be the event's child object rather
|
||||
// than the leaves-array twin — both are table objects (no-cloning is
|
||||
// asserted by identity in the fixture tests, where objects are shared)
|
||||
expect(runtime.getVisible().get(id)).toEqual(nodeRef)
|
||||
}
|
||||
}
|
||||
|
||||
// The micro-trace table of CLUSTERING.md §8.5:
|
||||
// E0 { zMerge 4.0, zSplit 6.0, A+B+C → P }
|
||||
// E1 { zMerge 1.0, zSplit 1.5, P+D → Q }
|
||||
const A = node(['a'], 0, 0)
|
||||
const B = node(['b'], 10, 0)
|
||||
const C = node(['c'], 20, 0)
|
||||
const D = node(['d'], 100, 0)
|
||||
const P = node(['a', 'b', 'c'], 10, 0)
|
||||
const Q = node(['a', 'b', 'c', 'd'], 32.5, 0)
|
||||
|
||||
function microTraceTable(): ClusterTable {
|
||||
return {
|
||||
events: [mev(4, 6, [A, B, C], P), mev(1, 1.5, [P, D], Q)],
|
||||
leaves: [A, B, C, D],
|
||||
}
|
||||
}
|
||||
|
||||
describe('createClusterRuntime micro-trace (CLUSTERING.md §8.5)', () => {
|
||||
it('walks the exact documented trace', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
|
||||
// mount at zoom 5.0 → seed: √(4·6) ≈ 4.9 < 5.0 → k = 0
|
||||
rt.seed(5.0)
|
||||
expect(rt.k).toBe(0)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
expectPostconditions(rt, table, 5.0)
|
||||
|
||||
// zoom out to 3.5 → fire E0
|
||||
rt.onCamera(3.5)
|
||||
expect(rt.k).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:3:a', 'd'])
|
||||
expectPostconditions(rt, table, 3.5)
|
||||
|
||||
// zoom back in to 5.0 → inside E0's band (4 < 5 < 6): hold — no flicker
|
||||
rt.onCamera(5.0)
|
||||
expect(rt.k).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:3:a', 'd'])
|
||||
expectPostconditions(rt, table, 5.0)
|
||||
|
||||
// zoom in to 6.2 → split E0
|
||||
rt.onCamera(6.2)
|
||||
expect(rt.k).toBe(0)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
expectPostconditions(rt, table, 6.2)
|
||||
|
||||
// zoom-to-fit at 0.8 → fires E0 AND E1 in one call
|
||||
rt.onCamera(0.8)
|
||||
expect(rt.k).toBe(2)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:4:a'])
|
||||
expectPostconditions(rt, table, 0.8)
|
||||
|
||||
// pan (same zoom) → no-op
|
||||
rt.onCamera(0.8)
|
||||
expect(rt.k).toBe(2)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:4:a'])
|
||||
})
|
||||
|
||||
it('splits multiple events in one zoom-in jump', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(0.8) // √(4·6) ≈ 4.9 ≥ 0.8 and √(1·1.5) ≈ 1.22 ≥ 0.8 → k = 2
|
||||
expect(rt.k).toBe(2)
|
||||
rt.onCamera(7) // ≥ both zSplits (1.5 and 6) → unwinds to k = 0
|
||||
expect(rt.k).toBe(0)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
expectPostconditions(rt, table, 7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createClusterRuntime seeding', () => {
|
||||
it('seeds each side of the geometric band midpoint', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
const mid0 = Math.sqrt(4 * 6) // ≈ 4.898979
|
||||
const mid1 = Math.sqrt(1 * 1.5) // ≈ 1.224745
|
||||
|
||||
rt.seed(mid0 + 0.001) // just above E0's midpoint → unmerged
|
||||
expect(rt.k).toBe(0)
|
||||
|
||||
rt.seed(mid0) // exactly at the midpoint: zoom <= mid counts as merged
|
||||
expect(rt.k).toBe(1)
|
||||
|
||||
rt.seed(mid1 + 0.001) // between the two midpoints → only E0 active
|
||||
expect(rt.k).toBe(1)
|
||||
|
||||
rt.seed(mid1) // at/below E1's midpoint → both active
|
||||
expect(rt.k).toBe(2)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:4:a'])
|
||||
})
|
||||
|
||||
it('re-seeding resets state from scratch', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(0.8)
|
||||
expect(rt.k).toBe(2)
|
||||
rt.seed(5.0) // rebuild-style reset: history is discarded
|
||||
expect(rt.k).toBe(0)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('throws on non-positive or non-finite seed zoom', () => {
|
||||
const rt = createClusterRuntime(microTraceTable())
|
||||
expect(() => rt.seed(0)).toThrow()
|
||||
expect(() => rt.seed(-1)).toThrow()
|
||||
expect(() => rt.seed(NaN)).toThrow()
|
||||
expect(() => rt.seed(Infinity)).toThrow()
|
||||
})
|
||||
|
||||
it('throws if onCamera is called before any seed', () => {
|
||||
const rt = createClusterRuntime(microTraceTable())
|
||||
expect(() => rt.onCamera(3)).toThrow()
|
||||
})
|
||||
|
||||
it('seed followed by onCamera at the same zoom is a no-op', () => {
|
||||
const table = microTraceTable()
|
||||
for (const zoom of [0.5, 1.1, 2, 4.5, 4.9, 5.5, 8]) {
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(zoom)
|
||||
const k = rt.k
|
||||
const ids = visibleIds(rt)
|
||||
rt.onCamera(zoom)
|
||||
expect(rt.k).toBe(k)
|
||||
expect(visibleIds(rt)).toEqual(ids)
|
||||
expectPostconditions(rt, table, zoom)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('createClusterRuntime edge cases', () => {
|
||||
it('handles an empty table: all leaves visible at any zoom', () => {
|
||||
const table: ClusterTable = { events: [], leaves: [A, B] }
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(3)
|
||||
expect(rt.k).toBe(0)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b'])
|
||||
rt.onCamera(0.01)
|
||||
rt.onCamera(100)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('keeps Infinity events permanently merged at any reachable zoom', () => {
|
||||
const R = node(['a', 'b'])
|
||||
const table: ClusterTable = { events: [mev(Infinity, Infinity, [A, B], R)], leaves: [A, B] }
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(8) // √(∞·∞) = ∞ ≥ any zoom → seeded merged
|
||||
expect(rt.k).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:2:a'])
|
||||
rt.onCamera(1e9) // no finite zoom reaches zSplit = ∞
|
||||
expect(rt.k).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:2:a'])
|
||||
})
|
||||
|
||||
it('exposes the table node objects by reference, without cloning', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(5)
|
||||
expect(rt.getVisible().get('a')).toBe(A)
|
||||
rt.onCamera(3.5)
|
||||
expect(rt.getVisible().get('cluster:3:a')).toBe(P)
|
||||
expect(rt.getVisible().get('d')).toBe(D)
|
||||
})
|
||||
|
||||
it('never mutates the table', () => {
|
||||
const table = microTraceTable()
|
||||
const snapshot = JSON.parse(JSON.stringify(table))
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(5)
|
||||
rt.onCamera(0.8)
|
||||
rt.onCamera(7)
|
||||
rt.seed(2)
|
||||
expect(JSON.parse(JSON.stringify(table))).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
||||
function leaf(id: string, x: number, y: number): LeafInput {
|
||||
return { id, point: { x, y } }
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
const PIPE_OPTS = {
|
||||
Tc: 40,
|
||||
Tu: 60,
|
||||
eps: 0.12,
|
||||
Dmax: 120,
|
||||
minZoom: 0.05,
|
||||
maxZoom: 8,
|
||||
maxSplitZoom: 1e9,
|
||||
}
|
||||
|
||||
function buildTable(leaves: LeafInput[]): ClusterTable {
|
||||
const rawEvents = cappedReplay(leaves, mstEdges(leaves), {
|
||||
Tc: PIPE_OPTS.Tc,
|
||||
Dmax: PIPE_OPTS.Dmax,
|
||||
})
|
||||
const events = finalize(contract(rawEvents, PIPE_OPTS.eps), {
|
||||
Tc: PIPE_OPTS.Tc,
|
||||
Tu: PIPE_OPTS.Tu,
|
||||
minZoom: PIPE_OPTS.minZoom,
|
||||
maxZoom: PIPE_OPTS.maxZoom,
|
||||
maxSplitZoom: PIPE_OPTS.maxSplitZoom,
|
||||
})
|
||||
const leafNodes: ClusterNode[] = leaves.map((l) => ({
|
||||
id: l.id,
|
||||
centroid: { x: l.point.x, y: l.point.y },
|
||||
count: 1,
|
||||
members: [l.id],
|
||||
}))
|
||||
return { events, leaves: leafNodes }
|
||||
}
|
||||
|
||||
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
||||
const rand = mulberry32(seed)
|
||||
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
||||
}
|
||||
|
||||
/** A zoom walk within camera bounds: mostly small multiplicative steps, some jumps. */
|
||||
function zoomWalk(steps: number, seed: number): number[] {
|
||||
const rand = mulberry32(seed)
|
||||
const lnMin = Math.log(PIPE_OPTS.minZoom)
|
||||
const lnMax = Math.log(PIPE_OPTS.maxZoom)
|
||||
let ln = (lnMin + lnMax) / 2
|
||||
const walk: number[] = []
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const r = rand()
|
||||
if (r < 0.15) {
|
||||
// jump anywhere (zoom-to-fit, keyboard shortcuts)
|
||||
ln = lnMin + rand() * (lnMax - lnMin)
|
||||
} else if (r < 0.25) {
|
||||
// hold (pan-only frames)
|
||||
} else {
|
||||
// scrub: small multiplicative step, clamped to bounds
|
||||
ln = Math.min(lnMax, Math.max(lnMin, ln + (rand() - 0.5) * 0.4))
|
||||
}
|
||||
walk.push(Math.exp(ln))
|
||||
}
|
||||
return walk
|
||||
}
|
||||
|
||||
/**
|
||||
* Independent per-event hysteresis simulator: each event keeps its own bit with
|
||||
* the two-threshold update rule. The prefix property says the bit vector is
|
||||
* always exactly the prefix mask [0..k) — this cross-checks the cursor against
|
||||
* the definition it compresses.
|
||||
*/
|
||||
class BitSimulator {
|
||||
private bits: boolean[]
|
||||
constructor(
|
||||
private events: readonly MergeEvent[],
|
||||
seedZoom: number
|
||||
) {
|
||||
this.bits = events.map((e) => seedZoom <= Math.sqrt(e.zMerge * e.zSplit))
|
||||
}
|
||||
update(zoom: number) {
|
||||
this.bits = this.events.map((e, i) => {
|
||||
if (zoom <= e.zMerge) return true
|
||||
if (zoom >= e.zSplit) return false
|
||||
return this.bits[i]
|
||||
})
|
||||
}
|
||||
expectPrefixOfLength(k: number) {
|
||||
for (let i = 0; i < this.bits.length; i++) {
|
||||
expect(this.bits[i]).toBe(i < k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('createClusterRuntime properties (random tables, random zoom walks)', () => {
|
||||
it('holds all postconditions and matches the per-event bit simulator', () => {
|
||||
for (const [n, seed] of [
|
||||
[2, 1],
|
||||
[15, 42],
|
||||
[60, 7],
|
||||
] as const) {
|
||||
const leaves = randomLeaves(n, seed * 13 + n)
|
||||
const table = buildTable(leaves)
|
||||
const rt = createClusterRuntime(table)
|
||||
const walk = zoomWalk(300, seed + 100)
|
||||
|
||||
const seedZoom = walk[0]
|
||||
rt.seed(seedZoom)
|
||||
const sim = new BitSimulator(table.events, seedZoom)
|
||||
expectPostconditions(rt, table, seedZoom)
|
||||
sim.expectPrefixOfLength(rt.k)
|
||||
|
||||
for (const zoom of walk.slice(1)) {
|
||||
rt.onCamera(zoom)
|
||||
sim.update(zoom)
|
||||
expectPostconditions(rt, table, zoom)
|
||||
sim.expectPrefixOfLength(rt.k)
|
||||
// conservation: visible clusters always partition all n leaves
|
||||
let total = 0
|
||||
for (const cluster of rt.getVisible().values()) total += cluster.count
|
||||
expect(total).toBe(n)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('is monotone: k never decreases while zooming out, never increases zooming in', () => {
|
||||
const leaves = randomLeaves(40, 33)
|
||||
const table = buildTable(leaves)
|
||||
const rt = createClusterRuntime(table)
|
||||
|
||||
rt.seed(PIPE_OPTS.maxZoom)
|
||||
let prevK = rt.k
|
||||
for (let zoom = PIPE_OPTS.maxZoom; zoom >= PIPE_OPTS.minZoom; zoom *= 0.97) {
|
||||
rt.onCamera(zoom)
|
||||
expect(rt.k).toBeGreaterThanOrEqual(prevK)
|
||||
prevK = rt.k
|
||||
}
|
||||
expect(rt.k).toBe(table.events.length) // fully zoomed out → everything merged
|
||||
|
||||
for (let zoom = PIPE_OPTS.minZoom; zoom <= PIPE_OPTS.maxZoom; zoom *= 1.03) {
|
||||
rt.onCamera(zoom)
|
||||
expect(rt.k).toBeLessThanOrEqual(prevK)
|
||||
prevK = rt.k
|
||||
}
|
||||
})
|
||||
|
||||
it('produces zero transitions while scrubbing inside a hysteresis band', () => {
|
||||
const leaves = randomLeaves(30, 55)
|
||||
const table = buildTable(leaves)
|
||||
// pick a finite event with a usable band inside camera bounds
|
||||
const ev = table.events.find(
|
||||
(e) =>
|
||||
Number.isFinite(e.zMerge) &&
|
||||
e.zMerge > PIPE_OPTS.minZoom * 2 &&
|
||||
e.zSplit < PIPE_OPTS.maxZoom &&
|
||||
e.zSplit / e.zMerge > 1.01
|
||||
)
|
||||
expect(ev).toBeDefined()
|
||||
const lo = ev!.zMerge * 1.001
|
||||
const hi = ev!.zSplit * 0.999
|
||||
|
||||
const rt = createClusterRuntime(table)
|
||||
// approach from below: merged state, then scrub within (zMerge, zSplit)
|
||||
rt.seed(ev!.zMerge * 0.9)
|
||||
rt.onCamera(lo)
|
||||
const k = rt.k
|
||||
const ids = visibleIds(rt)
|
||||
const rand = mulberry32(9)
|
||||
for (let i = 0; i < 50; i++) {
|
||||
rt.onCamera(lo + rand() * (hi - lo))
|
||||
expect(rt.k).toBe(k)
|
||||
expect(visibleIds(rt)).toEqual(ids)
|
||||
}
|
||||
})
|
||||
|
||||
it('agrees with a fresh seed after any walk that ends outside all bands', () => {
|
||||
// Outside every band, state is history-independent: a runtime that walked
|
||||
// there must equal a runtime seeded there directly.
|
||||
const leaves = randomLeaves(30, 77)
|
||||
const table = buildTable(leaves)
|
||||
const walked = createClusterRuntime(table)
|
||||
walked.seed(2)
|
||||
for (const zoom of zoomWalk(120, 5)) walked.onCamera(zoom)
|
||||
|
||||
// find a zoom outside all bands near the end state
|
||||
const finalZoom = PIPE_OPTS.maxZoom
|
||||
walked.onCamera(finalZoom)
|
||||
const inBand = table.events.some((e) => finalZoom > e.zMerge && finalZoom < e.zSplit)
|
||||
if (!inBand) {
|
||||
const seeded = createClusterRuntime(table)
|
||||
seeded.seed(finalZoom)
|
||||
expect(seeded.k).toBe(walked.k)
|
||||
expect(visibleIds(seeded)).toEqual(visibleIds(walked))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('createClusterRuntime seedFrom (carryover seeding)', () => {
|
||||
it('band event merged in the previous partition stays merged', () => {
|
||||
// zoom 5 is inside E0's band and above the geometric midpoint (≈4.899):
|
||||
// a fresh seed would leave A/B/C split. History says merged — carryover keeps it.
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seedFrom(
|
||||
5,
|
||||
new Map([
|
||||
[P.id, P],
|
||||
[D.id, D],
|
||||
])
|
||||
)
|
||||
expect(rt.k).toBe(1)
|
||||
expect(rt.getSuppressedCount()).toBe(0)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:3:a', 'd'])
|
||||
})
|
||||
|
||||
it('band event unmerged in the previous partition stays unmerged (no snap-together)', () => {
|
||||
// zoom 4.5 is inside E0's band and below the geometric midpoint: a fresh seed
|
||||
// would merge A/B/C even though nothing changed for them. History says split.
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seedFrom(
|
||||
4.5,
|
||||
new Map([
|
||||
[A.id, A],
|
||||
[B.id, B],
|
||||
[C.id, C],
|
||||
[D.id, D],
|
||||
])
|
||||
)
|
||||
expect(rt.k).toBe(1)
|
||||
expect(rt.getSuppressedCount()).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('thresholds override history in both directions', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
// zoom <= zMerge: mandatory merge, even though the previous partition was split
|
||||
rt.seedFrom(
|
||||
3,
|
||||
new Map([
|
||||
[A.id, A],
|
||||
[B.id, B],
|
||||
[C.id, C],
|
||||
[D.id, D],
|
||||
])
|
||||
)
|
||||
expect(rt.k).toBe(1)
|
||||
// zoom >= zSplit: mandatory split, even though the previous partition was merged
|
||||
rt.seedFrom(
|
||||
7,
|
||||
new Map([
|
||||
[P.id, P],
|
||||
[D.id, D],
|
||||
])
|
||||
)
|
||||
expect(rt.k).toBe(0)
|
||||
})
|
||||
|
||||
it('a band event with no counterpart in the previous partition stays inactive', () => {
|
||||
// Empty history (or partial membership) → the rebuild introduced this merge; it
|
||||
// defers to the next zoom-out rather than firing under a static camera.
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seedFrom(5, new Map())
|
||||
expect(rt.k).toBe(1)
|
||||
expect(rt.getSuppressedCount()).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
const partial = node(['a', 'b'], 5, 0)
|
||||
rt.seedFrom(
|
||||
5,
|
||||
new Map([
|
||||
[partial.id, partial],
|
||||
[C.id, C],
|
||||
[D.id, D],
|
||||
])
|
||||
)
|
||||
expect(rt.k).toBe(1)
|
||||
expect(rt.getSuppressedCount()).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('membership in a superset cluster counts as merged', () => {
|
||||
// The previous partition holds all four in Q; E1 (band at zoom 1.2) inherits merged.
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seedFrom(1.2, new Map([[Q.id, Q]]))
|
||||
expect(rt.k).toBe(2)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:4:a'])
|
||||
})
|
||||
|
||||
it('carries over mixed band states exactly (split before merged in table order)', () => {
|
||||
// Two independent band events at zoom 4.5: the earlier-sorted one split in history,
|
||||
// the later one merged. The old prefix cut forced the second to split (the mass-split
|
||||
// bug); with suppression the carryover is exact — each keeps its own state.
|
||||
const AB = node(['a', 'b'], 5, 0)
|
||||
const CD = node(['c', 'd'], 105, 0)
|
||||
const table: ClusterTable = {
|
||||
events: [mev(4, 6, [A, B], AB), mev(3, 5, [C, D], CD)],
|
||||
leaves: [A, B, C, D],
|
||||
}
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seedFrom(
|
||||
4.5,
|
||||
new Map([
|
||||
[A.id, A],
|
||||
[B.id, B],
|
||||
[CD.id, CD],
|
||||
])
|
||||
)
|
||||
expect(rt.k).toBe(2)
|
||||
expect(rt.getSuppressedCount()).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'cluster:2:c'])
|
||||
})
|
||||
|
||||
it('a suppressed event merges (heals) when a zoom-out crosses its own zMerge', () => {
|
||||
const AB = node(['a', 'b'], 5, 0)
|
||||
const CD = node(['c', 'd'], 105, 0)
|
||||
const table: ClusterTable = {
|
||||
events: [mev(4, 6, [A, B], AB), mev(3, 5, [C, D], CD)],
|
||||
leaves: [A, B, C, D],
|
||||
}
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seedFrom(
|
||||
4.5,
|
||||
new Map([
|
||||
[A.id, A],
|
||||
[B.id, B],
|
||||
[CD.id, CD],
|
||||
])
|
||||
)
|
||||
const versionBefore = rt.version
|
||||
rt.onCamera(4.4) // still inside AB's band: nothing happens
|
||||
expect(rt.version).toBe(versionBefore)
|
||||
rt.onCamera(3.9) // below AB's zMerge (4): the held-out merge fires at its own threshold
|
||||
expect(rt.version).toBeGreaterThan(versionBefore)
|
||||
expect(rt.k).toBe(2)
|
||||
expect(rt.getSuppressedCount()).toBe(0)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:2:a', 'cluster:2:c'])
|
||||
expectPostconditions(rt, table, 3.9)
|
||||
})
|
||||
|
||||
it('the split walk retreats past a suppressed event without corrupting the partition', () => {
|
||||
const AB = node(['a', 'b'], 5, 0)
|
||||
const CD = node(['c', 'd'], 105, 0)
|
||||
const table: ClusterTable = {
|
||||
events: [mev(4, 6, [A, B], AB), mev(3, 5, [C, D], CD)],
|
||||
leaves: [A, B, C, D],
|
||||
}
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seedFrom(
|
||||
4.5,
|
||||
new Map([
|
||||
[A.id, A],
|
||||
[B.id, B],
|
||||
[CD.id, CD],
|
||||
])
|
||||
)
|
||||
rt.onCamera(5.5) // past CD's zSplit (5): CD splits; suppressed AB stays put
|
||||
expect(rt.k).toBe(1)
|
||||
expect(rt.getSuppressedCount()).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
rt.onCamera(6.5) // past AB's zSplit (6): retreat past the suppressed (never-applied) event
|
||||
expect(rt.k).toBe(0)
|
||||
expect(rt.getSuppressedCount()).toBe(0)
|
||||
expect(visibleIds(rt)).toEqual(['a', 'b', 'c', 'd'])
|
||||
rt.onCamera(3.9) // zoom back out: normal cursor walk from a clean state
|
||||
expect(rt.k).toBe(1)
|
||||
expect(visibleIds(rt)).toEqual(['c', 'cluster:2:a', 'd'])
|
||||
expectPostconditions(rt, table, 3.9)
|
||||
})
|
||||
|
||||
it('heals a suppressed event whose result a later event consumes, in one zoom-out jump', () => {
|
||||
// E1 consumes E0's result, so a single jump past both thresholds must apply E0 first.
|
||||
// Applying E1 first would no-op its delete of the never-applied ABC and then re-add ABC
|
||||
// on top of ABCD, covering a, b and c twice.
|
||||
const ABC = node(['a', 'b', 'c'], 10, 0)
|
||||
const ABCD = node(['a', 'b', 'c', 'd'], 32.5, 0)
|
||||
const table: ClusterTable = {
|
||||
events: [mev(4, 6, [A, B, C], ABC), mev(1, 1.5, [ABC, D], ABCD)],
|
||||
leaves: [A, B, C, D],
|
||||
}
|
||||
const rt = createClusterRuntime(table)
|
||||
// previous partition: everything split apart, so E0 seeds suppressed inside its band
|
||||
rt.seedFrom(
|
||||
5,
|
||||
new Map([
|
||||
[A.id, A],
|
||||
[B.id, B],
|
||||
[C.id, C],
|
||||
[D.id, D],
|
||||
])
|
||||
)
|
||||
expect(rt.getSuppressedCount()).toBe(1)
|
||||
|
||||
rt.onCamera(0.9) // one jump past E0's zMerge (4) and E1's zMerge (1)
|
||||
|
||||
expect(rt.getSuppressedCount()).toBe(0)
|
||||
expect(rt.k).toBe(2)
|
||||
expect(visibleIds(rt)).toEqual(['cluster:4:a'])
|
||||
// conservation: every leaf is covered exactly once
|
||||
const members = [...rt.getVisible().values()].flatMap((n) => n.members)
|
||||
expect(members.slice().sort()).toEqual(['a', 'b', 'c', 'd'])
|
||||
expectPostconditions(rt, table, 0.9)
|
||||
})
|
||||
|
||||
it('seedFrom followed by onCamera at the same zoom is a no-op', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seedFrom(
|
||||
5,
|
||||
new Map([
|
||||
[P.id, P],
|
||||
[D.id, D],
|
||||
])
|
||||
)
|
||||
const k = rt.k
|
||||
const ids = visibleIds(rt)
|
||||
rt.onCamera(5)
|
||||
expect(rt.k).toBe(k)
|
||||
expect(visibleIds(rt)).toEqual(ids)
|
||||
expectPostconditions(rt, table, 5)
|
||||
})
|
||||
|
||||
it('matches a fresh seed when the previous partition mirrors the geometric tiebreak', () => {
|
||||
// Sanity: outside bands and with agreeing history, seedFrom degenerates to seed.
|
||||
const table = microTraceTable()
|
||||
for (const zoom of [0.5, 2, 8]) {
|
||||
const fresh = createClusterRuntime(table)
|
||||
fresh.seed(zoom)
|
||||
const carried = createClusterRuntime(table)
|
||||
carried.seedFrom(zoom, fresh.getVisible())
|
||||
expect(carried.k).toBe(fresh.k)
|
||||
expect(visibleIds(carried)).toEqual(visibleIds(fresh))
|
||||
}
|
||||
})
|
||||
|
||||
it('throws on non-positive or non-finite zoom', () => {
|
||||
const rt = createClusterRuntime(microTraceTable())
|
||||
expect(() => rt.seedFrom(0, new Map())).toThrow()
|
||||
expect(() => rt.seedFrom(NaN, new Map())).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createClusterRuntime detachLeaf (local partition edits)', () => {
|
||||
it('shrinks the containing badge in place: members, count, centroid', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4) // P = a+b+c visible, d separate
|
||||
const versionBefore = rt.version
|
||||
rt.detachLeaf('a')
|
||||
expect(rt.version).toBeGreaterThan(versionBefore)
|
||||
expect(rt.getDetachedCount()).toBe(1)
|
||||
const patchedP = rt.getVisible().get(P.id)!
|
||||
expect(patchedP.members).toEqual(['b', 'c'])
|
||||
expect(patchedP.count).toBe(2)
|
||||
// centroid recomputed from the remaining leaves: B (10,0), C (20,0)
|
||||
expect(patchedP.centroid).toEqual({ x: 15, y: 0 })
|
||||
// keeps the structural id so cursor events keep addressing it
|
||||
expect(patchedP.id).toBe(P.id)
|
||||
// everything else untouched
|
||||
expect(rt.getVisible().get('d')).toEqual(D)
|
||||
expect(rt.getVisible().size).toBe(2)
|
||||
})
|
||||
|
||||
it('is idempotent and ignores unknown ids', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4)
|
||||
rt.detachLeaf('a')
|
||||
const version = rt.version
|
||||
rt.detachLeaf('a')
|
||||
rt.detachLeaf('nonexistent')
|
||||
expect(rt.version).toBe(version)
|
||||
expect(rt.getDetachedCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('collapses a badge to its surviving leaf, and to nothing', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4)
|
||||
rt.detachLeaf('a')
|
||||
rt.detachLeaf('b')
|
||||
// P = {a,b,c} minus a,b → the leaf node c, keyed by its own id
|
||||
expect(rt.getVisible().get('c')).toEqual(C)
|
||||
expect(rt.getVisible().has(P.id)).toBe(false)
|
||||
rt.detachLeaf('c')
|
||||
expect(visibleIds(rt)).toEqual(['d'])
|
||||
})
|
||||
|
||||
it('removes a leaf that is visible as its own pin', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(8) // everything split
|
||||
rt.detachLeaf('a')
|
||||
expect(visibleIds(rt)).toEqual(['b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('zoom walks stay correct while patches are active (split)', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4)
|
||||
rt.detachLeaf('a')
|
||||
rt.onCamera(6.5) // past P's zSplit (6): structural split of a+b+c
|
||||
expect(rt.k).toBe(0)
|
||||
// resolved view: a stays gone, b/c/d as pins — the split itself changed nothing for a
|
||||
expect(visibleIds(rt)).toEqual(['b', 'c', 'd'])
|
||||
})
|
||||
|
||||
it('zoom walks stay correct while patches are active (merge)', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4)
|
||||
rt.detachLeaf('a')
|
||||
rt.onCamera(0.9) // below E1's zMerge (1): P+D merge into Q
|
||||
expect(rt.k).toBe(2)
|
||||
const patchedQ = rt.getVisible().get(Q.id)!
|
||||
expect(patchedQ.members).toEqual(['b', 'c', 'd'])
|
||||
expect(patchedQ.count).toBe(3)
|
||||
// mean of B (10,0), C (20,0), D (100,0)
|
||||
expect(patchedQ.centroid.x).toBeCloseTo(130 / 3)
|
||||
expect(rt.getVisible().size).toBe(1)
|
||||
})
|
||||
|
||||
it('seed and seedFrom clear detaches', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4)
|
||||
rt.detachLeaf('a')
|
||||
rt.seed(4)
|
||||
expect(rt.getDetachedCount()).toBe(0)
|
||||
expect(rt.getVisible().get(P.id)).toEqual(P)
|
||||
rt.detachLeaf('a')
|
||||
rt.seedFrom(
|
||||
4,
|
||||
new Map([
|
||||
[P.id, P],
|
||||
[D.id, D],
|
||||
])
|
||||
)
|
||||
expect(rt.getDetachedCount()).toBe(0)
|
||||
expect(rt.getVisible().get(P.id)).toEqual(P)
|
||||
})
|
||||
|
||||
it('detachLeaves batches a whole set into one version bump', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4) // P = a+b+c visible, d separate
|
||||
const versionBefore = rt.version
|
||||
rt.detachLeaves(['a', 'b'])
|
||||
expect(rt.version).toBe(versionBefore + 1)
|
||||
expect(rt.getDetachedCount()).toBe(2)
|
||||
// P = {a,b,c} minus a,b → the leaf node c, keyed by its own id
|
||||
expect(rt.getVisible().get('c')).toEqual(C)
|
||||
expect(rt.getVisible().has(P.id)).toBe(false)
|
||||
expect(visibleIds(rt)).toEqual(['c', 'd'])
|
||||
})
|
||||
|
||||
it('detachLeaves matches the equivalent one-at-a-time detaches', () => {
|
||||
const makeDetached = (detach: (rt: ReturnType<typeof createClusterRuntime>) => void) => {
|
||||
const rt = createClusterRuntime(microTraceTable())
|
||||
rt.seed(4)
|
||||
detach(rt)
|
||||
return rt
|
||||
}
|
||||
const batched = makeDetached((rt) => rt.detachLeaves(['a', 'd']))
|
||||
const oneByOne = makeDetached((rt) => {
|
||||
rt.detachLeaf('a')
|
||||
rt.detachLeaf('d')
|
||||
})
|
||||
expect(visibleIds(batched)).toEqual(visibleIds(oneByOne))
|
||||
for (const id of visibleIds(batched)) {
|
||||
expect(batched.getVisible().get(id)).toEqual(oneByOne.getVisible().get(id))
|
||||
}
|
||||
})
|
||||
|
||||
it('detachLeaves skips unknown and already-detached ids, and no-ops on an empty batch', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4)
|
||||
rt.detachLeaf('a')
|
||||
const version = rt.version
|
||||
const visible = rt.getVisible()
|
||||
rt.detachLeaves([])
|
||||
rt.detachLeaves(['a', 'nonexistent'])
|
||||
expect(rt.version).toBe(version)
|
||||
expect(rt.getDetachedCount()).toBe(1)
|
||||
expect(rt.getVisible()).toBe(visible)
|
||||
// a mixed batch still applies the new id in a single bump
|
||||
rt.detachLeaves(['a', 'b', 'nonexistent'])
|
||||
expect(rt.version).toBe(version + 1)
|
||||
expect(rt.getDetachedCount()).toBe(2)
|
||||
})
|
||||
|
||||
it('getVisible returns a stable reference until the partition changes', () => {
|
||||
const table = microTraceTable()
|
||||
const rt = createClusterRuntime(table)
|
||||
rt.seed(4)
|
||||
rt.detachLeaf('a')
|
||||
const first = rt.getVisible()
|
||||
expect(rt.getVisible()).toBe(first)
|
||||
rt.detachLeaf('b')
|
||||
expect(rt.getVisible()).not.toBe(first)
|
||||
})
|
||||
})
|
||||
304
packages/commenting/src/clustering/runtime.ts
Normal file
304
packages/commenting/src/clustering/runtime.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import type { ClusterNode, ClusterTable, MergeEvent } from './types'
|
||||
|
||||
/** @internal */
|
||||
export interface ClusterRuntime {
|
||||
/** Events[0..k) are in the cursor (applied unless suppressed). Exposed for tests/debugging. */
|
||||
readonly k: number
|
||||
/**
|
||||
* Increments whenever the displayed partition changes. Subscribe to this, not `k`: detaches
|
||||
* and suppressed-event healing change the partition without moving the cursor.
|
||||
*/
|
||||
readonly version: number
|
||||
/** Number of suppressed band events currently inside the cursor. Exposed for tests. */
|
||||
getSuppressedCount(): number
|
||||
/** Number of leaves currently detached from the displayed partition. Exposed for tests. */
|
||||
getDetachedCount(): number
|
||||
/** Reset state from scratch for the given zoom (cold start / after rebuild). Clears detaches. */
|
||||
seed(zoom: number): void
|
||||
/**
|
||||
* Reset state for the given zoom, carrying hysteresis state over from a previous partition.
|
||||
* Threshold-forced events ignore history: zoom \<= zMerge is always merged, zoom \>= zSplit always
|
||||
* split. An event inside its band keeps its previous state; one that was unmerged (or has no
|
||||
* history) stays suppressed inside the cursor until a zoom-out crosses its own zMerge. Carryover is
|
||||
* exact — no group changes state because of the swap alone. Clears detaches.
|
||||
*/
|
||||
seedFrom(zoom: number, previous: ReadonlyMap<string, ClusterNode>): void
|
||||
/** Advance/retreat the cursor for a camera change. No-op if zoom sits inside all bands. */
|
||||
onCamera(zoom: number): void
|
||||
/**
|
||||
* Remove one leaf from the displayed partition without touching the event table. Local by
|
||||
* construction: only the nodes containing the leaf change, so a deletion, pop-out, or thread-open
|
||||
* never re-flows the rest of the document. The table's thresholds around the detached leaf go
|
||||
* stale; the caller is expected to adopt a corrected rebuild (with seedFrom) at the next zoom-out.
|
||||
* Unknown or already-detached ids are no-ops.
|
||||
*/
|
||||
detachLeaf(leafId: string): void
|
||||
/**
|
||||
* Batch form of {@link ClusterRuntime.detachLeaf}: detach every given leaf with a single
|
||||
* patch rebuild and a single version bump. Ids that are unknown or already detached are
|
||||
* skipped; if nothing new detaches, nothing changes.
|
||||
*/
|
||||
detachLeaves(leafIds: Iterable<string>): void
|
||||
/** The displayed partition: cluster id → node, with detaches applied. Do not mutate. */
|
||||
getVisible(): ReadonlyMap<string, ClusterNode>
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function createClusterRuntime(table: ClusterTable): ClusterRuntime {
|
||||
return new ClusterRuntimeImpl(table)
|
||||
}
|
||||
|
||||
class ClusterRuntimeImpl implements ClusterRuntime {
|
||||
// mutable internally; readonly through the ClusterRuntime interface
|
||||
k = 0
|
||||
version = 0
|
||||
// Structural partition: exactly leaves + applied events, never patched. The cursor invariants
|
||||
// live here, untouched by detaches — resolution to the displayed partition happens at read
|
||||
// time in getVisible().
|
||||
private visible = new Map<string, ClusterNode>()
|
||||
private seeded = false
|
||||
// Indices (< k) of band events held unmerged by seedFrom carryover. The single cursor can only
|
||||
// express "merged up to here", but a carried-over partition can be "merged except these". Self-
|
||||
// draining: an entry leaves via onCamera when the zoom crosses its zMerge or its zSplit.
|
||||
private suppressed = new Set<number>()
|
||||
// Leaves removed from the displayed partition (deleted / popped out / opened). Patches map
|
||||
// each structural node containing a detached leaf to its displayed replacement (or null to
|
||||
// drop it). Rebuilt from the detached set; cleared by seed/seedFrom.
|
||||
private detached = new Set<string>()
|
||||
private patched = new Map<string, ClusterNode | null>()
|
||||
private leafById: Map<string, ClusterNode> | null = null
|
||||
private resolvedCache: { version: number; map: Map<string, ClusterNode> } | null = null
|
||||
|
||||
constructor(private readonly table: ClusterTable) {
|
||||
this.reset()
|
||||
}
|
||||
|
||||
getSuppressedCount(): number {
|
||||
return this.suppressed.size
|
||||
}
|
||||
|
||||
getDetachedCount(): number {
|
||||
return this.detached.size
|
||||
}
|
||||
|
||||
seed(zoom: number): void {
|
||||
validateZoom(zoom)
|
||||
this.k = seedCount(this.table.events, zoom)
|
||||
this.reset()
|
||||
for (let i = 0; i < this.k; i++) {
|
||||
applyEvent(this.visible, this.table.events[i])
|
||||
}
|
||||
this.seeded = true
|
||||
this.version++
|
||||
}
|
||||
|
||||
seedFrom(zoom: number, previous: ReadonlyMap<string, ClusterNode>): void {
|
||||
validateZoom(zoom)
|
||||
const ownerByMember = new Map<string, string>()
|
||||
for (const node of previous.values()) {
|
||||
for (const member of node.members) {
|
||||
ownerByMember.set(member, node.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Cut the cursor purely on split thresholds. zSplit is non-increasing down the table, so
|
||||
// everything past the first mandatory split (zoom >= zSplit) is also mandatorily split —
|
||||
// this cut can never conflict with an event that should be merged.
|
||||
const events = this.table.events
|
||||
let k = 0
|
||||
while (k < events.length && zoom < events[k].zSplit) {
|
||||
k++
|
||||
}
|
||||
|
||||
this.k = k
|
||||
this.reset()
|
||||
for (let i = 0; i < k; i++) {
|
||||
const event = events[i]
|
||||
if (zoom > event.zMerge && !wasMergedTogether(event.result.members, ownerByMember)) {
|
||||
// In its band and previously unmerged: keep it unmerged, as an exception inside the cursor.
|
||||
// Dependency-safe, since an applied event can never consume a suppressed result.
|
||||
this.suppressed.add(i)
|
||||
} else {
|
||||
applyEvent(this.visible, event)
|
||||
}
|
||||
}
|
||||
this.seeded = true
|
||||
this.version++
|
||||
}
|
||||
|
||||
onCamera(zoom: number): void {
|
||||
validateZoom(zoom)
|
||||
if (!this.seeded) {
|
||||
throw new Error('Cluster runtime must be seeded before onCamera')
|
||||
}
|
||||
|
||||
let changed = false
|
||||
// Heal suppressed events at their own merge threshold BEFORE the merge walk, so a zoom-out past
|
||||
// zMerge merges a held-out band event as if it had still been ahead of the cursor. Healing must come
|
||||
// first: one zoom jump can cross both a suppressed event and an event consuming its result, and
|
||||
// applying the consumer first would double-count the producer's leaves. Iterated live — deleting only
|
||||
// the entry being visited is safe, and Set iteration still yields the rest in insertion order.
|
||||
if (this.suppressed.size > 0) {
|
||||
for (const i of this.suppressed) {
|
||||
if (zoom <= this.table.events[i].zMerge) {
|
||||
this.suppressed.delete(i)
|
||||
applyEvent(this.visible, this.table.events[i])
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
while (this.k < this.table.events.length && zoom <= this.table.events[this.k].zMerge) {
|
||||
applyEvent(this.visible, this.table.events[this.k])
|
||||
this.k++
|
||||
changed = true
|
||||
}
|
||||
while (this.k > 0 && zoom >= this.table.events[this.k - 1].zSplit) {
|
||||
this.k--
|
||||
// A suppressed event was never applied — retreating past it is bookkeeping only.
|
||||
if (!this.suppressed.delete(this.k)) {
|
||||
unapplyEvent(this.visible, this.table.events[this.k])
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) this.version++
|
||||
}
|
||||
|
||||
detachLeaf(leafId: string): void {
|
||||
this.detachLeaves([leafId])
|
||||
}
|
||||
|
||||
detachLeaves(leafIds: Iterable<string>): void {
|
||||
const leafById = this.getLeafById()
|
||||
let changed = false
|
||||
for (const leafId of leafIds) {
|
||||
if (this.detached.has(leafId)) continue
|
||||
if (!leafById.has(leafId)) continue
|
||||
this.detached.add(leafId)
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return
|
||||
this.rebuildPatches()
|
||||
this.version++
|
||||
}
|
||||
|
||||
getVisible(): ReadonlyMap<string, ClusterNode> {
|
||||
if (this.resolvedCache && this.resolvedCache.version === this.version) {
|
||||
return this.resolvedCache.map
|
||||
}
|
||||
let map: Map<string, ClusterNode>
|
||||
if (this.patched.size === 0) {
|
||||
map = this.visible
|
||||
} else {
|
||||
map = new Map()
|
||||
for (const node of this.visible.values()) {
|
||||
const resolved = this.patched.has(node.id) ? this.patched.get(node.id)! : node
|
||||
if (resolved) map.set(resolved.id, resolved)
|
||||
}
|
||||
}
|
||||
this.resolvedCache = { version: this.version, map }
|
||||
return map
|
||||
}
|
||||
|
||||
private getLeafById(): Map<string, ClusterNode> {
|
||||
if (!this.leafById) {
|
||||
this.leafById = new Map(this.table.leaves.map((leaf) => [leaf.id, leaf]))
|
||||
}
|
||||
return this.leafById
|
||||
}
|
||||
|
||||
/** Recompute the patch map from the detached set. A node is patched iff it contains a detached
|
||||
* member; the patch drops those members and recomputes count/centroid, collapsing to the surviving
|
||||
* leaf at count 1 and to nothing at count 0. Patched nodes keep their structural id. */
|
||||
private rebuildPatches() {
|
||||
this.patched.clear()
|
||||
const leafById = this.getLeafById()
|
||||
for (const id of this.detached) {
|
||||
this.patched.set(id, null)
|
||||
}
|
||||
const patch = (node: ClusterNode) => {
|
||||
const members = node.members.filter((m) => !this.detached.has(m))
|
||||
if (members.length === node.members.length) return
|
||||
if (members.length === 0) {
|
||||
this.patched.set(node.id, null)
|
||||
} else if (members.length === 1) {
|
||||
this.patched.set(node.id, leafById.get(members[0])!)
|
||||
} else {
|
||||
let x = 0
|
||||
let y = 0
|
||||
for (const m of members) {
|
||||
const leaf = leafById.get(m)!
|
||||
x += leaf.centroid.x
|
||||
y += leaf.centroid.y
|
||||
}
|
||||
this.patched.set(node.id, {
|
||||
id: node.id,
|
||||
centroid: { x: x / members.length, y: y / members.length },
|
||||
count: members.length,
|
||||
members,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const event of this.table.events) {
|
||||
patch(event.result)
|
||||
for (const child of event.children) patch(child)
|
||||
}
|
||||
}
|
||||
|
||||
private reset() {
|
||||
this.suppressed.clear()
|
||||
this.detached.clear()
|
||||
this.patched.clear()
|
||||
this.visible = new Map(this.table.leaves.map((leaf) => [leaf.id, leaf]))
|
||||
}
|
||||
}
|
||||
|
||||
/** True iff every member id belonged to the same node of the previous partition. */
|
||||
function wasMergedTogether(
|
||||
members: readonly string[],
|
||||
ownerByMember: ReadonlyMap<string, string>
|
||||
): boolean {
|
||||
const owner = ownerByMember.get(members[0])
|
||||
if (owner === undefined) return false
|
||||
for (let i = 1; i < members.length; i++) {
|
||||
if (ownerByMember.get(members[i]) !== owner) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function seedCount(events: readonly MergeEvent[], zoom: number): number {
|
||||
let lo = 0
|
||||
let hi = events.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (zoom <= seedThreshold(events[mid])) {
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
return lo
|
||||
}
|
||||
|
||||
function seedThreshold(event: MergeEvent): number {
|
||||
return Math.sqrt(event.zMerge * event.zSplit)
|
||||
}
|
||||
|
||||
function applyEvent(visible: Map<string, ClusterNode>, event: MergeEvent) {
|
||||
for (const child of event.children) {
|
||||
visible.delete(child.id)
|
||||
}
|
||||
visible.set(event.result.id, event.result)
|
||||
}
|
||||
|
||||
function unapplyEvent(visible: Map<string, ClusterNode>, event: MergeEvent) {
|
||||
visible.delete(event.result.id)
|
||||
for (const child of event.children) {
|
||||
visible.set(child.id, child)
|
||||
}
|
||||
}
|
||||
|
||||
function validateZoom(zoom: number) {
|
||||
if (!Number.isFinite(zoom) || zoom <= 0) {
|
||||
throw new Error('zoom must be finite and greater than 0')
|
||||
}
|
||||
}
|
||||
508
packages/commenting/src/clustering/schedule.test.ts
Normal file
508
packages/commenting/src/clustering/schedule.test.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mstEdges } from './mst'
|
||||
import { cappedReplay } from './replay'
|
||||
// These imports are red until step 3 is implemented — that is intentional.
|
||||
// Implement `schedule.ts` (and the step 3 additions to `types.ts`) per
|
||||
// CLUSTERING-STEPS.md until this suite passes, without modifying this file.
|
||||
import { contract, finalize } from './schedule'
|
||||
import { ClusterNode, ContractedEvent, LeafInput, MergeEvent, RawMergeEvent } from './types'
|
||||
|
||||
// `contract` is specified over plain RawMergeEvent[] input precisely so it can
|
||||
// be tested with hand-built events, without running the replay.
|
||||
|
||||
/** Build a ClusterNode from member ids (leaf node for one id, cluster node for more). */
|
||||
function node(ids: string[], x = 0, y = 0): ClusterNode {
|
||||
const members = ids.slice().sort()
|
||||
return {
|
||||
id: members.length === 1 ? members[0] : `cluster:${members.length}:${members[0]}`,
|
||||
centroid: { x, y },
|
||||
count: members.length,
|
||||
members,
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a RawMergeEvent merging two nodes, with contract-conformant ordering. */
|
||||
function raw(z: number, a: ClusterNode, b: ClusterNode): RawMergeEvent {
|
||||
const [first, second] = a.members[0] < b.members[0] ? [a, b] : [b, a]
|
||||
return { z, children: [first, second], result: node([...a.members, ...b.members]) }
|
||||
}
|
||||
|
||||
/** Every cluster id referenced anywhere in the output (children and results). */
|
||||
function collectIds(events: readonly ContractedEvent[]): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const ev of events) {
|
||||
for (const c of ev.children) ids.add(c.id)
|
||||
ids.add(ev.result.id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function contractedShape(ev: ContractedEvent) {
|
||||
return {
|
||||
zMerge: ev.zMerge,
|
||||
children: ev.children.map((c) => c.id),
|
||||
result: ev.result.id,
|
||||
}
|
||||
}
|
||||
|
||||
const A = node(['a'])
|
||||
const B = node(['b'])
|
||||
const C = node(['c'])
|
||||
const D = node(['d'])
|
||||
const E = node(['e'])
|
||||
const F = node(['f'])
|
||||
const X = node(['x'])
|
||||
const Y = node(['y'])
|
||||
|
||||
describe('contract fixtures', () => {
|
||||
it('returns [] for empty input', () => {
|
||||
expect(contract([], 0.12)).toEqual([])
|
||||
})
|
||||
|
||||
it('passes a single event through unchanged (apart from the type)', () => {
|
||||
const e1 = raw(4, A, B)
|
||||
expect(contract([e1], 0.12)).toEqual([{ zMerge: 4, children: [A, B], result: e1.result }])
|
||||
})
|
||||
|
||||
it('fuses a two-event chain within eps into one 3-way event at the anchor z', () => {
|
||||
const e1 = raw(4, A, B) // result P = cluster:2:a
|
||||
const e2 = raw(3.7, e1.result, C) // 3.7 >= 4/1.12 ≈ 3.571 → same window, chained via P
|
||||
const out = contract([e1, e2], 0.12)
|
||||
expect(out).toEqual([{ zMerge: 4, children: [A, B, C], result: e2.result }])
|
||||
// the intermediate P is erased — representable nowhere
|
||||
expect(collectIds(out).has(e1.result.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the same chain separate when the second event falls outside eps', () => {
|
||||
const e1 = raw(4, A, B)
|
||||
const e2 = raw(3.5, e1.result, C) // 3.5 < 4/1.12 ≈ 3.571 → next window
|
||||
const out = contract([e1, e2], 0.12)
|
||||
expect(out.map(contractedShape)).toEqual([
|
||||
{ zMerge: 4, children: ['a', 'b'], result: e1.result.id },
|
||||
{ zMerge: 3.5, children: [e1.result.id, 'c'], result: e2.result.id },
|
||||
])
|
||||
// the intermediate survives: it is genuinely visible between 3.5 and 4
|
||||
expect(collectIds(out).has(e1.result.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('synchronizes unrelated same-window merges without fusing them', () => {
|
||||
const e1 = raw(4, A, B)
|
||||
const e2 = raw(3.9, X, Y) // within eps of the anchor, but disjoint
|
||||
const out = contract([e1, e2], 0.12)
|
||||
// two events, BOTH at the anchor z, ordered by ascending min-member id of result
|
||||
expect(out.map(contractedShape)).toEqual([
|
||||
{ zMerge: 4, children: ['a', 'b'], result: e1.result.id },
|
||||
{ zMerge: 4, children: ['x', 'y'], result: e2.result.id },
|
||||
])
|
||||
})
|
||||
|
||||
it('separates interleaved chains within one window by connectivity', () => {
|
||||
const e1 = raw(4, A, B) // chain 1
|
||||
const e2 = raw(3.95, X, Y) // chain 2, interleaved between chain 1's events
|
||||
const e3 = raw(3.9, e1.result, C) // chain 1 again, via e1.result
|
||||
const out = contract([e1, e2, e3], 0.12)
|
||||
expect(out.map(contractedShape)).toEqual([
|
||||
{ zMerge: 4, children: ['a', 'b', 'c'], result: e3.result.id },
|
||||
{ zMerge: 4, children: ['x', 'y'], result: e2.result.id },
|
||||
])
|
||||
expect(collectIds(out).has(e1.result.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('fuses branched chains (two results consumed by one later event)', () => {
|
||||
const e1 = raw(4, A, B) // → cluster:2:a
|
||||
const e2 = raw(3.9, C, D) // → cluster:2:c
|
||||
const e3 = raw(3.85, e1.result, e2.result) // consumes both → one connected chain
|
||||
const out = contract([e1, e2, e3], 0.12)
|
||||
expect(out).toEqual([{ zMerge: 4, children: [A, B, C, D], result: e3.result }])
|
||||
const ids = collectIds(out)
|
||||
expect(ids.has(e1.result.id)).toBe(false)
|
||||
expect(ids.has(e2.result.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('anchors the window to its first event, never the previous one', () => {
|
||||
// 3.8 is within eps of the 4.0 anchor; 3.55 is within eps of 3.8 but NOT of
|
||||
// the anchor (3.55 < 4/1.12 ≈ 3.571) — a gradient must break into windows.
|
||||
const e1 = raw(4, A, B)
|
||||
const e2 = raw(3.8, X, Y)
|
||||
const e3 = raw(3.55, C, D)
|
||||
const out = contract([e1, e2, e3], 0.12)
|
||||
expect(out.map((ev) => ev.zMerge)).toEqual([4, 4, 3.55])
|
||||
})
|
||||
|
||||
it('includes an event exactly on the window boundary (>=, not >)', () => {
|
||||
// eps = 0.25 → boundary at exactly 4/1.25 = 3.2
|
||||
const e1 = raw(4, A, B)
|
||||
const e2 = raw(3.2, X, Y)
|
||||
const out = contract([e1, e2], 0.25)
|
||||
expect(out.map((ev) => ev.zMerge)).toEqual([4, 4])
|
||||
})
|
||||
|
||||
it('fuses a chain that spans windows only up to the boundary, keeping the seam intact', () => {
|
||||
// One chain across five events; eps = 0.12 splits it into windows
|
||||
// {4.0, 3.8, 3.6} and {3.4, 3.2}. The second window's fused event must
|
||||
// reference the first window's fused RESULT (cluster:4:a), not an erased
|
||||
// intermediate.
|
||||
const e1 = raw(4, A, B) // → cluster:2:a
|
||||
const e2 = raw(3.8, e1.result, C) // → cluster:3:a
|
||||
const e3 = raw(3.6, e2.result, D) // → cluster:4:a
|
||||
const e4 = raw(3.4, e3.result, E) // → cluster:5:a
|
||||
const e5 = raw(3.2, e4.result, F) // → cluster:6:a
|
||||
const out = contract([e1, e2, e3, e4, e5], 0.12)
|
||||
expect(out.map(contractedShape)).toEqual([
|
||||
{ zMerge: 4, children: ['a', 'b', 'c', 'd'], result: 'cluster:4:a' },
|
||||
{ zMerge: 3.4, children: ['cluster:4:a', 'e', 'f'], result: 'cluster:6:a' },
|
||||
])
|
||||
const ids = collectIds(out)
|
||||
for (const erased of [e1.result.id, e2.result.id, e4.result.id]) {
|
||||
expect(ids.has(erased)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('groups exact ties with eps = 0, and nothing else', () => {
|
||||
const e1 = raw(4, A, B)
|
||||
const e2 = raw(4, e1.result, C) // exact tie, chained → fuses even at eps = 0
|
||||
expect(contract([e1, e2], 0)).toEqual([{ zMerge: 4, children: [A, B, C], result: e2.result }])
|
||||
|
||||
const e3 = raw(4, A, B)
|
||||
const e4 = raw(3.9999999, e3.result, C) // any difference → separate windows
|
||||
expect(contract([e3, e4], 0).map((ev) => ev.zMerge)).toEqual([4, 3.9999999])
|
||||
})
|
||||
|
||||
it('lets an Infinity anchor admit only other Infinity events', () => {
|
||||
const e1 = raw(Infinity, A, B)
|
||||
const e2 = raw(4, e1.result, C) // finite: must NOT join the Infinity window
|
||||
expect(contract([e1, e2], 0.12).map(contractedShape)).toEqual([
|
||||
{ zMerge: Infinity, children: ['a', 'b'], result: e1.result.id },
|
||||
{ zMerge: 4, children: [e1.result.id, 'c'], result: e2.result.id },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps two coincident groups separate inside one Infinity window', () => {
|
||||
const e1 = raw(Infinity, A, B)
|
||||
const e2 = raw(Infinity, X, Y)
|
||||
expect(contract([e1, e2], 0.12).map(contractedShape)).toEqual([
|
||||
{ zMerge: Infinity, children: ['a', 'b'], result: e1.result.id },
|
||||
{ zMerge: Infinity, children: ['x', 'y'], result: e2.result.id },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('contract validation and purity', () => {
|
||||
it('throws on negative or non-finite eps', () => {
|
||||
const events = [raw(4, A, B)]
|
||||
expect(() => contract(events, -0.1)).toThrow()
|
||||
expect(() => contract(events, NaN)).toThrow()
|
||||
expect(() => contract(events, Infinity)).toThrow()
|
||||
})
|
||||
|
||||
it('is pure: identical repeat output, no input mutation', () => {
|
||||
const e1 = raw(4, A, B)
|
||||
const e2 = raw(3.7, e1.result, C)
|
||||
const input = [e1, e2]
|
||||
const snapshot = JSON.parse(JSON.stringify(input))
|
||||
const first = contract(input, 0.12)
|
||||
const second = contract(input, 0.12)
|
||||
expect(first).toEqual(second)
|
||||
expect(JSON.parse(JSON.stringify(input))).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
||||
const FIN_OPTS = { Tc: 40, Tu: 60, minZoom: 0.1, maxZoom: 8, maxSplitZoom: 1e9 } // r = 1.5
|
||||
|
||||
function contracted(zMerge: number, children: ClusterNode[], result: ClusterNode): ContractedEvent {
|
||||
return { zMerge, children, result }
|
||||
}
|
||||
|
||||
describe('finalize fixtures', () => {
|
||||
it('returns [] for empty input', () => {
|
||||
expect(finalize([], FIN_OPTS)).toEqual([])
|
||||
})
|
||||
|
||||
it('applies zSplit = r · zMerge and preserves everything else', () => {
|
||||
const ev1 = contracted(4, [A, B], node(['a', 'b']))
|
||||
const ev2 = contracted(1, [node(['a', 'b']), C], node(['a', 'b', 'c']))
|
||||
expect(finalize([ev1, ev2], FIN_OPTS)).toEqual([
|
||||
{ zMerge: 4, zSplit: 6, children: ev1.children, result: ev1.result },
|
||||
{ zMerge: 1, zSplit: 1.5, children: ev2.children, result: ev2.result },
|
||||
])
|
||||
})
|
||||
|
||||
it('clamps a band that straddles maxZoom (the stuck-merged trap)', () => {
|
||||
// zMerge 6, r 1.5 → raw zSplit 9 > maxZoom 8: without the clamp this pair
|
||||
// would merge on zoom-out and never split again within reachable zoom.
|
||||
const ev = contracted(6, [A, B], node(['a', 'b']))
|
||||
const [out] = finalize([ev], FIN_OPTS)
|
||||
expect(out.zSplit).toBe(8)
|
||||
expect(out.zMerge).toBe(6)
|
||||
})
|
||||
|
||||
it('does not clamp bands that fit under maxZoom', () => {
|
||||
const ev = contracted(2, [A, B], node(['a', 'b']))
|
||||
expect(finalize([ev], FIN_OPTS)[0].zSplit).toBe(3)
|
||||
})
|
||||
|
||||
it('leaves zMerge >= maxZoom events unclamped by maxZoom (band above camera range)', () => {
|
||||
// zMerge exactly at maxZoom: the clamp condition is zMerge < maxZoom, strict
|
||||
const atMax = contracted(8, [A, B], node(['a', 'b']))
|
||||
expect(finalize([atMax], FIN_OPTS)[0].zSplit).toBe(12)
|
||||
// coincident anchors are no longer permanently merged — maxSplitZoom caps them
|
||||
const inf = contracted(Infinity, [X, Y], node(['x', 'y']))
|
||||
expect(finalize([inf], FIN_OPTS)[0]).toMatchObject({ zMerge: 1e9 / 1.5, zSplit: 1e9 })
|
||||
})
|
||||
|
||||
it('caps every split at maxSplitZoom, folding the band below the cap', () => {
|
||||
// r = 1.5, maxSplitZoom 6 -> zMerge cap 4: past zoom 6 nothing stays clustered,
|
||||
// however close (or coincident) the members are.
|
||||
const opts = { ...FIN_OPTS, maxSplitZoom: 6 }
|
||||
const coincident = contracted(Infinity, [A, B], node(['a', 'b']))
|
||||
const tight = contracted(10, [A, B], node(['a', 'b']))
|
||||
const normal = contracted(2, [A, B], node(['a', 'b']))
|
||||
expect(finalize([coincident], opts)[0]).toMatchObject({ zMerge: 4, zSplit: 6 })
|
||||
expect(finalize([tight], opts)[0]).toMatchObject({ zMerge: 4, zSplit: 6 })
|
||||
// events already below the cap are untouched
|
||||
expect(finalize([normal], opts)[0]).toMatchObject({ zMerge: 2, zSplit: 3 })
|
||||
})
|
||||
|
||||
it('capping preserves sort order and the zSplit > zMerge invariant', () => {
|
||||
const opts = { ...FIN_OPTS, maxSplitZoom: 6 }
|
||||
const events = [
|
||||
contracted(Infinity, [A, B], node(['a', 'b'])),
|
||||
contracted(10, [C, X], node(['c', 'x'])),
|
||||
contracted(2, [Y, node(['a', 'b'])], node(['a', 'b', 'y'])),
|
||||
]
|
||||
const out = finalize(events, opts)
|
||||
expect(out.map((e) => e.zMerge)).toEqual([4, 4, 2])
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
expect(out[i].zSplit).toBeGreaterThan(out[i].zMerge)
|
||||
if (i > 0) expect(out[i].zSplit).toBeLessThanOrEqual(out[i - 1].zSplit)
|
||||
}
|
||||
})
|
||||
|
||||
it('throws on non-positive or non-finite maxSplitZoom', () => {
|
||||
const events = [contracted(2, [A, B], node(['a', 'b']))]
|
||||
expect(() => finalize(events, { ...FIN_OPTS, maxSplitZoom: 0 })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, maxSplitZoom: -1 })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, maxSplitZoom: NaN })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, maxSplitZoom: Infinity })).toThrow()
|
||||
})
|
||||
|
||||
it('prunes the trailing suffix below minZoom, keeping the boundary value', () => {
|
||||
const keep = contracted(4, [A, B], node(['a', 'b']))
|
||||
const boundary = contracted(0.1, [node(['a', 'b']), C], node(['a', 'b', 'c'])) // == minZoom: kept
|
||||
const prune = contracted(0.0999, [node(['a', 'b', 'c']), D], node(['a', 'b', 'c', 'd']))
|
||||
const out = finalize([keep, boundary, prune], FIN_OPTS)
|
||||
expect(out.map((ev) => ev.zMerge)).toEqual([4, 0.1])
|
||||
})
|
||||
|
||||
it('throws on every invalid option combination', () => {
|
||||
const events = [contracted(4, [A, B], node(['a', 'b']))]
|
||||
expect(() => finalize(events, { ...FIN_OPTS, Tc: 0 })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, Tc: -1 })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, Tu: 40 })).toThrow() // Tu must be > Tc
|
||||
expect(() => finalize(events, { ...FIN_OPTS, Tu: 30 })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, minZoom: 0 })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, minZoom: -1 })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, maxZoom: 0.1 })).toThrow() // must be > minZoom
|
||||
expect(() => finalize(events, { ...FIN_OPTS, maxZoom: 0.05 })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, Tc: NaN })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, Tu: Infinity })).toThrow()
|
||||
expect(() => finalize(events, { ...FIN_OPTS, maxZoom: Infinity })).toThrow()
|
||||
})
|
||||
|
||||
it('is pure: identical repeat output, no input mutation', () => {
|
||||
const input = [contracted(4, [A, B], node(['a', 'b']))]
|
||||
const snapshot = JSON.parse(JSON.stringify(input))
|
||||
expect(finalize(input, FIN_OPTS)).toEqual(finalize(input, FIN_OPTS))
|
||||
expect(JSON.parse(JSON.stringify(input))).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
||||
// finalize(contract(cappedReplay(mstEdges(leaves)))) must satisfy every table
|
||||
// invariant of CLUSTERING.md §7.6.
|
||||
|
||||
function leaf(id: string, x: number, y: number): LeafInput {
|
||||
return { id, point: { x, y } }
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function shuffled<T>(items: readonly T[], rand: () => number): T[] {
|
||||
const out = items.slice()
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rand() * (i + 1))
|
||||
;[out[i], out[j]] = [out[j], out[i]]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
||||
const rand = mulberry32(seed)
|
||||
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
||||
}
|
||||
|
||||
const PIPE_OPTS = {
|
||||
Tc: 40,
|
||||
Tu: 60,
|
||||
eps: 0.12,
|
||||
Dmax: 120,
|
||||
minZoom: 1e-6,
|
||||
maxZoom: 8,
|
||||
maxSplitZoom: 1e9,
|
||||
}
|
||||
|
||||
function buildTableEvents(
|
||||
leaves: readonly LeafInput[],
|
||||
opts: typeof PIPE_OPTS = PIPE_OPTS
|
||||
): MergeEvent[] {
|
||||
const rawEvents = cappedReplay(leaves, mstEdges(leaves), { Tc: opts.Tc, Dmax: opts.Dmax })
|
||||
return finalize(contract(rawEvents, opts.eps), {
|
||||
Tc: opts.Tc,
|
||||
Tu: opts.Tu,
|
||||
minZoom: opts.minZoom,
|
||||
maxZoom: opts.maxZoom,
|
||||
maxSplitZoom: opts.maxSplitZoom,
|
||||
})
|
||||
}
|
||||
|
||||
/** All §7.6 invariants over a finalized event list. */
|
||||
function expectTableInvariants(
|
||||
events: readonly MergeEvent[],
|
||||
leaves: readonly LeafInput[],
|
||||
opts: typeof PIPE_OPTS
|
||||
) {
|
||||
// 1–3: threshold ordering and band validity
|
||||
for (let i = 1; i < events.length; i++) {
|
||||
expect(events[i].zMerge).toBeLessThanOrEqual(events[i - 1].zMerge)
|
||||
expect(events[i].zSplit).toBeLessThanOrEqual(events[i - 1].zSplit)
|
||||
}
|
||||
for (const ev of events) {
|
||||
// coincident clusters are the one exception: zMerge = zSplit = Infinity (never split)
|
||||
if (Number.isFinite(ev.zMerge)) {
|
||||
expect(ev.zSplit).toBeGreaterThan(ev.zMerge)
|
||||
} else {
|
||||
expect(ev.zSplit).toBe(Infinity)
|
||||
}
|
||||
if (ev.zMerge < opts.maxZoom) expect(ev.zSplit).toBeLessThanOrEqual(opts.maxZoom)
|
||||
expect(ev.zMerge).toBeGreaterThanOrEqual(opts.minZoom)
|
||||
}
|
||||
// 4: dendrogram integrity — replaying all events over the leaves is well
|
||||
// formed (children exist when consumed ⇒ no erased intermediate is referenced)
|
||||
const live = new Map<string, ClusterNode>()
|
||||
for (const l of leaves) {
|
||||
live.set(l.id, { id: l.id, centroid: { ...l.point }, count: 1, members: [l.id] })
|
||||
}
|
||||
for (const ev of events) {
|
||||
expect(ev.children.length).toBeGreaterThanOrEqual(2)
|
||||
for (const child of ev.children) {
|
||||
expect(live.get(child.id)).toBeDefined()
|
||||
live.delete(child.id)
|
||||
}
|
||||
for (let i = 1; i < ev.children.length; i++) {
|
||||
expect(ev.children[i - 1].members[0] < ev.children[i].members[0]).toBe(true)
|
||||
}
|
||||
const union = ev.children.flatMap((c) => c.members).sort()
|
||||
expect(ev.result.members).toEqual(union)
|
||||
expect(ev.result.count).toBe(union.length)
|
||||
expect(ev.result.id).toBe(`cluster:${union.length}:${union[0]}`)
|
||||
live.set(ev.result.id, ev.result)
|
||||
}
|
||||
// with minZoom = 1e-6 nothing prunes, so the dendrogram completes to one root
|
||||
if (leaves.length >= 2) {
|
||||
expect(live.size).toBe(1)
|
||||
expect([...live.values()][0].members).toEqual(leaves.map((l) => l.id).sort())
|
||||
}
|
||||
}
|
||||
|
||||
describe('composed pipeline (replay → contract → finalize)', () => {
|
||||
it('satisfies all table invariants on random inputs', () => {
|
||||
for (const seed of [3, 42]) {
|
||||
for (const n of [2, 7, 30, 80]) {
|
||||
const leaves = randomLeaves(n, seed * 17 + n)
|
||||
expectTableInvariants(buildTableEvents(leaves), leaves, PIPE_OPTS)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('satisfies all table invariants with ties and coincident anchors (lattice)', () => {
|
||||
const rand = mulberry32(11)
|
||||
const leaves = Array.from({ length: 40 }, (_, i) =>
|
||||
leaf(`lat-${i}`, Math.floor(rand() * 8) * 10, Math.floor(rand() * 8) * 10)
|
||||
)
|
||||
expectTableInvariants(buildTableEvents(leaves), leaves, PIPE_OPTS)
|
||||
})
|
||||
|
||||
it('reduces to the identity at eps = 0 on tie-free input', () => {
|
||||
// Centroid pricing plus the monotonicity clamp can produce exact ties even
|
||||
// on random input (a clamped threshold lands exactly on its predecessor),
|
||||
// and eps = 0 legitimately fuses chained ties. Identity is only promised
|
||||
// for tie-free tables — so probe seeds until one produces distinct
|
||||
// thresholds, and assert the identity there.
|
||||
let verified = false
|
||||
for (const seed of [5, 6, 7, 8, 9, 10]) {
|
||||
const leaves = randomLeaves(40, seed)
|
||||
const rawEvents = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
||||
if (new Set(rawEvents.map((ev) => ev.z)).size !== rawEvents.length) continue
|
||||
const contractedEvents = contract(rawEvents, 0)
|
||||
expect(
|
||||
contractedEvents.map((ev) => ({ z: ev.zMerge, c: ev.children, r: ev.result }))
|
||||
).toEqual(rawEvents.map((ev) => ({ z: ev.z, c: ev.children, r: ev.result })))
|
||||
verified = true
|
||||
}
|
||||
expect(verified).toBe(true)
|
||||
})
|
||||
|
||||
it('schedules the uniform chain as synchronized pairs, quads, then the octet', () => {
|
||||
// CLUSTERING.md appendix A.2 with centroid pricing: leaf pairs all fire at
|
||||
// z = 4 (one contraction window, four disconnected chains → four events at
|
||||
// the shared anchor), quads at centroid distance 20 → z = 2, and the
|
||||
// quad-quad bridge at centroid distance 40 → z = 1.
|
||||
const leaves = Array.from({ length: 8 }, (_, i) => leaf(`p${i + 1}`, i * 10, 0))
|
||||
const events = buildTableEvents(leaves)
|
||||
expect(
|
||||
events.map((ev) => ({
|
||||
zMerge: ev.zMerge,
|
||||
children: ev.children.map((c) => c.id),
|
||||
result: ev.result.id,
|
||||
}))
|
||||
).toEqual([
|
||||
{ zMerge: 4, children: ['p1', 'p2'], result: 'cluster:2:p1' },
|
||||
{ zMerge: 4, children: ['p3', 'p4'], result: 'cluster:2:p3' },
|
||||
{ zMerge: 4, children: ['p5', 'p6'], result: 'cluster:2:p5' },
|
||||
{ zMerge: 4, children: ['p7', 'p8'], result: 'cluster:2:p7' },
|
||||
{ zMerge: 2, children: ['cluster:2:p1', 'cluster:2:p3'], result: 'cluster:4:p1' },
|
||||
{ zMerge: 2, children: ['cluster:2:p5', 'cluster:2:p7'], result: 'cluster:4:p5' },
|
||||
{ zMerge: 1, children: ['cluster:4:p1', 'cluster:4:p5'], result: 'cluster:8:p1' },
|
||||
])
|
||||
expect(events.map((ev) => ev.zSplit)).toEqual([6, 6, 6, 6, 3, 3, 1.5])
|
||||
})
|
||||
|
||||
it('is deterministic end to end under input permutation', () => {
|
||||
const base = randomLeaves(50, 23)
|
||||
const expected = buildTableEvents(base).map((ev) => ({
|
||||
zMerge: ev.zMerge,
|
||||
zSplit: ev.zSplit,
|
||||
children: ev.children.map((c) => c.id),
|
||||
result: ev.result.id,
|
||||
}))
|
||||
for (const shuffleSeed of [7, 29]) {
|
||||
const perm = shuffled(base, mulberry32(shuffleSeed))
|
||||
expect(
|
||||
buildTableEvents(perm).map((ev) => ({
|
||||
zMerge: ev.zMerge,
|
||||
zSplit: ev.zSplit,
|
||||
children: ev.children.map((c) => c.id),
|
||||
result: ev.result.id,
|
||||
}))
|
||||
).toEqual(expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
184
packages/commenting/src/clustering/schedule.ts
Normal file
184
packages/commenting/src/clustering/schedule.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type { ClusterNode, ContractedEvent, MergeEvent, RawMergeEvent } from './types'
|
||||
|
||||
export function contract(raw: readonly RawMergeEvent[], eps: number): ContractedEvent[] {
|
||||
if (!Number.isFinite(eps) || eps < 0) {
|
||||
throw new Error('eps must be finite and greater than or equal to 0')
|
||||
}
|
||||
|
||||
const out: ContractedEvent[] = []
|
||||
let i = 0
|
||||
while (i < raw.length) {
|
||||
const anchor = raw[i].z
|
||||
let j = i
|
||||
while (j + 1 < raw.length && raw[j + 1].z >= anchor / (1 + eps)) {
|
||||
j++
|
||||
}
|
||||
|
||||
const chains = contractWindow(raw.slice(i, j + 1), anchor)
|
||||
out.push(...chains)
|
||||
i = j + 1
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export function finalize(
|
||||
events: readonly ContractedEvent[],
|
||||
opts: { Tc: number; Tu: number; minZoom: number; maxZoom: number; maxSplitZoom: number }
|
||||
): MergeEvent[] {
|
||||
validateFinalizeOptions(opts)
|
||||
|
||||
const r = opts.Tu / opts.Tc
|
||||
// Every cluster must have split by maxSplitZoom, however close (or coincident) its members.
|
||||
// Capping zMerge at maxSplitZoom / r keeps the whole band below the cap, preserving
|
||||
// zSplit > zMerge and the table's sort order (min with a constant is order-preserving).
|
||||
const zMergeCap = opts.maxSplitZoom / r
|
||||
const out: MergeEvent[] = []
|
||||
for (const event of events) {
|
||||
const zMerge = Math.min(event.zMerge, zMergeCap)
|
||||
if (zMerge < opts.minZoom) break
|
||||
let zSplit = zMerge * r
|
||||
if (zMerge < opts.maxZoom) {
|
||||
zSplit = Math.min(zSplit, opts.maxZoom)
|
||||
}
|
||||
out.push({
|
||||
zMerge,
|
||||
zSplit,
|
||||
children: event.children,
|
||||
result: event.result,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function contractWindow(events: readonly RawMergeEvent[], zMerge: number): ContractedEvent[] {
|
||||
const uf = new ComponentUnionFind(events.length)
|
||||
const resultToIndex = new Map<string, number>()
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
resultToIndex.set(events[i].result.id, i)
|
||||
}
|
||||
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
for (const child of events[i].children) {
|
||||
const producer = resultToIndex.get(child.id)
|
||||
if (producer !== undefined) {
|
||||
uf.union(i, producer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const byRoot = new Map<number, number[]>()
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const root = uf.find(i)
|
||||
const list = byRoot.get(root)
|
||||
if (list) {
|
||||
list.push(i)
|
||||
} else {
|
||||
byRoot.set(root, [i])
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byRoot.values())
|
||||
.map((indices) => contractChain(events, indices, zMerge))
|
||||
.sort((a, b) => compareNodesByMinMember(a.result, b.result))
|
||||
}
|
||||
|
||||
function contractChain(
|
||||
events: readonly RawMergeEvent[],
|
||||
indices: readonly number[],
|
||||
zMerge: number
|
||||
): ContractedEvent {
|
||||
const produced = new Set<string>()
|
||||
const consumedProduced = new Set<string>()
|
||||
for (const index of indices) {
|
||||
produced.add(events[index].result.id)
|
||||
}
|
||||
for (const index of indices) {
|
||||
for (const child of events[index].children) {
|
||||
if (produced.has(child.id)) consumedProduced.add(child.id)
|
||||
}
|
||||
}
|
||||
|
||||
const childrenById = new Map<string, ClusterNode>()
|
||||
for (const index of indices) {
|
||||
for (const child of events[index].children) {
|
||||
if (!produced.has(child.id)) childrenById.set(child.id, child)
|
||||
}
|
||||
}
|
||||
|
||||
let result: ClusterNode | undefined
|
||||
for (const index of indices) {
|
||||
const candidate = events[index].result
|
||||
if (!consumedProduced.has(candidate.id)) {
|
||||
if (!result || compareNodesByMinMember(candidate, result) < 0) result = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
zMerge,
|
||||
children: Array.from(childrenById.values()).sort(compareNodesByMinMember),
|
||||
result: result!,
|
||||
}
|
||||
}
|
||||
|
||||
function validateFinalizeOptions(opts: {
|
||||
Tc: number
|
||||
Tu: number
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
maxSplitZoom: number
|
||||
}) {
|
||||
if (!Number.isFinite(opts.maxSplitZoom) || opts.maxSplitZoom <= 0) {
|
||||
throw new Error('maxSplitZoom must be finite and greater than 0')
|
||||
}
|
||||
if (!Number.isFinite(opts.Tc) || opts.Tc <= 0) {
|
||||
throw new Error('Tc must be finite and greater than 0')
|
||||
}
|
||||
if (!Number.isFinite(opts.Tu) || opts.Tu <= opts.Tc) {
|
||||
throw new Error('Tu must be finite and greater than Tc')
|
||||
}
|
||||
if (!Number.isFinite(opts.minZoom) || opts.minZoom <= 0) {
|
||||
throw new Error('minZoom must be finite and greater than 0')
|
||||
}
|
||||
if (!Number.isFinite(opts.maxZoom) || opts.maxZoom <= opts.minZoom) {
|
||||
throw new Error('maxZoom must be finite and greater than minZoom')
|
||||
}
|
||||
}
|
||||
|
||||
function compareNodesByMinMember(a: ClusterNode, b: ClusterNode): number {
|
||||
const aMin = a.members[0]
|
||||
const bMin = b.members[0]
|
||||
if (aMin < bMin) return -1
|
||||
if (aMin > bMin) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
class ComponentUnionFind {
|
||||
private readonly parent: Int32Array
|
||||
|
||||
constructor(n: number) {
|
||||
this.parent = new Int32Array(n)
|
||||
for (let i = 0; i < n; i++) {
|
||||
this.parent[i] = i
|
||||
}
|
||||
}
|
||||
|
||||
find(index: number): number {
|
||||
let root = index
|
||||
while (this.parent[root] !== root) {
|
||||
root = this.parent[root]
|
||||
}
|
||||
while (this.parent[index] !== index) {
|
||||
const next = this.parent[index]
|
||||
this.parent[index] = root
|
||||
index = next
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
union(a: number, b: number) {
|
||||
const rootA = this.find(a)
|
||||
const rootB = this.find(b)
|
||||
if (rootA !== rootB) this.parent[rootB] = rootA
|
||||
}
|
||||
}
|
||||
171
packages/commenting/src/clustering/screen-offsets.test.ts
Normal file
171
packages/commenting/src/clustering/screen-offsets.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { VecLike } from 'tldraw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeClusterTable } from './computeClusterTable'
|
||||
import type { LeafInput } from './types'
|
||||
|
||||
function leaf(id: string, x: number, y: number): LeafInput {
|
||||
return { id, point: { x, y } }
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = a
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1)
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
||||
const rand = mulberry32(seed)
|
||||
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
||||
}
|
||||
|
||||
const ZOOM_BOUNDS = { minZoom: 0.05, maxZoom: 8 }
|
||||
// eps 0 keeps every merge as its own solo event, so the exact thresholds under test aren't
|
||||
// snapped to a contraction window's anchor.
|
||||
const EXACT_OPTS = { Tc: 22, Tu: 26.4, eps: 0, Dmax: 82.5, ...ZOOM_BOUNDS }
|
||||
|
||||
function offsets(entries: Record<string, VecLike>): Map<string, VecLike> {
|
||||
return new Map(Object.entries(entries))
|
||||
}
|
||||
|
||||
describe('computeClusterTable without screen offsets (the guarantee)', () => {
|
||||
it('is deep-equal across omitted, undefined, and empty offset arguments', () => {
|
||||
for (const seed of [3, 21]) {
|
||||
const leaves = randomLeaves(40, seed)
|
||||
const plain = computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
||||
expect(computeClusterTable(leaves, { ...ZOOM_BOUNDS }, undefined)).toEqual(plain)
|
||||
expect(computeClusterTable(leaves, { ...ZOOM_BOUNDS }, new Map())).toEqual(plain)
|
||||
}
|
||||
})
|
||||
|
||||
it('is deep-equal when every leaf carries the same offset (Δō is always zero)', () => {
|
||||
const leaves = randomLeaves(30, 11)
|
||||
const uniform = new Map(leaves.map((l) => [l.id, { x: -20, y: 20 }]))
|
||||
expect(computeClusterTable(leaves, { ...ZOOM_BOUNDS }, uniform)).toEqual(
|
||||
computeClusterTable(leaves, { ...ZOOM_BOUNDS })
|
||||
)
|
||||
})
|
||||
|
||||
it('never emits a zSplit override on the offset-free path', () => {
|
||||
const table = computeClusterTable(randomLeaves(20, 5), { ...ZOOM_BOUNDS }, new Map())
|
||||
for (const event of table.events) {
|
||||
// finalize always derives ratio splits when no override was carried
|
||||
expect(event.zSplit).toBeCloseTo(event.zMerge * 1.2, 10)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('offset-aware pricing', () => {
|
||||
const A = leaf('a', 0, 0)
|
||||
const B = leaf('b', 100, 0)
|
||||
|
||||
it('merges earlier when the insets point toward each other, at the exact visual crossing', () => {
|
||||
// Visual positions: a at 100·0·z + 20, b at 100z − 20 → distance |100z − 40|. Tc = 22
|
||||
// crosses at z = 0.62 (raw anchors would say 0.22); the split derives from the Tu/Tc
|
||||
// ratio, as for every event.
|
||||
const table = computeClusterTable(
|
||||
[A, B],
|
||||
EXACT_OPTS,
|
||||
offsets({ a: { x: 20, y: 0 }, b: { x: -20, y: 0 } })
|
||||
)
|
||||
expect(table.events).toHaveLength(1)
|
||||
expect(table.events[0].zMerge).toBeCloseTo(0.62, 10)
|
||||
expect(table.events[0].zSplit).toBeCloseTo(0.62 * 1.2, 10)
|
||||
})
|
||||
|
||||
it('merges later when the insets point apart, at the exact visual crossing', () => {
|
||||
// Distance 100z + 10: Tc at z = 0.12 (raw: 0.22); ratio split at 0.144.
|
||||
const table = computeClusterTable(
|
||||
[A, B],
|
||||
EXACT_OPTS,
|
||||
offsets({ a: { x: -5, y: 0 }, b: { x: 5, y: 0 } })
|
||||
)
|
||||
expect(table.events).toHaveLength(1)
|
||||
expect(table.events[0].zMerge).toBeCloseTo(0.12, 10)
|
||||
expect(table.events[0].zSplit).toBeCloseTo(0.144, 10)
|
||||
})
|
||||
|
||||
it('never merges a pair whose visual distance never reaches Tc', () => {
|
||||
// Distance 100z + 40 ≥ 40 > Tc at every zoom: priced 0 and pruned by the minZoom cut.
|
||||
const table = computeClusterTable(
|
||||
[A, B],
|
||||
EXACT_OPTS,
|
||||
offsets({ a: { x: -20, y: 0 }, b: { x: 20, y: 0 } })
|
||||
)
|
||||
expect(table.events).toEqual([])
|
||||
})
|
||||
|
||||
it('still applies the Dmax fit cap to a corrected merge', () => {
|
||||
// Exaggerated inward offsets put the Tc crossing at z = 1.42, above the spread cap
|
||||
// Dmax/diag = 82.5/100 = 0.825, so the cap wins the min.
|
||||
const table = computeClusterTable(
|
||||
[A, B],
|
||||
EXACT_OPTS,
|
||||
offsets({ a: { x: 60, y: 0 }, b: { x: -60, y: 0 } })
|
||||
)
|
||||
expect(table.events).toHaveLength(1)
|
||||
expect(table.events[0].zMerge).toBeCloseTo(0.825, 10)
|
||||
expect(table.events[0].zSplit).toBeCloseTo(0.825 * 1.2, 10)
|
||||
})
|
||||
|
||||
it('treats coincident anchors with differing offsets as a constant visual distance', () => {
|
||||
// Same anchor point, one pin tucked: constant 20px apart < Tc → merged at every zoom,
|
||||
// entering the same maxSplitZoom band as a coincident pair (zMerge 6/1.2 = 5, zSplit 6).
|
||||
const table = computeClusterTable(
|
||||
[leaf('a', 50, 50), leaf('b', 50, 50)],
|
||||
{ ...EXACT_OPTS, maxSplitZoom: 6 },
|
||||
offsets({ a: { x: 20, y: 0 } })
|
||||
)
|
||||
expect(table.events).toHaveLength(1)
|
||||
expect(table.events[0].zMerge).toBe(5)
|
||||
expect(table.events[0].zSplit).toBe(6)
|
||||
})
|
||||
|
||||
it("dilutes a cluster's mean offset as precise members fold in", () => {
|
||||
// a (tucked 20px) and b (precise) share an anchor: constant 20px apart, always merged.
|
||||
// Their cluster's mean offset is (10, 0); against precise c 100 units right the visual
|
||||
// distance is |100z − 10| → Tc at z = 0.32 — half the lone pin's correction.
|
||||
const table = computeClusterTable(
|
||||
[leaf('a', 0, 0), leaf('b', 0, 0), leaf('c', 100, 0)],
|
||||
EXACT_OPTS,
|
||||
offsets({ a: { x: 20, y: 0 } })
|
||||
)
|
||||
expect(table.events).toHaveLength(2)
|
||||
const outer = table.events.find((e) => e.result.count === 3)!
|
||||
expect(outer.zMerge).toBeCloseTo(0.32, 10)
|
||||
expect(outer.zSplit).toBeCloseTo(0.32 * 1.2, 10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('table ordering with offsets', () => {
|
||||
it('keeps zMerge and zSplit non-increasing (splits are always ratio-derived)', () => {
|
||||
// Mixed offset and plain leaves at varied spacings: every split is zMerge · (Tu/Tc), so
|
||||
// both threshold sequences inherit the table's non-increasing order — the invariant the
|
||||
// runtime's prefix cursor, split walk, and seed bisection rely on.
|
||||
const leaves = [
|
||||
leaf('a', 0, 0),
|
||||
leaf('b', 100, 0),
|
||||
leaf('c', 130, 0),
|
||||
leaf('d', 400, 0),
|
||||
leaf('e', 470, 0),
|
||||
leaf('f', 1000, 300),
|
||||
]
|
||||
const table = computeClusterTable(
|
||||
leaves,
|
||||
EXACT_OPTS,
|
||||
offsets({ a: { x: -20, y: 0 }, b: { x: 14, y: 0 }, d: { x: 20, y: 14 } })
|
||||
)
|
||||
expect(table.events.length).toBeGreaterThan(2)
|
||||
for (let i = 1; i < table.events.length; i++) {
|
||||
expect(table.events[i].zMerge).toBeLessThanOrEqual(table.events[i - 1].zMerge)
|
||||
expect(table.events[i].zSplit).toBeLessThanOrEqual(table.events[i - 1].zSplit)
|
||||
}
|
||||
for (const event of table.events) {
|
||||
expect(event.zSplit).toBeCloseTo(event.zMerge * 1.2, 10)
|
||||
}
|
||||
})
|
||||
})
|
||||
112
packages/commenting/src/clustering/types.ts
Normal file
112
packages/commenting/src/clustering/types.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { VecLike } from 'tldraw'
|
||||
|
||||
/**
|
||||
* One comment thread's pin, already resolved to a page-space anchor point.
|
||||
* @internal
|
||||
*/
|
||||
export interface LeafInput {
|
||||
/** Unique. Thread id. Uniqueness is a precondition — throw on duplicates. */
|
||||
id: string
|
||||
/** Page-space coordinates. Must be finite — throw on NaN/Infinity. */
|
||||
point: VecLike
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant screen-px render offsets for markers that don't draw exactly on their anchor —
|
||||
* imprecise shape pins, which tuck ~20px into their shape. Keyed by leaf id; leaves absent from
|
||||
* the map render on their anchor. A marker's visual position at zoom z is `z · point + offset`.
|
||||
* Passed as the optional third argument to `computeClusterTable`; when omitted or empty the
|
||||
* algorithm's behavior (and output) is identical to an offset-unaware run.
|
||||
* @internal
|
||||
*/
|
||||
export type LeafScreenOffsets = ReadonlyMap<string, VecLike>
|
||||
|
||||
/** An edge of the Euclidean MST over the leaf anchor points. */
|
||||
export interface MstEdge {
|
||||
/** Index into the input leaves array. Normalized: leaves[a].id < leaves[b].id (lexicographic). */
|
||||
a: number
|
||||
/** Index into the input leaves array. */
|
||||
b: number
|
||||
/** Exact Euclidean page-space distance between the two anchors. May be 0 (coincident). */
|
||||
d: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A cluster in the merge tree: a leaf (one thread) or a merged group.
|
||||
* @internal
|
||||
*/
|
||||
export interface ClusterNode {
|
||||
/** Leaves: the thread id verbatim. Merged nodes: `cluster:${count}:${minMemberId}`. */
|
||||
id: string
|
||||
/** Page space; count-weighted mean of all member leaf anchors. */
|
||||
centroid: VecLike
|
||||
/** Number of member leaves. Leaves = 1. */
|
||||
count: number
|
||||
/** All member thread ids, sorted lexicographically ascending. */
|
||||
members: string[]
|
||||
}
|
||||
|
||||
/** One merge produced by the capped replay, before contraction. */
|
||||
export interface RawMergeEvent {
|
||||
/** Effective merge threshold zEff = min(Tc/d, Dmax/unionBboxDiag). +Infinity for coincident anchors. */
|
||||
z: number
|
||||
/** The two clusters consumed, ordered by ascending min-member id. */
|
||||
children: [ClusterNode, ClusterNode]
|
||||
/** The cluster produced. */
|
||||
result: ClusterNode
|
||||
}
|
||||
|
||||
/** A merge event after contraction: possibly multi-way, before hysteresis. */
|
||||
export interface ContractedEvent {
|
||||
/** Fires (merges) when zoom <= zMerge. May be +Infinity. */
|
||||
zMerge: number
|
||||
/** Clusters consumed — 2 or more, ordered by ascending min-member id. */
|
||||
children: ClusterNode[]
|
||||
/** Cluster produced. */
|
||||
result: ClusterNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A finalized merge event, ready for the runtime.
|
||||
* @internal
|
||||
*/
|
||||
export interface MergeEvent {
|
||||
zMerge: number
|
||||
/** Reverses (splits) when zoom \>= zSplit. Always \> zMerge. May be +Infinity. */
|
||||
zSplit: number
|
||||
children: ClusterNode[]
|
||||
result: ClusterNode
|
||||
}
|
||||
|
||||
/**
|
||||
* The precomputed clustering schedule for one page's comments.
|
||||
* @internal
|
||||
*/
|
||||
export interface ClusterTable {
|
||||
/** Sorted non-increasing by zMerge; satisfies the invariants of CLUSTERING.md §7.6. */
|
||||
events: readonly MergeEvent[]
|
||||
/** One node per input leaf, in input order. */
|
||||
leaves: readonly ClusterNode[]
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface ClusterOptions {
|
||||
/** Cluster (merge) distance, screen px. Default 22. */
|
||||
Tc?: number
|
||||
/** Uncluster (split) distance, screen px. Must be \> Tc. Default 1.2 · Tc. */
|
||||
Tu?: number
|
||||
/** Contraction window ratio. Default 0.7. */
|
||||
eps?: number
|
||||
/** Max cluster screen extent at birth, screen px. Must be \>= Tc. Default 3.75 · Tc. */
|
||||
Dmax?: number
|
||||
/** Camera zoom bounds — pass the editor's camera constraints. Required. */
|
||||
minZoom: number
|
||||
maxZoom: number
|
||||
/**
|
||||
* Zoom by which every cluster has split, no matter how close its members are — including
|
||||
* coincident anchors, which otherwise never split. Merge thresholds are capped at
|
||||
* `maxSplitZoom / (Tu/Tc)` so the hysteresis band keeps its shape below the cap.
|
||||
* Default 6 (600%).
|
||||
*/
|
||||
maxSplitZoom?: number
|
||||
}
|
||||
145
packages/commenting/src/index.ts
Normal file
145
packages/commenting/src/index.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { registerTldrawLibraryVersion } from '@tldraw/utils'
|
||||
|
||||
// Presentational commenting components. These are tldraw-independent and can be used to build
|
||||
// custom commenting UI. The mention picker, pill, and avatar are re-exported from @tldraw/mentions
|
||||
// so the commenting public API is unchanged.
|
||||
export {
|
||||
Avatar,
|
||||
type AvatarProps,
|
||||
type CommentAuthor,
|
||||
createMentionSuggestion,
|
||||
filterMentionMembers,
|
||||
Mention,
|
||||
MentionList,
|
||||
type MentionListProps,
|
||||
type MentionMember,
|
||||
type MentionProps,
|
||||
type MentionSuggestionOptions,
|
||||
} from '@tldraw/mentions'
|
||||
export { Byline, type BylineProps } from './ui/byline'
|
||||
export { CommentCard, type CommentCardProps } from './ui/comment-card'
|
||||
export { CommentComposer, type CommentComposerProps } from './ui/comment-composer'
|
||||
export { CountBadge, type CountBadgeProps } from './ui/count-badge'
|
||||
export { CommentPin, type CommentPinProps } from './ui/comment-pin'
|
||||
export { CommentThread, type CommentThreadProps } from './ui/comment-thread'
|
||||
export {
|
||||
CommentListItem,
|
||||
CommentsList,
|
||||
isOpenInNewTabClick,
|
||||
type CommentListItemProps,
|
||||
type CommentListItemRenderProps,
|
||||
type CommentsListProps,
|
||||
} from './ui/comments-list'
|
||||
export { EmptyState, type EmptyStateProps } from './ui/empty-state'
|
||||
export { formatFullDateTime, formatRelativeTime } from './ui/format-time'
|
||||
export {
|
||||
DEFAULT_REACTION_EMOJI,
|
||||
EmojiPicker,
|
||||
type EmojiPickerProps,
|
||||
isAllowedReactionEmoji,
|
||||
} from './ui/emoji-picker'
|
||||
export {
|
||||
Reaction,
|
||||
type ReactionProps,
|
||||
type RenderReaction,
|
||||
defaultRenderReaction,
|
||||
DefaultReactionTooltip,
|
||||
DefaultReactionTooltipContent,
|
||||
type ReactionTooltipProps,
|
||||
} from './ui/reaction'
|
||||
export { ReactionPicker, type ReactionPickerProps } from './ui/reaction-picker'
|
||||
export {
|
||||
Reactions,
|
||||
type ReactionReactor,
|
||||
type ReactionsProps,
|
||||
type ReactionSummary,
|
||||
} from './ui/reactions'
|
||||
export { SendButton, type SendButtonProps } from './ui/send-button'
|
||||
|
||||
// The tldraw-coupled commenting layer: the comment tool, reactive hooks over the comment
|
||||
// records, a rich-text body renderer, and a batteries-included <CanvasComments> overlay. Pairs
|
||||
// with the presentational components above.
|
||||
export { registerCommentAnchorLifecycle } from './canvas/anchor-lifecycle'
|
||||
export { CommentBody, type CommentBodyProps } from './canvas/comment-body'
|
||||
export {
|
||||
deleteComment,
|
||||
deleteThread,
|
||||
editComment,
|
||||
putCommentRecords,
|
||||
removeCommentRecords,
|
||||
reopenThread,
|
||||
resolveThread,
|
||||
} from './canvas/comment-mutations'
|
||||
export {
|
||||
CommentReactionPicker,
|
||||
type CommentReactionPickerProps,
|
||||
CommentReactions,
|
||||
type CommentReactionsProps,
|
||||
type ReactionSummaryInput,
|
||||
summarizeReactions,
|
||||
toggleCommentReaction,
|
||||
useCommentReactions,
|
||||
} from './canvas/comment-reactions'
|
||||
export { CommentTool, commentToolOverrides, commentTools } from './canvas/comment-tool'
|
||||
export {
|
||||
getCommentReactions,
|
||||
getCommentRecord,
|
||||
getComments,
|
||||
getCommentThreads,
|
||||
getLiveComments,
|
||||
getLiveCommentThreads,
|
||||
type TLCommentRecord,
|
||||
} from './canvas/comment-store'
|
||||
export { type CommentingContext } from './canvas/context'
|
||||
export { CommentsFilterMenu, type CommentsFilterMenuProps } from './canvas/comments-filter-menu'
|
||||
export { CommentsMenuItem } from './canvas/comments-menu-item'
|
||||
export { CanvasComments, type CanvasCommentsProps } from './canvas/comments-overlay'
|
||||
export {
|
||||
type CommentingComponents,
|
||||
type CommentingOptions,
|
||||
type CommentModification,
|
||||
type CommentModificationContext,
|
||||
defaultCanModifyComment,
|
||||
defaultCommentingOptions,
|
||||
getCanComment,
|
||||
getCanModifyComment,
|
||||
getCommentingOptions,
|
||||
type ShapeCommentPrecisionContext,
|
||||
useCanComment,
|
||||
useCanModifyComment,
|
||||
useCommentingOptions,
|
||||
} from './canvas/options'
|
||||
export { CommentsOverflowMenu } from './canvas/comments-overflow-menu'
|
||||
export { CommentsVisibilityToggle } from './canvas/comments-visibility-toggle'
|
||||
export {
|
||||
CanvasCommentsSidebar,
|
||||
sortSidebarRows,
|
||||
type CanvasCommentsSidebarProps,
|
||||
type SidebarRow,
|
||||
} from './canvas/comments-sidebar'
|
||||
export { useComments, useCommentThreads, useThreadComments } from './canvas/hooks'
|
||||
export { useCommentingEnabled } from './canvas/license'
|
||||
export { richTextToPlaintext } from './canvas/rich-text'
|
||||
export { DEFAULT_SIDEBAR_FILTERS, type SidebarFilters } from './canvas/sidebar-filters'
|
||||
export {
|
||||
commentsHidden,
|
||||
commentsSidebarOpen,
|
||||
getRevealThreadPending,
|
||||
openThreadId,
|
||||
revealThread,
|
||||
sidebarFilters,
|
||||
toggleCommentsHidden,
|
||||
toggleCommentsSidebar,
|
||||
useCommentsHidden,
|
||||
useCommentsSidebarOpen,
|
||||
useOpenThreadId,
|
||||
useRevealThreadPending,
|
||||
useSidebarFilters,
|
||||
} from './canvas/state'
|
||||
export { anchorPagePoint, focusThread, shapeAnchorAt } from './canvas/thread-state'
|
||||
|
||||
registerTldrawLibraryVersion(
|
||||
(globalThis as any).TLDRAW_LIBRARY_NAME,
|
||||
(globalThis as any).TLDRAW_LIBRARY_VERSION,
|
||||
(globalThis as any).TLDRAW_LIBRARY_MODULES
|
||||
)
|
||||
35
packages/commenting/src/ui/byline.tsx
Normal file
35
packages/commenting/src/ui/byline.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Avatar, type CommentAuthor } from '@tldraw/mentions'
|
||||
import { TldrawUiTooltip, useCurrentTranslation } from 'tldraw'
|
||||
import { formatFullDateTime, formatRelativeTime } from './format-time'
|
||||
|
||||
/** @public */
|
||||
export interface BylineProps {
|
||||
author: CommentAuthor
|
||||
/** ISO datetime; formatted to relative time by the component. */
|
||||
date: string
|
||||
/** Shows an "edited" marker when the comment has been edited. */
|
||||
edited?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A comment's metadata line: author name, relative time, and an edited marker. Hovering the time
|
||||
* shows the full date and time in a tooltip.
|
||||
* @public @react
|
||||
*/
|
||||
export function Byline({ author, date, edited }: BylineProps) {
|
||||
// The relative time is the one piece of a byline that isn't the host's data, so it follows the
|
||||
// same translation context as every other string in the UI rather than a prop of its own.
|
||||
const { locale } = useCurrentTranslation()
|
||||
return (
|
||||
<div className="tlui-cmt-head">
|
||||
<Avatar author={author} />
|
||||
<span className="tlui-cmt-author">{author.name}</span>
|
||||
<TldrawUiTooltip content={formatFullDateTime(date, locale)}>
|
||||
<span className="tlui-cmt-time">
|
||||
{formatRelativeTime(date, locale)}
|
||||
{edited && <span className="tlui-cmt-edited"> · edited</span>}
|
||||
</span>
|
||||
</TldrawUiTooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
packages/commenting/src/ui/comment-card.tsx
Normal file
42
packages/commenting/src/ui/comment-card.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { type CommentAuthor } from '@tldraw/mentions'
|
||||
import { ReactNode } from 'react'
|
||||
import { Byline } from './byline'
|
||||
|
||||
/** @public */
|
||||
export interface CommentCardProps {
|
||||
author: CommentAuthor
|
||||
/** The rendered comment body. The card doesn't dictate a format — pass a `<CommentBody>`
|
||||
* for a comment's rich text, or any node of your own. */
|
||||
body: ReactNode
|
||||
/** ISO datetime; formatted to relative time by the component. */
|
||||
date: string
|
||||
you: boolean
|
||||
/** Whether the comment has been edited (shows an "edited" marker). */
|
||||
edited?: boolean
|
||||
/** Hover-revealed controls at the card's top-right (e.g. an edit affordance). */
|
||||
actions?: ReactNode
|
||||
/** Content under the body, aligned with it rather than the avatar (e.g. a `<Reactions>` row). */
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
/** A single comment: Avatar, Byline, and a body slot the consumer renders. @public @react */
|
||||
export function CommentCard({
|
||||
author,
|
||||
body,
|
||||
date,
|
||||
you,
|
||||
edited,
|
||||
actions,
|
||||
footer,
|
||||
}: CommentCardProps) {
|
||||
return (
|
||||
<div className={you ? 'tlui-cmt-card tlui-cmt-card--you' : 'tlui-cmt-card'}>
|
||||
<Byline author={author} date={date} edited={edited} />
|
||||
<div className="tlui-cmt-body">
|
||||
{body}
|
||||
{footer}
|
||||
</div>
|
||||
{actions !== undefined && <div className="tlui-cmt-card__actions">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
299
packages/commenting/src/ui/comment-composer.tsx
Normal file
299
packages/commenting/src/ui/comment-composer.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { JSONContent } from '@tiptap/core'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import {
|
||||
Avatar,
|
||||
type CommentAuthor,
|
||||
createMentionExtension,
|
||||
createMentionSuggestion,
|
||||
isMentionPickerOpen,
|
||||
MentionMember,
|
||||
} from '@tldraw/mentions'
|
||||
import {
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { isEqual, TLRichText, useMaybeEditor } from 'tldraw'
|
||||
import { commentTipTapExtensions, EMPTY_COMMENT, isCommentEmpty } from './comment-extensions'
|
||||
import { SendButton } from './send-button'
|
||||
|
||||
/** @public */
|
||||
export interface CommentComposerProps {
|
||||
author: CommentAuthor
|
||||
placeholder: string
|
||||
/** Controlled rich-text value. Omit for the presentational (display-only) composer. */
|
||||
value?: TLRichText
|
||||
onChange?(value: TLRichText): void
|
||||
/** Called on Send click or Enter. When set, the composer is interactive. */
|
||||
onSubmit?(): void
|
||||
sendLabel?: string
|
||||
/** Called when Up is pressed in an empty composer — e.g. to start editing the comment above,
|
||||
* the way chat apps edit your last message. With content in the field, Up moves the cursor. */
|
||||
onArrowUpWhenEmpty?(): void
|
||||
disabled?: boolean
|
||||
autoFocus?: boolean
|
||||
/** The leading element before the field. Defaults to the author's avatar. */
|
||||
leading?: ReactNode
|
||||
/** Resolve the members matching an `@`-query (sync or async). Provide to enable mentions. */
|
||||
getMentionSuggestions?(query: string): MentionMember[] | Promise<MentionMember[]>
|
||||
/** Override a picker row's content. Defaults to avatar + name (+ secondary). */
|
||||
renderMentionSuggestion?(member: MentionMember): ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* The input for writing a comment: a TipTap rich-text editor restricted to the comment extension
|
||||
* set (bold, italic, lists, links, code, highlight — no headings), with a Send button. Formatting
|
||||
* is applied through markdown and keyboard shortcuts (e.g. `**bold**`, `- `, Cmd+B); there's no
|
||||
* floating toolbar. Presentational by default; pass value/onChange/onSubmit to drive it as a form.
|
||||
* @public @react
|
||||
*/
|
||||
export function CommentComposer({
|
||||
author,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
sendLabel = 'Send',
|
||||
onArrowUpWhenEmpty,
|
||||
disabled,
|
||||
autoFocus,
|
||||
leading,
|
||||
getMentionSuggestions,
|
||||
renderMentionSuggestion,
|
||||
}: CommentComposerProps) {
|
||||
const interactive = !!onChange || !!onSubmit
|
||||
// Lets the mention popup track the camera. Null outside a tldraw editor.
|
||||
const tlEditor = useMaybeEditor()
|
||||
|
||||
// Read through refs so the editor isn't recreated when callback identity changes.
|
||||
const onChangeRef = useRef(onChange)
|
||||
onChangeRef.current = onChange
|
||||
const onSubmitRef = useRef(onSubmit)
|
||||
onSubmitRef.current = onSubmit
|
||||
const onArrowUpWhenEmptyRef = useRef(onArrowUpWhenEmpty)
|
||||
onArrowUpWhenEmptyRef.current = onArrowUpWhenEmpty
|
||||
const disabledRef = useRef(disabled)
|
||||
disabledRef.current = disabled
|
||||
const getMentionSuggestionsRef = useRef(getMentionSuggestions)
|
||||
getMentionSuggestionsRef.current = getMentionSuggestions
|
||||
const renderMentionSuggestionRef = useRef(renderMentionSuggestion)
|
||||
renderMentionSuggestionRef.current = renderMentionSuggestion
|
||||
|
||||
const [isEmpty, setIsEmpty] = useState(() => !value || isCommentEmpty(value))
|
||||
// Whether the field has grown to two rows: input across the full width, send button below.
|
||||
// Flips when the (single-line) content width reaches the space left beside the send button.
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const expandedRef = useRef(expanded)
|
||||
expandedRef.current = expanded
|
||||
const inputWrapRef = useRef<HTMLDivElement>(null)
|
||||
const mirrorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Measure whether the content still fits on one line beside the send button, against a hidden
|
||||
// nowrap clone so marks and mention chips measure at their true width. The gap between the
|
||||
// expand and collapse thresholds is a dead zone, so the layout can't flap at the boundary.
|
||||
const remeasure = () => {
|
||||
const wrap = inputWrapRef.current
|
||||
const mirror = mirrorRef.current
|
||||
const editor = editorRef.current
|
||||
if (!wrap || !mirror || !editor) return
|
||||
// Empty always fits — and measuring before TipTap has laid out reads zero widths, flashing
|
||||
// the expanded layout for a frame on mount.
|
||||
if (editor.isEmpty) {
|
||||
if (expandedRef.current) setExpanded(false)
|
||||
return
|
||||
}
|
||||
if (wrap.clientWidth === 0) return
|
||||
const doc = editor.state.doc
|
||||
const multiBlock =
|
||||
doc.childCount > 1 || (doc.firstChild !== null && doc.firstChild.type.name !== 'paragraph')
|
||||
if (multiBlock) {
|
||||
if (!expandedRef.current) setExpanded(true)
|
||||
return
|
||||
}
|
||||
const send = wrap.parentElement?.querySelector<HTMLElement>('.tlui-cmt-send')
|
||||
const input = wrap.querySelector('.tlui-cmt-input')
|
||||
if (!send || !input) return
|
||||
mirror.innerHTML = input.innerHTML
|
||||
const textWidth = mirror.offsetWidth
|
||||
// Measured against the wrap's *content* box: text wraps at clientWidth minus padding, so
|
||||
// without subtracting it the input grows a line before expansion fires and then snaps.
|
||||
const wrapStyle = getComputedStyle(wrap)
|
||||
const wrapPadX = parseFloat(wrapStyle.paddingLeft) + parseFloat(wrapStyle.paddingRight)
|
||||
const collapsedAvailable =
|
||||
wrap.clientWidth - wrapPadX - (expandedRef.current ? send.offsetWidth + 6 : 0)
|
||||
if (!expandedRef.current && textWidth > collapsedAvailable - 8) {
|
||||
setExpanded(true)
|
||||
} else if (expandedRef.current && textWidth < collapsedAvailable - 24) {
|
||||
setExpanded(false)
|
||||
}
|
||||
}
|
||||
const remeasureRef = useRef(remeasure)
|
||||
remeasureRef.current = remeasure
|
||||
|
||||
// Reachable from `handleKeyDown`, which is created before `useEditor` returns.
|
||||
const editorRef = useRef<ReturnType<typeof useEditor>>(null)
|
||||
|
||||
// `commands.enter()` re-dispatches through `handleKeyDown`; without this guard our own handler
|
||||
// would catch the synthetic Enter and submit.
|
||||
const replayingEnter = useRef(false)
|
||||
|
||||
// The suggestion plugin is built once and runs outside React, so it reads the mention callbacks
|
||||
// through refs — otherwise it queries the roster present at mount forever, never seeing a member
|
||||
// who joins later. Whether mentions are wired at all is fixed at mount; the callbacks are live.
|
||||
const mentionsEnabled = !!getMentionSuggestions
|
||||
const hasCustomRow = !!renderMentionSuggestion
|
||||
const extensions = useMemo(() => {
|
||||
const list = [...commentTipTapExtensions]
|
||||
// The mention node is always registered, or ProseMirror strips existing mentions when an
|
||||
// edited body loads. Only the `@` trigger is gated on the host providing a resolver.
|
||||
if (mentionsEnabled) {
|
||||
const resolveSuggestions = (query: string) => getMentionSuggestionsRef.current?.(query) ?? []
|
||||
const renderRow = hasCustomRow
|
||||
? (member: MentionMember) => renderMentionSuggestionRef.current?.(member)
|
||||
: undefined
|
||||
list.push(
|
||||
createMentionExtension({
|
||||
suggestion: createMentionSuggestion(resolveSuggestions, {
|
||||
renderMember: renderRow,
|
||||
editor: tlEditor,
|
||||
}),
|
||||
})
|
||||
)
|
||||
} else {
|
||||
list.push(createMentionExtension({ suggestion: { char: '@', allow: () => false } }))
|
||||
}
|
||||
return list
|
||||
}, [mentionsEnabled, hasCustomRow, tlEditor])
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions,
|
||||
content: (value ?? EMPTY_COMMENT) as JSONContent,
|
||||
editable: interactive,
|
||||
// tldraw ships its own TextDirection extension, so TipTap's core one would warn about a
|
||||
// duplicate. Mirrors RichTextArea's setup.
|
||||
enableCoreExtensions: { textDirection: false },
|
||||
textDirection: 'auto',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'tlui-cmt-input',
|
||||
'aria-label': placeholder,
|
||||
role: 'textbox',
|
||||
'aria-multiline': 'true',
|
||||
},
|
||||
// Runs before every keymap plugin, so it can distinguish Shift+Enter from Enter — an
|
||||
// `Enter` keymap binding also fires on Shift+Enter and would otherwise swallow it.
|
||||
handleKeyDown: (_view, event) => {
|
||||
// Let the keymaps handle the synthetic Enter we replay for a Shift+Enter newline.
|
||||
if (replayingEnter.current) return false
|
||||
// Up in an empty composer hands off to the host. With content it stays cursor
|
||||
// movement, and with the picker open it navigates the roster.
|
||||
if (event.key === 'ArrowUp' && !event.isComposing) {
|
||||
if (
|
||||
onArrowUpWhenEmptyRef.current &&
|
||||
editorRef.current?.isEmpty &&
|
||||
!isMentionPickerOpen()
|
||||
) {
|
||||
onArrowUpWhenEmptyRef.current()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (event.key !== 'Enter' || event.isComposing) return false
|
||||
// While the @-mention picker is open, Enter selects the highlighted member — defer.
|
||||
if (isMentionPickerOpen()) return false
|
||||
// Shift+Enter inserts a new line by replaying a plain Enter through the keymaps, reusing
|
||||
// the editor's list-aware handling. tldraw doesn't do soft breaks.
|
||||
if (event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) {
|
||||
// An empty field would open the comment with a stray leading blank line.
|
||||
if (editorRef.current?.isEmpty) return true
|
||||
replayingEnter.current = true
|
||||
try {
|
||||
editorRef.current?.commands.enter()
|
||||
} finally {
|
||||
replayingEnter.current = false
|
||||
}
|
||||
return true
|
||||
}
|
||||
// Enter, Cmd+Enter, and Ctrl+Enter submit.
|
||||
if (!disabledRef.current) onSubmitRef.current?.()
|
||||
return true
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
setIsEmpty(editor.isEmpty)
|
||||
// Same-value state sets don't re-render, and the DOM is already updated here.
|
||||
remeasureRef.current()
|
||||
onChangeRef.current?.(editor.getJSON() as TLRichText)
|
||||
},
|
||||
},
|
||||
[interactive, placeholder]
|
||||
)
|
||||
editorRef.current = editor
|
||||
|
||||
// Sync controlled resets (e.g. the parent clearing to EMPTY_COMMENT after posting) into the
|
||||
// editor without echoing back the value the editor itself just emitted.
|
||||
useEffect(() => {
|
||||
if (!editor || value === undefined) return
|
||||
if (isEqual(editor.getJSON(), value)) return
|
||||
editor.commands.setContent(value as JSONContent)
|
||||
setIsEmpty(editor.isEmpty)
|
||||
}, [editor, value])
|
||||
|
||||
// Measure once the editor's content is in the DOM, so a composer that mounts pre-filled (the
|
||||
// edit-in-place composer) starts in the right layout.
|
||||
useLayoutEffect(() => {
|
||||
remeasureRef.current()
|
||||
}, [editor, value])
|
||||
|
||||
// Focus on the next frame rather than via TipTap's autofocus: the composer often mounts from a
|
||||
// canvas pointer event whose default focus handling would otherwise steal it back.
|
||||
useEffect(() => {
|
||||
if (!autoFocus || !editor) return
|
||||
const raf = requestAnimationFrame(() => editor.commands.focus('end'))
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [autoFocus, editor])
|
||||
|
||||
// The whole field behaves like the text input: clicking its empty area (the padding, or the
|
||||
// space beside/below a short line) focuses the editor rather than only the text glyphs being
|
||||
// clickable. The input (caret placement) and the send button keep their own click handling.
|
||||
const focusEditorFromField = (e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if ((e.target as HTMLElement).closest('.tlui-cmt-input, .tlui-cmt-send')) return
|
||||
e.preventDefault()
|
||||
editor?.commands.focus('end')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tlui-cmt-composer">
|
||||
{leading ?? <Avatar author={author} />}
|
||||
<div
|
||||
className={[
|
||||
'tlui-cmt-composer__field',
|
||||
interactive && expanded && 'tlui-cmt-composer__field--expanded',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onMouseDown={interactive ? focusEditorFromField : undefined}
|
||||
>
|
||||
<div className="tlui-cmt-composer__input-wrap" ref={inputWrapRef}>
|
||||
<EditorContent editor={editor} />
|
||||
{isEmpty && (
|
||||
<div className="tlui-cmt-input__placeholder" aria-hidden="true">
|
||||
{placeholder}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="tlui-cmt-composer__mirror tlui-cmt-input"
|
||||
ref={mirrorRef}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
{interactive && <SendButton label={sendLabel} onClick={onSubmit} disabled={disabled} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
packages/commenting/src/ui/comment-extensions.ts
Normal file
25
packages/commenting/src/ui/comment-extensions.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getTipTapDefaultExtensions, TLRichText, toRichText } from 'tldraw'
|
||||
|
||||
/**
|
||||
* tldraw's default rich-text extension set, minus headings — the deliberately limited set used for
|
||||
* both the comment composer and comment display. Comments support paragraphs, bold, italic, lists,
|
||||
* links, code, and highlight, but not headings. Built from tldraw's shared factory so the config
|
||||
* stays in lockstep with the text shape's defaults rather than drifting from a copy.
|
||||
*/
|
||||
export const commentTipTapExtensions = getTipTapDefaultExtensions({ heading: false })
|
||||
|
||||
/** An empty comment document — the seed value for a fresh composer and its post-submit reset. */
|
||||
export const EMPTY_COMMENT: TLRichText = toRichText('')
|
||||
|
||||
/**
|
||||
* Whether a rich-text comment body has no text. Mirrors tldraw's private `isEmptyRichText`: an
|
||||
* empty doc can be encoded as an empty `content` array or a single empty paragraph.
|
||||
*/
|
||||
export function isCommentEmpty(richText: TLRichText): boolean {
|
||||
if (richText.content.length === 0) return true
|
||||
if (richText.content.length === 1) {
|
||||
const node = richText.content[0] as any
|
||||
if (!node.content || node.content.length === 0) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
32
packages/commenting/src/ui/comment-pin.tsx
Normal file
32
packages/commenting/src/ui/comment-pin.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { PinCheckIcon } from './icons'
|
||||
|
||||
/** @public */
|
||||
export interface CommentPinProps {
|
||||
/** What the pin shows when unresolved — a number, an author initial, an <Avatar>, etc.
|
||||
* Left as a lever so consumers aren't locked into a count. */
|
||||
children?: ReactNode
|
||||
resolved?: boolean
|
||||
/** The pin's thread is open — shows the active/selected indicator state. */
|
||||
open?: boolean
|
||||
}
|
||||
|
||||
/* Decorative — the pin's accessible name lives on whatever wraps it. */
|
||||
const resolvedCheck = <PinCheckIcon />
|
||||
|
||||
/** A canvas comment marker: shows its `children` (or a check when resolved). Purely
|
||||
* presentational — it reflects open/resolved state via CSS; wrap it to make it clickable.
|
||||
* @public @react */
|
||||
export function CommentPin({ children, resolved, open }: CommentPinProps) {
|
||||
const className = [
|
||||
// `tlui-cmt-marker` carries the resting shadow and the hover lift — the same treatment a
|
||||
// count badge wears, so the two markers behave alike.
|
||||
'tlui-cmt-marker',
|
||||
'tlui-cmt-pin',
|
||||
resolved && 'tlui-cmt-pin--resolved',
|
||||
open && 'tlui-cmt-marker--open',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
return <div className={className}>{resolved ? resolvedCheck : children}</div>
|
||||
}
|
||||
63
packages/commenting/src/ui/comment-thread.tsx
Normal file
63
packages/commenting/src/ui/comment-thread.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { CommentCard, CommentCardProps } from './comment-card'
|
||||
import { CommentComposer, CommentComposerProps } from './comment-composer'
|
||||
|
||||
/** @public */
|
||||
export interface CommentThreadProps {
|
||||
/** The thread's comments, already adapted to card props (oldest first). */
|
||||
comments: CommentCardProps[]
|
||||
/** Optional header, e.g. "Thread". Omit for no header. */
|
||||
header?: ReactNode
|
||||
/** Action controls shown at the right of the header (resolve, delete, dismiss…). */
|
||||
headerActions?: ReactNode
|
||||
/** When set, shows a banner above the comments (e.g. "Resolved by Jess"). */
|
||||
resolvedBanner?: ReactNode
|
||||
/** Reply composer props. Omit for a read-only thread (no composer). */
|
||||
composer?: CommentComposerProps
|
||||
/** Shown at the bottom of the thread (e.g. a sign-in prompt when there's no composer). When
|
||||
* both are set, the footer renders after the composer. */
|
||||
footer?: ReactNode
|
||||
/** Override how each comment renders. Defaults to `<CommentCard>`. */
|
||||
renderComment?(comment: CommentCardProps, index: number): ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A comment thread: an optional header, an optional resolved banner, the comments, and an
|
||||
* optional reply composer. Presentational — you supply the comments (as card props) and the
|
||||
* composer's handlers; how each comment renders is overridable via `renderComment`.
|
||||
* @public @react
|
||||
*/
|
||||
export function CommentThread({
|
||||
comments,
|
||||
header,
|
||||
headerActions,
|
||||
resolvedBanner,
|
||||
composer,
|
||||
footer,
|
||||
renderComment,
|
||||
}: CommentThreadProps) {
|
||||
return (
|
||||
<div className="tlui-cmt-thread">
|
||||
{(header !== undefined || headerActions !== undefined) && (
|
||||
<div className="tlui-cmt-thread__header">
|
||||
{header !== undefined && <span className="tlui-cmt-thread__title">{header}</span>}
|
||||
{headerActions !== undefined && (
|
||||
<div className="tlui-cmt-thread__actions">{headerActions}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{resolvedBanner !== undefined && (
|
||||
<div className="tlui-cmt-thread__resolved">{resolvedBanner}</div>
|
||||
)}
|
||||
<div className="tlui-cmt-thread__list">
|
||||
{comments.map((comment, i) => (
|
||||
<div key={i}>
|
||||
{renderComment ? renderComment(comment, i) : <CommentCard {...comment} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{composer !== undefined && <CommentComposer {...composer} />}
|
||||
{footer !== undefined && <div className="tlui-cmt-thread__footer">{footer}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
packages/commenting/src/ui/comments-list.test.ts
Normal file
31
packages/commenting/src/ui/comments-list.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { MouseEvent } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isOpenInNewTabClick } from './comments-list'
|
||||
|
||||
function click(overrides: Partial<MouseEvent> = {}): MouseEvent {
|
||||
return {
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
button: 0,
|
||||
...overrides,
|
||||
} as MouseEvent
|
||||
}
|
||||
|
||||
describe('isOpenInNewTabClick', () => {
|
||||
it('treats a plain left click as an in-place select', () => {
|
||||
expect(isOpenInNewTabClick(click())).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['metaKey', 'ctrlKey', 'shiftKey', 'altKey'] as const)(
|
||||
'leaves a %s click to the browser',
|
||||
(modifier) => {
|
||||
expect(isOpenInNewTabClick(click({ [modifier]: true }))).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves non-primary-button clicks to the browser', () => {
|
||||
expect(isOpenInNewTabClick(click({ button: 1 }))).toBe(true)
|
||||
})
|
||||
})
|
||||
174
packages/commenting/src/ui/comments-list.tsx
Normal file
174
packages/commenting/src/ui/comments-list.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Avatar, type CommentAuthor } from '@tldraw/mentions'
|
||||
import { Fragment, MouseEvent, ReactNode } from 'react'
|
||||
import { useTranslation } from 'tldraw'
|
||||
import { Byline } from './byline'
|
||||
import { CheckIcon } from './icons'
|
||||
import { Reactions, type ReactionSummary } from './reactions'
|
||||
import { replyCountLabel } from './reply-count'
|
||||
|
||||
/** @public */
|
||||
export interface CommentListItemProps {
|
||||
id: string
|
||||
author: CommentAuthor
|
||||
/** A short preview of the thread — e.g. the first comment's body. */
|
||||
preview: ReactNode
|
||||
/** ISO datetime of the thread's first comment. */
|
||||
date: string
|
||||
resolved?: boolean
|
||||
/** Name of the page the thread lives on, shown as a small label. Omit to hide. */
|
||||
page?: string
|
||||
/** Total comments in the thread. */
|
||||
count?: number
|
||||
/** Whether this thread is the open one. */
|
||||
selected?: boolean
|
||||
/** Tallied reactions for the row (a thread's, or a single comment's when the row is one
|
||||
* comment), shown as inert pills under the preview. Omit to hide. */
|
||||
reactions?: ReactionSummary[]
|
||||
/**
|
||||
* Link target for the item. When set, the row renders as an anchor so browser affordances
|
||||
* (ctrl/cmd-click, middle-click) open it in a new tab; a plain click still calls `onSelect`.
|
||||
*/
|
||||
href?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What a row is rendered with: the item, plus the list-level wiring it needs to be interactive.
|
||||
* A custom row gets the same props the default `<CommentListItem>` does, so it can wrap the
|
||||
* default rather than reimplement it.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface CommentListItemRenderProps extends CommentListItemProps {
|
||||
/** Label for a resolved thread's marker on its row. */
|
||||
resolvedLabel?: string
|
||||
/** Called with the thread id when the row is chosen. */
|
||||
onSelect?(id: string): void
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface CommentsListProps {
|
||||
items: CommentListItemProps[]
|
||||
/** Called with a thread id when an item is chosen. */
|
||||
onSelect?(id: string): void
|
||||
/** Shown above the list (e.g. "Comments"). Omit for none. */
|
||||
header?: ReactNode
|
||||
/** Rendered at the right of the header row — e.g. a filter menu. */
|
||||
headerAction?: ReactNode
|
||||
/** Shown in place of the list when there are no threads. */
|
||||
empty?: ReactNode
|
||||
/** Label for a resolved thread's marker on its row. Defaults to "Resolved". */
|
||||
resolvedLabel?: string
|
||||
/**
|
||||
* Override how each item renders. Defaults to `<CommentListItem>`, which is exported — so a
|
||||
* row that only adds something can spread these props into it rather than start over. The list
|
||||
* supplies the key, so a custom row doesn't need one.
|
||||
*/
|
||||
renderItem?(props: CommentListItemRenderProps): ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A scrollable list of comment threads — each an avatar, byline, and a one-line preview.
|
||||
* Presentational: you supply the items (already summarised) and an `onSelect` handler; the canvas
|
||||
* `CanvasCommentsSidebar` wires it to the store, but a consumer can build their own list from this.
|
||||
* @public @react
|
||||
*/
|
||||
export function CommentsList({
|
||||
items,
|
||||
onSelect,
|
||||
header,
|
||||
headerAction,
|
||||
empty,
|
||||
resolvedLabel,
|
||||
renderItem,
|
||||
}: CommentsListProps) {
|
||||
return (
|
||||
<div className="tlui-cmt-list">
|
||||
{(header !== undefined || headerAction) && (
|
||||
<div className="tlui-cmt-list__header">
|
||||
{header !== undefined && <span className="tlui-cmt-list__header-title">{header}</span>}
|
||||
{headerAction}
|
||||
</div>
|
||||
)}
|
||||
{items.length === 0 ? (
|
||||
<div className="tlui-cmt-list__empty">{empty}</div>
|
||||
) : (
|
||||
<div className="tlui-cmt-list__items">
|
||||
{items.map((item) => {
|
||||
const props: CommentListItemRenderProps = { ...item, resolvedLabel, onSelect }
|
||||
// A custom row is wrapped rather than keyed directly: it's the consumer's element,
|
||||
// and requiring them to remember a key is the kind of thing that only shows up as a
|
||||
// console warning in someone else's app.
|
||||
return renderItem ? (
|
||||
<Fragment key={item.id}>{renderItem(props)}</Fragment>
|
||||
) : (
|
||||
<CommentListItem key={item.id} {...props} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One thread's row in a {@link CommentsList}. @public @react */
|
||||
export function CommentListItem({
|
||||
id,
|
||||
author,
|
||||
preview,
|
||||
date,
|
||||
resolved,
|
||||
page,
|
||||
count,
|
||||
selected,
|
||||
reactions,
|
||||
href,
|
||||
resolvedLabel = 'Resolved',
|
||||
onSelect,
|
||||
}: CommentListItemRenderProps) {
|
||||
const msg = useTranslation()
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (href && isOpenInNewTabClick(e)) return
|
||||
e.preventDefault()
|
||||
if (onSelect) onSelect(id)
|
||||
}
|
||||
const Tag = href ? 'a' : 'button'
|
||||
// `count` is the thread's total comments; a reply is every comment after the opening one.
|
||||
const replies = count !== undefined ? replyCountLabel(msg, count - 1) : null
|
||||
return (
|
||||
<Tag
|
||||
{...(href ? { href } : { type: 'button' })}
|
||||
className={
|
||||
selected ? 'tlui-cmt-list__item tlui-cmt-list__item--selected' : 'tlui-cmt-list__item'
|
||||
}
|
||||
data-resolved={resolved || undefined}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Avatar author={author} />
|
||||
<div className="tlui-cmt-list__item-body">
|
||||
<Byline author={author} date={date} />
|
||||
<div className="tlui-cmt-list__item-preview">{preview}</div>
|
||||
{reactions && <Reactions reactions={reactions} canReact={false} enableHoverList={false} />}
|
||||
{(resolved || page !== undefined || replies) && (
|
||||
<div className="tlui-cmt-list__item-meta">
|
||||
{resolved && (
|
||||
<span className="tlui-cmt-list__item-resolved">
|
||||
<CheckIcon />
|
||||
{resolvedLabel}
|
||||
</span>
|
||||
)}
|
||||
{page !== undefined && <span className="tlui-cmt-list__item-page">{page}</span>}
|
||||
{replies && <span className="tlui-cmt-list__item-replies">{replies}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a click should be left to the browser's link handling (new tab, new window, or download).
|
||||
* @public
|
||||
*/
|
||||
export function isOpenInNewTabClick(e: MouseEvent) {
|
||||
return e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0
|
||||
}
|
||||
935
packages/commenting/src/ui/comments.css
Normal file
935
packages/commenting/src/ui/comments.css
Normal file
@@ -0,0 +1,935 @@
|
||||
/* Real comment UI components. Co-located so styles travel to any view (studio,
|
||||
canvas), and themed via tldraw's --tl-color tokens so they follow light/dark. */
|
||||
.tlui-cmt-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
width: 260px;
|
||||
background: var(--tl-color-panel);
|
||||
border: 1px solid var(--tl-color-divider);
|
||||
border-radius: var(--tl-radius-3);
|
||||
}
|
||||
|
||||
/* The avatar lives in the byline row (see Byline), so the body text below indents to line up with
|
||||
the name — the avatar's width plus the head's gap. */
|
||||
.tlui-cmt-body {
|
||||
padding-left: calc(28px + 6px);
|
||||
}
|
||||
|
||||
/* hover-revealed controls (edit, react…) stacked at the card's top-right */
|
||||
.tlui-cmt-card__actions {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
.tlui-cmt-card:hover .tlui-cmt-card__actions,
|
||||
.tlui-cmt-card:focus-within .tlui-cmt-card__actions,
|
||||
/* an open menu is portaled out of the card, so neither :hover nor :focus-within survives moving
|
||||
the pointer into it — keep the controls up while their menu is open */
|
||||
.tlui-cmt-card__actions:has([data-state='open']) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tlui-cmt-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tlui-cmt-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tlui-cmt-author {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: var(--tl-color-text-1);
|
||||
/* a long name ellipsizes rather than pushing the time out or running under the actions */
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tlui-cmt-time {
|
||||
font-size: 12px;
|
||||
color: var(--tl-color-text-3);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* In the hover preview a long comment truncates with an ellipsis rather than growing the card —
|
||||
~7 lines (≈120px at this line height). Preview only. */
|
||||
.tlui-cmt-canvas-preview--thread .tlui-cmt-text {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 7;
|
||||
line-clamp: 7;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tlui-cmt-text {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--tl-color-text-1);
|
||||
/* Break long unbroken strings (URLs, keyboard-mashing) so they wrap inside the card instead of
|
||||
overflowing it. `anywhere` also lets the flex body shrink to the card width. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* A comment body is read-only but its text must stay selectable so it can be copied. tldraw's
|
||||
container sets `user-select: none` on every descendant (`.tl-container *`) to keep canvas gestures
|
||||
from selecting UI text, so the body and its rendered children have to opt back in. */
|
||||
.tlui-cmt-text,
|
||||
.tlui-cmt-text * {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.tlui-cmt-edited {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* rendered rich text inside a comment body — bare tags emitted by the comment renderer
|
||||
(paragraphs, lists, code, highlight, links), matching the limited comment extension set. */
|
||||
.tlui-cmt-text p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tlui-cmt-text p + p,
|
||||
.tlui-cmt-text p + ul,
|
||||
.tlui-cmt-text p + ol,
|
||||
.tlui-cmt-text ul + p,
|
||||
.tlui-cmt-text ol + p {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.tlui-cmt-text ul,
|
||||
.tlui-cmt-text ol {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.tlui-cmt-text code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.92em;
|
||||
background: var(--tl-color-muted-2);
|
||||
border-radius: 3px;
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
.tlui-cmt-text mark {
|
||||
background: color-mix(in srgb, #ffeb3b 60%, transparent);
|
||||
color: inherit;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tlui-cmt-text a {
|
||||
color: var(--tl-color-selected);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Avatar and mention styles live in @tldraw/mentions (src/mention.css) and are bundled into
|
||||
commenting.css by copy-css-files.mjs. */
|
||||
|
||||
/* composer — an avatar beside a rounded-rect field holding the rich-text input and the send button.
|
||||
Formatting is applied via markdown and keyboard shortcuts (no toolbar). The avatar aligns to the
|
||||
top so it stays put as the field grows to multiple lines. */
|
||||
.tlui-cmt-composer {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 4px 0 8px 4px;
|
||||
}
|
||||
|
||||
.tlui-cmt-composer__field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 3px 3px 8px;
|
||||
background: var(--tl-color-muted-0);
|
||||
border: 1px solid var(--tl-color-divider);
|
||||
border-radius: var(--tl-radius-2);
|
||||
/* The whole field reads as the text input — clicking its empty area focuses the editor
|
||||
(see CommentComposer). The send button restores its own pointer cursor. */
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.tlui-cmt-composer__field:focus-within {
|
||||
border-color: var(--tl-color-selected);
|
||||
background: var(--tl-color-panel);
|
||||
}
|
||||
|
||||
/* Expanded: the content no longer fits on one line beside the send button, so the input takes
|
||||
the full width and the send button drops to its own row, right-aligned. */
|
||||
.tlui-cmt-composer__field--expanded {
|
||||
flex-wrap: wrap;
|
||||
/* No vertical gap between the input and the send-button row it wraps onto. */
|
||||
row-gap: 0;
|
||||
}
|
||||
.tlui-cmt-composer__field--expanded .tlui-cmt-composer__input-wrap {
|
||||
flex-basis: 100%;
|
||||
/* The field pads 8px left but only 3px right (the send button's inset). With the input
|
||||
spanning the full width, give the text the same 8px on the right. */
|
||||
padding-right: 5px;
|
||||
}
|
||||
.tlui-cmt-composer__field--expanded .tlui-cmt-send {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* the editable area, with the placeholder overlaid while it's empty */
|
||||
.tlui-cmt-composer__input-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* Frames the input's line height symmetrically so the text sits centred in the field. Paired
|
||||
with the input's min-height, which equals its line height (no gap for the text to top-align
|
||||
into). Keeps the field's overall height. */
|
||||
padding: 4.5px 0;
|
||||
}
|
||||
|
||||
/* The hidden single-line measuring clone of the editor's content (see CommentComposer). */
|
||||
.tlui-cmt-composer__mirror {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.tlui-cmt-input {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--tl-color-text-1);
|
||||
outline: none;
|
||||
min-height: 17px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tlui-cmt-input p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tlui-cmt-input p + p,
|
||||
.tlui-cmt-input p + ul,
|
||||
.tlui-cmt-input p + ol {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tlui-cmt-input ul,
|
||||
.tlui-cmt-input ol {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.tlui-cmt-input__placeholder {
|
||||
position: absolute;
|
||||
top: 4.5px;
|
||||
left: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--tl-color-text-3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* iOS Safari zooms the whole page in when a text input with a sub-16px font takes focus, and doesn't
|
||||
zoom back out. Raising the font to 16px on touch devices suppresses that without disabling
|
||||
pinch-zoom of the canvas (which a `maximum-scale` viewport lock would). Must come after both base
|
||||
rules above: a media query adds no specificity, so it only wins the placeholder on source order. */
|
||||
@media (pointer: coarse) {
|
||||
.tlui-cmt-input,
|
||||
.tlui-cmt-input__placeholder {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* A bare up-arrow icon button (matching the placement composer): no fill by default, a grey square
|
||||
on hover, grey while there's nothing to send and dark once there is. */
|
||||
.tlui-cmt-send {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: var(--tl-radius-2);
|
||||
background: transparent;
|
||||
color: var(--tl-color-text-3);
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.tlui-cmt-send:not(:disabled) {
|
||||
color: var(--tl-color-text-1);
|
||||
}
|
||||
|
||||
.tlui-cmt-send:hover:not(:disabled) {
|
||||
background: var(--tl-color-muted-2);
|
||||
}
|
||||
|
||||
.tlui-cmt-send:active:not(:disabled),
|
||||
.pseudo-active .tlui-cmt-send:not(:disabled) {
|
||||
background: var(--tl-color-muted-1);
|
||||
}
|
||||
|
||||
.tlui-cmt-send:focus-visible,
|
||||
.pseudo-focus-visible .tlui-cmt-send {
|
||||
outline: 2px solid var(--tl-color-selected);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tlui-cmt-send:disabled {
|
||||
background: transparent;
|
||||
color: var(--tl-color-text-3);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* The shared marker treatment. To the pointer a comment pin and a count badge are the same
|
||||
object — a token sitting on the canvas — so they rest and lift identically; everything shared
|
||||
lives here so the two can't drift apart. */
|
||||
.tlui-cmt-marker {
|
||||
box-shadow: var(--tlui-cmt-marker-shadow);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform var(--tlui-cmt-marker-transition),
|
||||
box-shadow var(--tlui-cmt-marker-transition);
|
||||
}
|
||||
|
||||
.tlui-cmt-marker:hover {
|
||||
transform: scale(var(--tlui-cmt-marker-hover-scale));
|
||||
}
|
||||
|
||||
/* The hover shadow is for closed markers only — an open one keeps its selection ring, which this
|
||||
box-shadow would otherwise override (the hover selector outranks the --open rule). */
|
||||
.tlui-cmt-marker:not(.tlui-cmt-marker--open):hover {
|
||||
box-shadow: var(--tlui-cmt-marker-shadow-hover);
|
||||
}
|
||||
|
||||
/* Count badges don't react to hover — no scale, no shadow lift. Only pins do. */
|
||||
.tlui-cmt-count-badge:hover {
|
||||
transform: none;
|
||||
box-shadow: var(--tlui-cmt-marker-shadow);
|
||||
}
|
||||
|
||||
/* Pins don't scale on hover — only the shadow lifts. */
|
||||
.tlui-cmt-pin:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* open: the thread (or a stack's list) is showing — the active marker just keeps its lift shadow,
|
||||
no selection ring. */
|
||||
.tlui-cmt-marker--open {
|
||||
box-shadow: var(--tlui-cmt-marker-shadow);
|
||||
}
|
||||
|
||||
/* pin — a canvas marker: a white teardrop holding the author's avatar circle, so a thread is
|
||||
identifiable at a glance. */
|
||||
.tlui-cmt-pin {
|
||||
--tlui-cmt-marker-size: var(--tlui-cmt-pin-size);
|
||||
width: var(--tlui-cmt-marker-size);
|
||||
height: var(--tlui-cmt-marker-size);
|
||||
border-radius: 50% 50% 50% 0;
|
||||
background: var(--tl-color-panel);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* The pin holds the author's 22px avatar circle, centred in its 28px face. */
|
||||
.tlui-cmt-pin .tlui-cmt-avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* resolved: a solid, muted grey (the default --tl-color-muted-1 is 10% black, so it goes
|
||||
translucent over the canvas). Dark mode gets its own grey — the token's dark value is near-black. */
|
||||
.tlui-cmt-pin--resolved {
|
||||
background: #dfe0e1;
|
||||
color: hsl(214, 8%, 42%);
|
||||
}
|
||||
.tl-theme__dark .tlui-cmt-pin--resolved {
|
||||
background: hsl(240, 5%, 30%);
|
||||
color: hsl(0, 0%, 72%);
|
||||
}
|
||||
|
||||
/* count badge — the same marker as a pin. */
|
||||
.tlui-cmt-count-badge {
|
||||
width: var(--tlui-cmt-marker-size);
|
||||
height: var(--tlui-cmt-marker-size);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--tl-color-text-1);
|
||||
color: var(--tl-color-panel);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* reactions */
|
||||
.tlui-cmt-reactions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.tlui-cmt-reaction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
border: none;
|
||||
border-radius: var(--tl-radius-1);
|
||||
background: var(--tl-color-muted-2);
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
color: var(--tl-color-text-1);
|
||||
}
|
||||
|
||||
/* An inert pill renders as a span (e.g. in a notification row); only the button variant is
|
||||
clickable, so only it gets the pointer cursor and hover shade. */
|
||||
button.tlui-cmt-reaction {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Hover deepens the grey — no border to tint now. Mixing toward the text colour is theme-aware:
|
||||
it darkens the fill in light mode and lightens it in dark, tracking the pill's own contrast. */
|
||||
button.tlui-cmt-reaction:hover {
|
||||
background: var(--tl-color-muted-1);
|
||||
}
|
||||
|
||||
/* a reaction you're part of fills with the selection colour, so "I reacted with this" stands out
|
||||
from the grey pills of reactions you haven't joined. */
|
||||
.tlui-cmt-reaction--active {
|
||||
background: color-mix(in srgb, var(--tl-color-selected) 28%, var(--tl-color-panel));
|
||||
}
|
||||
|
||||
button.tlui-cmt-reaction--active:hover {
|
||||
background: color-mix(in srgb, var(--tl-color-selected) 38%, var(--tl-color-panel));
|
||||
}
|
||||
|
||||
/* The emoji renders larger than the 10px count, but its pinned line-height keeps the pill the
|
||||
count's height — the glyph overflows the line box rather than growing it. */
|
||||
.tlui-cmt-reaction__emoji {
|
||||
font-size: 13px;
|
||||
line-height: 10px;
|
||||
}
|
||||
|
||||
.tlui-cmt-reaction__count {
|
||||
color: var(--tl-color-text-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tlui-cmt-reaction--active .tlui-cmt-reaction__count {
|
||||
color: var(--tl-color-text-1);
|
||||
}
|
||||
|
||||
/* emoji picker */
|
||||
.tlui-cmt-emoji-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 40px);
|
||||
gap: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.tlui-cmt-emoji-picker__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 21px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Inset wash behind the glyph — the 40px hit cells tile edge-to-edge, the squares sit 4px apart,
|
||||
like the thread action buttons. Sits under the emoji so an opaque selected fill can't hide it. */
|
||||
.tlui-cmt-emoji-picker__item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
z-index: -1;
|
||||
border-radius: var(--tl-radius-2);
|
||||
}
|
||||
.tlui-cmt-emoji-picker__item:hover {
|
||||
z-index: 1;
|
||||
}
|
||||
.tlui-cmt-emoji-picker__item:hover::after {
|
||||
background: var(--tl-color-muted-2);
|
||||
}
|
||||
|
||||
.tlui-cmt-emoji-picker__item--active::after {
|
||||
background: color-mix(in srgb, var(--tl-color-selected) 28%, var(--tl-color-panel));
|
||||
}
|
||||
|
||||
/* empty state */
|
||||
.tlui-cmt-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.tlui-cmt-empty__icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.tlui-cmt-empty__message {
|
||||
margin: 0;
|
||||
color: var(--tl-color-text-3);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* a comment thread: an elevated panel holding a header, an optional resolved banner, the
|
||||
comments, and a reply composer */
|
||||
/* The thread panel's layout metrics live on the container because two separate portals need them:
|
||||
the panel itself, and the canvas hover preview, which has to land its first comment on exactly
|
||||
the pixel the panel would. Deriving the preview's offset from these means the two can't drift —
|
||||
retune the padding or the header here and the preview follows. */
|
||||
.tl-container {
|
||||
/* Everything that has to line up against a pin — its preview's bridge, the draft composer's
|
||||
offsets, `PIN_SIZE` in thread-view.tsx — derives from this, so the pin's size stays one
|
||||
number. */
|
||||
--tlui-cmt-pin-size: 28px;
|
||||
/* A cluster badge and a stack badge are this square — the same size as a pin, so the marker
|
||||
kinds read as one family. The hover preview measures its bridge against it, so the bridge
|
||||
stops at the marker's edge instead of covering it. Mirrored by `MARKER_SIZE` in
|
||||
thread-view.tsx. */
|
||||
--tlui-cmt-marker-size: var(--tlui-cmt-pin-size);
|
||||
/* How a marker sits, lifts, and rings. Shared so a pin and a badge respond identically.
|
||||
shadow-3's depth without its `inset 1px panel-contrast` edge — that inset reads as a white
|
||||
border on a coloured pin (it's invisible on the panel-white menus shadow-3 is meant for). */
|
||||
--tlui-cmt-marker-shadow: 0px 1px 2px hsl(0, 0%, 0%, 28%), 0px 2px 6px hsl(0, 0%, 0%, 14%);
|
||||
--tlui-cmt-marker-shadow-hover: var(--tlui-cmt-marker-shadow);
|
||||
--tlui-cmt-marker-hover-scale: 1.08;
|
||||
--tlui-cmt-marker-transition: 0.08s ease;
|
||||
--tlui-cmt-thread-padding: 12px;
|
||||
--tlui-cmt-thread-gap: 0px;
|
||||
/* Hit-area width of one .tlui-cmt-thread__action button (drawn as a 32px square inset 4px). */
|
||||
--tlui-cmt-thread-action-size: 40px;
|
||||
/* A floating pill's edge — its hairline ring is the only thing separating it from the panel it
|
||||
overlaps, so the ring is a theme token rather than a literal black: a near-black hairline
|
||||
vanishes on a dark panel, where the divider token lightens instead. The drop below it stays
|
||||
literal; a soft dark blur reads on both themes. */
|
||||
--tlui-cmt-pill-shadow: 0 0 0 1px var(--tl-color-divider), 0 1px 3px hsl(0, 0%, 0%, 0.05);
|
||||
}
|
||||
|
||||
.tlui-cmt-thread {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 300px;
|
||||
background: var(--tl-color-panel);
|
||||
border-radius: var(--tl-radius-4);
|
||||
box-shadow: var(--tl-shadow-3);
|
||||
}
|
||||
/* A pill of its own, floating above the panel's top-right corner and overlapping its top edge. */
|
||||
.tlui-cmt-thread__header {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: calc(100% - 15px);
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
background: var(--tl-color-panel);
|
||||
border-radius: var(--tl-radius-3);
|
||||
box-shadow: var(--tlui-cmt-pill-shadow);
|
||||
transition: opacity 0.1s ease;
|
||||
}
|
||||
/* The first comment's hover actions surface right where the header pill sits, so hide the pill
|
||||
while that comment is hovered — one pill in the corner, never two stacked. */
|
||||
.tlui-cmt-thread:has(.tlui-cmt-thread__list > :first-child:hover)
|
||||
.tlui-cmt-thread__header:not(:has(.tlui-cmt-thread__title)) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* A title puts the header back in the panel, in flow. The pill above is tuned for the actions-only
|
||||
case tldraw itself uses: right-anchored and only as wide as its buttons, so a title inside it
|
||||
would grow leftwards off the panel's edge and float over whatever sits above. `header` is public
|
||||
API (see `CommentThreadProps`), so it has to keep laying out sensibly for anyone passing one. */
|
||||
.tlui-cmt-thread__header:has(.tlui-cmt-thread__title) {
|
||||
position: static;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.tlui-cmt-thread__title {
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--tl-color-text-1);
|
||||
}
|
||||
.tlui-cmt-thread__actions {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
/* Right-align the actions whether or not the header has a title (it currently has none, so
|
||||
space-between alone would leave them at the left). */
|
||||
margin-left: auto;
|
||||
}
|
||||
/* These are TldrawUiButtons (type="icon"), so the 40px box, the 32px ::after square carrying the
|
||||
hover wash, and the hover z-index flip all come from .tlui-button — the hit area (not the
|
||||
::after) is what's clickable, and it never reaches past its row. Only the bits that differ from
|
||||
a toolbar button are set here, at .tlui-button's specificity + 1 so stylesheet order can't
|
||||
decide the winner. */
|
||||
.tlui-button.tlui-cmt-thread__action {
|
||||
width: 40px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
color: var(--tl-color-text-3);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
/* Overlap each button 4px into the previous so the 32px visual squares sit 4px apart, not 8px.
|
||||
Left-only, so the last button's hit area still stops at its row's right edge. */
|
||||
.tlui-cmt-thread__action + .tlui-cmt-thread__action {
|
||||
margin-left: -4px;
|
||||
}
|
||||
.tlui-button.tlui-cmt-thread__action:hover {
|
||||
color: var(--tl-color-text-1);
|
||||
}
|
||||
.tlui-cmt-thread__resolved {
|
||||
margin: 4px 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--tl-color-muted-2);
|
||||
font-size: 12px;
|
||||
color: var(--tl-color-text-3);
|
||||
}
|
||||
.tlui-cmt-thread__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tlui-cmt-thread-gap);
|
||||
}
|
||||
/* When nothing follows the list (a resolved thread has no composer or footer) the last comment
|
||||
would sit against the panel's rounded bottom — restore the trailing inset the composer supplies. */
|
||||
.tlui-cmt-thread__list:last-child {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
/* The footer slot sits where the composer would — its content (e.g. a sign-in prompt) sits at
|
||||
the thread's trailing edge, like the composer's send button. */
|
||||
.tlui-cmt-thread__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 4px 2px 2px;
|
||||
}
|
||||
/* Inside a thread, author identity is a small colour dot, no initial — the letter doesn't fit
|
||||
at this size. */
|
||||
.tlui-cmt-thread .tlui-cmt-avatar,
|
||||
.tlui-cmt-canvas-preview--thread .tlui-cmt-avatar,
|
||||
.tlui-cmt-canvas-preview--list .tlui-cmt-avatar,
|
||||
.tlui-cmt-stack-list .tlui-cmt-avatar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.tlui-cmt-thread .tlui-cmt-composer {
|
||||
padding: 12px;
|
||||
}
|
||||
.tlui-cmt-thread .tlui-cmt-composer .tlui-cmt-avatar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The sidebar's rows use the same small colour dot as the thread view. */
|
||||
.tlui-cmt-list .tlui-cmt-avatar {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
/* The sidebar row already leads with its own avatar (the flex row's first column); the byline's
|
||||
avatar — added for the canvas thread views — would double it, so hide the byline's inside a row. */
|
||||
.tlui-cmt-list .tlui-cmt-head .tlui-cmt-avatar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The dot treatment is for colour-only identity — an image avatar stays full size. */
|
||||
.tlui-cmt-thread .tlui-cmt-avatar--image,
|
||||
.tlui-cmt-canvas-preview--thread .tlui-cmt-avatar--image,
|
||||
.tlui-cmt-canvas-preview--list .tlui-cmt-avatar--image,
|
||||
.tlui-cmt-stack-list .tlui-cmt-avatar--image {
|
||||
/* Match the 12px colour dot's width so the body's calc(12px + 6px) indent still lines up under
|
||||
the byline name when the author has an image rather than a dot. */
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-top: 0;
|
||||
}
|
||||
.tlui-cmt-list .tlui-cmt-avatar--image {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* inside the panel, comments are plain content — no surrounding box; the composer's field carries
|
||||
its own surface. The hover preview is the same panel without a header, so its comment is laid
|
||||
out by the same rule rather than a copy of it. */
|
||||
.tlui-cmt-thread .tlui-cmt-card,
|
||||
.tlui-cmt-canvas-preview--thread .tlui-cmt-card {
|
||||
width: 100%;
|
||||
gap: 0;
|
||||
padding: 0 var(--tlui-cmt-thread-padding);
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
/* The avatar is a 12px dot, so the body lines up 12px + the head's 6px gap in from the row. */
|
||||
.tlui-cmt-thread .tlui-cmt-body,
|
||||
.tlui-cmt-canvas-preview--thread .tlui-cmt-body,
|
||||
.tlui-cmt-canvas-preview--list .tlui-cmt-body,
|
||||
.tlui-cmt-stack-list .tlui-cmt-body {
|
||||
padding-left: calc(12px + 6px);
|
||||
}
|
||||
/* Inside a panel the actions float as their own pill — the same pill the thread header is, in the
|
||||
same corner (which is why the header hides while the first comment is hovered). It sits just
|
||||
above the card's top edge rather than on the byline. */
|
||||
/* Line the card actions up with the header actions: flush to the full-width card's right edge (the
|
||||
panel edge, matching the header ✕) and flush on the 40px byline row, so the 40px buttons fill it. */
|
||||
.tlui-cmt-thread .tlui-cmt-card .tlui-cmt-card__actions {
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
/* The byline row is 40px: on the opened thread it fits the action pills (react/edit/delete) that
|
||||
float in its corner without the text running underneath; previews and collapsed cards carry the
|
||||
same height so every comment row reads identically, solo or in a stack. */
|
||||
.tlui-cmt-thread .tlui-cmt-card .tlui-cmt-head,
|
||||
.tlui-cmt-canvas-preview--thread .tlui-cmt-card .tlui-cmt-head,
|
||||
.tlui-cmt-canvas-preview--list .tlui-cmt-card .tlui-cmt-head,
|
||||
.tlui-cmt-stack-list .tlui-cmt-card .tlui-cmt-head {
|
||||
min-height: 40px;
|
||||
}
|
||||
/* Reserve head room only for pills that actually rendered (one child / two children) — pill count
|
||||
is a permissions question (canModifyComment), not ownership, and empty slots (read-only viewers)
|
||||
keep the full row. Sized from the 40px hit areas (4px overlap), minus the card padding the
|
||||
right:0 pills sit over; text stops 4px shy of the visible square. */
|
||||
.tlui-cmt-thread .tlui-cmt-card:has(.tlui-cmt-card__actions > *) .tlui-cmt-head {
|
||||
padding-right: calc(var(--tlui-cmt-thread-action-size) - var(--tlui-cmt-thread-padding));
|
||||
}
|
||||
.tlui-cmt-thread .tlui-cmt-card:has(.tlui-cmt-card__actions > * + *) .tlui-cmt-head {
|
||||
padding-right: calc(
|
||||
2 * var(--tlui-cmt-thread-action-size) - 4px - var(--tlui-cmt-thread-padding)
|
||||
);
|
||||
}
|
||||
|
||||
/* comments list — thread summaries, one per row (used by the canvas sidebar) */
|
||||
.tlui-cmt-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.tlui-cmt-list__header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 2px 2px 2px 14px;
|
||||
border-bottom: 1px solid var(--tl-color-divider);
|
||||
}
|
||||
.tlui-cmt-list__header-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--tl-color-text-1);
|
||||
}
|
||||
|
||||
/* An icon button in the sidebar header (filter funnel, overflow …). Sized like the thread actions:
|
||||
a TldrawUiButton (type="icon") left at its native 40px box, with .tlui-button's 32px ::after
|
||||
square carrying the hover wash. Only the bits that differ are set here, at .tlui-button's
|
||||
specificity + 1 so stylesheet order can't decide the winner. */
|
||||
.tlui-button.tlui-cmt-header-btn {
|
||||
width: 40px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
color: var(--tl-color-text-2);
|
||||
}
|
||||
/* Overlap each button 4px into the previous so the 32px visual squares sit 4px apart, not 8px —
|
||||
the same tiling the thread actions use. */
|
||||
.tlui-cmt-header-btn + .tlui-cmt-header-btn {
|
||||
margin-left: -4px;
|
||||
}
|
||||
.tlui-button.tlui-cmt-header-btn:hover,
|
||||
.tlui-button.tlui-cmt-header-btn[data-state='open'] {
|
||||
color: var(--tl-color-text-1);
|
||||
}
|
||||
.tlui-cmt-list__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* The commenting dropdowns' surface — a little air between the items and the menu edge. */
|
||||
.tlui-cmt-menu {
|
||||
min-width: 100px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* a menu row with a label and a right-aligned shortcut hint (overflow menu) */
|
||||
.tlui-cmt-menu-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 0 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--tl-color-text-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
/* 36px visual inset within the 40px row — the hover wash the action and emoji buttons use. */
|
||||
.tlui-cmt-menu-item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border-radius: var(--tl-radius-2);
|
||||
}
|
||||
.tlui-cmt-menu-item:hover::after {
|
||||
background: var(--tl-color-muted-2);
|
||||
}
|
||||
/* Destructive menu entries (delete) — the same danger colour as tldraw's danger buttons. */
|
||||
.tlui-cmt-menu-item--danger {
|
||||
color: var(--tl-color-danger);
|
||||
}
|
||||
|
||||
.tlui-cmt-menu-item__shortcut {
|
||||
color: var(--tl-color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.tlui-cmt-list__items {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tlui-cmt-list__empty {
|
||||
padding: 20px 14px;
|
||||
color: var(--tl-color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.tlui-cmt-list__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--tl-color-divider);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
/* rows can render as anchors (for open-in-new-tab); keep them looking like list rows */
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
/* The highlight is an inset, rounded wash like tldraw's buttons and menu items — not an
|
||||
edge-to-edge band — so a comment row lights up the same way every other interactive row does. */
|
||||
.tlui-cmt-list__item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border-radius: var(--tl-radius-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
.tlui-cmt-list__item:hover::after {
|
||||
background: var(--tl-color-muted-2);
|
||||
}
|
||||
/* Selected sits a step above hover so the open thread's row stays distinguishable while
|
||||
pointing at other rows — but only a step: the muted-1 token (10%) reads too heavy here.
|
||||
These rows sit on the opaque sidebar panel, so a translucent tint composes safely. */
|
||||
.tlui-cmt-list__item--selected::after,
|
||||
.tlui-cmt-list__item--selected:hover::after {
|
||||
background: hsl(0, 0%, 0%, 5%);
|
||||
}
|
||||
.tl-theme__dark .tlui-cmt-list__item--selected::after,
|
||||
.tl-theme__dark .tlui-cmt-list__item--selected:hover::after {
|
||||
background: hsl(0, 0%, 100%, 5%);
|
||||
}
|
||||
/* resolved rows mute their content but keep the "Resolved" marker legible */
|
||||
.tlui-cmt-list__item[data-resolved] .tlui-cmt-head,
|
||||
.tlui-cmt-list__item[data-resolved] .tlui-cmt-list__item-preview {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.tlui-cmt-list__item-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.tlui-cmt-list__item-preview {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--tl-color-text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
/* Long unbroken strings (URLs, mashed keys) wrap onto the clamped lines instead of clipping
|
||||
mid-string — same treatment as the card body. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* meta line under the preview: page label and/or a resolved marker */
|
||||
.tlui-cmt-list__item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--tl-color-text-3);
|
||||
}
|
||||
.tlui-cmt-list__item-resolved {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.tlui-cmt-list__item-page {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--tl-color-muted-2);
|
||||
}
|
||||
.tlui-cmt-list__item-replies {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
/* reply count under a preview card's opening comment — the thread continues past what's shown */
|
||||
.tlui-cmt-card__replies {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--tl-color-text-3);
|
||||
}
|
||||
16
packages/commenting/src/ui/count-badge.tsx
Normal file
16
packages/commenting/src/ui/count-badge.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
/** @public */
|
||||
export interface CountBadgeProps {
|
||||
count: number
|
||||
/** The badge's list is showing — shows the active/selected indicator state, as an open pin does. */
|
||||
open?: boolean
|
||||
}
|
||||
|
||||
/** @public @react */
|
||||
export function CountBadge({ count, open }: CountBadgeProps) {
|
||||
// `tlui-cmt-marker` carries the resting shadow, the hover lift, and the open ring — the same
|
||||
// treatment a comment pin wears, so the two markers behave alike.
|
||||
const className = ['tlui-cmt-marker', 'tlui-cmt-count-badge', open && 'tlui-cmt-marker--open']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
return <div className={className}>{count}</div>
|
||||
}
|
||||
25
packages/commenting/src/ui/emoji-picker.test.ts
Normal file
25
packages/commenting/src/ui/emoji-picker.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DEFAULT_REACTION_EMOJI, isAllowedReactionEmoji } from './emoji-picker'
|
||||
|
||||
describe('isAllowedReactionEmoji', () => {
|
||||
it('accepts every emoji in the default palette', () => {
|
||||
for (const emoji of DEFAULT_REACTION_EMOJI) {
|
||||
expect(isAllowedReactionEmoji(emoji)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects emoji not in the palette', () => {
|
||||
expect(isAllowedReactionEmoji('🦄')).toBe(false)
|
||||
})
|
||||
|
||||
// the record's emoji field is a free string, so this is the guard against junk values
|
||||
it('rejects arbitrary non-emoji strings', () => {
|
||||
expect(isAllowedReactionEmoji('not an emoji')).toBe(false)
|
||||
expect(isAllowedReactionEmoji('')).toBe(false)
|
||||
})
|
||||
|
||||
it('checks against a custom palette when given one', () => {
|
||||
expect(isAllowedReactionEmoji('🦄', ['🦄', '🌈'])).toBe(true)
|
||||
expect(isAllowedReactionEmoji('👍', ['🦄', '🌈'])).toBe(false)
|
||||
})
|
||||
})
|
||||
80
packages/commenting/src/ui/emoji-picker.tsx
Normal file
80
packages/commenting/src/ui/emoji-picker.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { type KeyboardEvent, type MouseEvent } from 'react'
|
||||
import { RenderReaction, defaultRenderReaction } from './reaction'
|
||||
|
||||
/**
|
||||
* The reaction emoji palette: the small, opinionated set the picker offers *and* the set a
|
||||
* reaction is allowed to use. It's deliberately short — a quick affordance under a comment, not a
|
||||
* full emoji keyboard — and it's the single source of truth for "which emoji may a reaction
|
||||
* carry". A reaction's `emoji` field is a free-form `string`, so this palette is what keeps it to
|
||||
* real, offered emoji; check membership with {@link isAllowedReactionEmoji}. Pass a custom `emoji`
|
||||
* array to `ReactionPicker` to offer a different set — validate against that same set if you do.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const DEFAULT_REACTION_EMOJI = ['👍', '👎', '❤️', '🎉', '😄', '😮', '😢', '🙏', '👀', '🔥']
|
||||
|
||||
/**
|
||||
* Whether `emoji` belongs to a reaction palette (defaults to {@link DEFAULT_REACTION_EMOJI}). Use
|
||||
* it to reject arbitrary strings before writing a reaction, so a scripted client can't spam a
|
||||
* comment with junk `emoji` values that the picker would never offer. Pass a custom `palette` to
|
||||
* match a customized picker.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function isAllowedReactionEmoji(
|
||||
emoji: string,
|
||||
palette: readonly string[] = DEFAULT_REACTION_EMOJI
|
||||
): boolean {
|
||||
return palette.includes(emoji)
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface EmojiPickerProps {
|
||||
/** The emoji to offer. Defaults to `DEFAULT_REACTION_EMOJI`. */
|
||||
emoji?: string[]
|
||||
/** Emoji the current user has already reacted with; shown as pressed. */
|
||||
selected?: string[]
|
||||
/**
|
||||
* Called when an emoji is chosen. Custom palettes should forward their click or keydown event
|
||||
* so `ReactionPicker` can keep the palette open on shift-picks.
|
||||
*/
|
||||
onSelect?(emoji: string, event?: MouseEvent | KeyboardEvent): void
|
||||
/** How to draw each emoji token. Defaults to the token string (OS emoji font). */
|
||||
renderReaction?: RenderReaction
|
||||
}
|
||||
|
||||
/**
|
||||
* A grid of emoji to react with. Presentational and unpositioned — the host owns placement and
|
||||
* dismissal; `ReactionPicker` renders it in a dropdown under the add-reaction button.
|
||||
* @public @react
|
||||
*/
|
||||
export function EmojiPicker({
|
||||
emoji = DEFAULT_REACTION_EMOJI,
|
||||
selected,
|
||||
onSelect,
|
||||
renderReaction = defaultRenderReaction,
|
||||
}: EmojiPickerProps) {
|
||||
return (
|
||||
<div className="tlui-cmt-emoji-picker" role="group">
|
||||
{emoji.map((value) => {
|
||||
const active = selected?.includes(value) ?? false
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={
|
||||
active
|
||||
? 'tlui-cmt-emoji-picker__item tlui-cmt-emoji-picker__item--active'
|
||||
: 'tlui-cmt-emoji-picker__item'
|
||||
}
|
||||
aria-label={value}
|
||||
aria-pressed={active}
|
||||
onClick={(e) => onSelect?.(value, e)}
|
||||
>
|
||||
{renderReaction(value)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
packages/commenting/src/ui/empty-state.tsx
Normal file
16
packages/commenting/src/ui/empty-state.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
/** @public */
|
||||
export interface EmptyStateProps {
|
||||
message: string
|
||||
}
|
||||
|
||||
/** The empty state shown when a thread has no comments yet. @public @react */
|
||||
export function EmptyState({ message }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="tlui-cmt-empty">
|
||||
<div className="tlui-cmt-empty__icon" aria-hidden="true">
|
||||
💬
|
||||
</div>
|
||||
<p className="tlui-cmt-empty__message">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
packages/commenting/src/ui/format-time.test.ts
Normal file
69
packages/commenting/src/ui/format-time.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatFullDateTime, formatRelativeTime } from './format-time'
|
||||
|
||||
/** An ISO timestamp `seconds` in the past, relative to now. */
|
||||
function agoIso(seconds: number): string {
|
||||
return new Date(Date.now() - seconds * 1000).toISOString()
|
||||
}
|
||||
|
||||
const TWO_HOURS = 2 * 60 * 60
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
it('formats in English by default', () => {
|
||||
expect(formatRelativeTime(agoIso(TWO_HOURS))).toBe(
|
||||
new Intl.RelativeTimeFormat('en', { numeric: 'auto', style: 'narrow' }).format(-2, 'hour')
|
||||
)
|
||||
})
|
||||
|
||||
// `Byline` passes the UI's current translation locale; without this a localized app showed
|
||||
// English timestamps under localized everything else.
|
||||
it('formats in the given locale', () => {
|
||||
const french = formatRelativeTime(agoIso(TWO_HOURS), 'fr')
|
||||
expect(french).toBe(
|
||||
new Intl.RelativeTimeFormat('fr', { numeric: 'auto', style: 'narrow' }).format(-2, 'hour')
|
||||
)
|
||||
expect(french).not.toBe(formatRelativeTime(agoIso(TWO_HOURS), 'en'))
|
||||
})
|
||||
|
||||
// The parameter is optional, so undefined has to mean "the default", not "no locale".
|
||||
it('treats an undefined locale as the default', () => {
|
||||
expect(formatRelativeTime(agoIso(TWO_HOURS), undefined)).toBe(
|
||||
formatRelativeTime(agoIso(TWO_HOURS))
|
||||
)
|
||||
})
|
||||
|
||||
it('reads under a minute as "now" in the given locale', () => {
|
||||
expect(formatRelativeTime(agoIso(5), 'fr')).toBe(
|
||||
new Intl.RelativeTimeFormat('fr', { numeric: 'auto', style: 'narrow' }).format(0, 'second')
|
||||
)
|
||||
})
|
||||
|
||||
it('returns nothing for an unparseable date', () => {
|
||||
expect(formatRelativeTime('not a date', 'fr')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatFullDateTime', () => {
|
||||
it('formats an ISO datetime as a full date and time', () => {
|
||||
const iso = '2025-07-22T16:44:00.000Z'
|
||||
expect(formatFullDateTime(iso)).toBe(
|
||||
new Intl.DateTimeFormat('en', { dateStyle: 'full', timeStyle: 'short' }).format(new Date(iso))
|
||||
)
|
||||
})
|
||||
|
||||
it('formats in the given locale', () => {
|
||||
const french = formatFullDateTime('2025-07-22T16:44:00.000Z', 'fr')
|
||||
expect(french).toContain('juillet')
|
||||
expect(french).not.toBe(formatFullDateTime('2025-07-22T16:44:00.000Z', 'en'))
|
||||
})
|
||||
|
||||
// The parameter is optional, so undefined has to mean "the default", not "no locale".
|
||||
it('treats an undefined locale as the default', () => {
|
||||
const iso = '2025-07-22T16:44:00.000Z'
|
||||
expect(formatFullDateTime(iso, undefined)).toBe(formatFullDateTime(iso))
|
||||
})
|
||||
|
||||
it('returns an empty string for invalid dates', () => {
|
||||
expect(formatFullDateTime('not a date')).toBe('')
|
||||
})
|
||||
})
|
||||
67
packages/commenting/src/ui/format-time.ts
Normal file
67
packages/commenting/src/ui/format-time.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
const DIVISIONS = [
|
||||
{ amount: 60, unit: 'second' },
|
||||
{ amount: 60, unit: 'minute' },
|
||||
{ amount: 24, unit: 'hour' },
|
||||
{ amount: 7, unit: 'day' },
|
||||
{ amount: 4.34524, unit: 'week' },
|
||||
{ amount: 12, unit: 'month' },
|
||||
{ amount: Number.POSITIVE_INFINITY, unit: 'year' },
|
||||
] as const
|
||||
|
||||
// Both formatters are cached per locale — comment cards re-render on every reply keystroke, and
|
||||
// constructing an Intl formatter is far more expensive than formatting with one.
|
||||
const relativeTimeFormatters = new Map<string, Intl.RelativeTimeFormat>()
|
||||
const fullDateTimeFormatters = new Map<string, Intl.DateTimeFormat>()
|
||||
|
||||
function getCached<T>(cache: Map<string, T>, locale: string, create: () => T): T {
|
||||
let formatter = cache.get(locale)
|
||||
if (!formatter) {
|
||||
formatter = create()
|
||||
cache.set(locale, formatter)
|
||||
}
|
||||
return formatter
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO datetime as compact relative time ("2h ago", "yesterday", "last wk.").
|
||||
* Locale-aware via Intl.RelativeTimeFormat.
|
||||
* @public
|
||||
*/
|
||||
export function formatRelativeTime(iso: string, locale = 'en'): string {
|
||||
const then = new Date(iso).getTime()
|
||||
if (Number.isNaN(then)) return ''
|
||||
|
||||
const rtf = getCached(
|
||||
relativeTimeFormatters,
|
||||
locale,
|
||||
() => new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style: 'narrow' })
|
||||
)
|
||||
let duration = (then - Date.now()) / 1000
|
||||
// Under a minute is just "now" — with second granularity the label visibly ticks while
|
||||
// typing a reply (every keystroke re-renders the card).
|
||||
if (Math.abs(duration) < 60) {
|
||||
return rtf.format(0, 'second')
|
||||
}
|
||||
for (const division of DIVISIONS) {
|
||||
if (Math.abs(duration) < division.amount) {
|
||||
return rtf.format(Math.round(duration), division.unit)
|
||||
}
|
||||
duration /= division.amount
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ISO datetime as a full date and time ("Tuesday, July 22, 2025 at 4:44 PM").
|
||||
* Locale-aware via Intl.DateTimeFormat.
|
||||
* @public
|
||||
*/
|
||||
export function formatFullDateTime(iso: string, locale = 'en'): string {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return getCached(
|
||||
fullDateTimeFormatters,
|
||||
locale,
|
||||
() => new Intl.DateTimeFormat(locale, { dateStyle: 'full', timeStyle: 'short' })
|
||||
).format(date)
|
||||
}
|
||||
138
packages/commenting/src/ui/icons.tsx
Normal file
138
packages/commenting/src/ui/icons.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/* The commenting UI's inline icons, kept together so the set is visible in one place.
|
||||
*
|
||||
* Anything with an equivalent in tldraw's icon assets goes through `TldrawUiIcon` instead (the ⋯
|
||||
* menu, the dismiss cross, the resolve button's check). What's left lives here because the asset
|
||||
* set has no counterpart — a smiley, a send arrow, a filter — or because the asset reads wrong at
|
||||
* the size it's needed, which is the case for both resolved checks below. The eye pair mirrors
|
||||
* tldraw's presence glyphs: the open state is the `follow` icon — the almond eye whose pupil
|
||||
* tracks presence — and the closed state the `closed` icon, its shut-lid companion. Both are in
|
||||
* the asset set, but they're inlined because the package renders without an `AssetUrlsProvider`,
|
||||
* so `TldrawUiIcon` can't reach them. Inline also means they render without the consumer serving
|
||||
* files.
|
||||
*
|
||||
* All internal — none are exported from the package. */
|
||||
|
||||
/** The check inside a resolved comment pin. A drawn check, not the '✓' glyph — that character sits
|
||||
* off-baseline and varies by font. Heavier and rounder than `CheckIcon`: it has to hold its weight
|
||||
* against the pin's 28px filled face, where the asset check reads too light. */
|
||||
export function PinCheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="15"
|
||||
height="15"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 12.5l5 5L20 6.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** The check beside the "Resolved" label on a comments list row. Thinner than `PinCheckIcon` — it
|
||||
* sits next to text at 12px, so it's drawn to match the label's weight rather than shout. */
|
||||
export function CheckIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M2.5 6.2 4.7 8.4 9.5 3.6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** The reaction picker's trigger. */
|
||||
export function SmileyIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M7.49991 0.876892C3.84222 0.876892 0.877075 3.84204 0.877075 7.49972C0.877075 11.1574 3.84222 14.1226 7.49991 14.1226C11.1576 14.1226 14.1227 11.1574 14.1227 7.49972C14.1227 3.84204 11.1576 0.876892 7.49991 0.876892ZM1.82708 7.49972C1.82708 4.36671 4.36689 1.82689 7.49991 1.82689C10.6329 1.82689 13.1727 4.36671 13.1727 7.49972C13.1727 10.6327 10.6329 13.1726 7.49991 13.1726C4.36689 13.1726 1.82708 10.6327 1.82708 7.49972ZM5.03747 9.21395C4.87949 8.98746 4.56782 8.93193 4.34133 9.08991C4.11484 9.24789 4.05931 9.55956 4.21729 9.78605C4.93926 10.8211 6.14033 11.5 7.50004 11.5C8.85974 11.5 10.0608 10.8211 10.7828 9.78605C10.9408 9.55956 10.8852 9.24789 10.6587 9.08991C10.4323 8.93193 10.1206 8.98746 9.9626 9.21395C9.41963 9.99238 8.51907 10.5 7.50004 10.5C6.481 10.5 5.58044 9.99238 5.03747 9.21395ZM5.37503 6.84998C5.85828 6.84998 6.25003 6.45815 6.25003 5.97498C6.25003 5.4918 5.85828 5.09998 5.37503 5.09998C4.89179 5.09998 4.50003 5.4918 4.50003 5.97498C4.50003 6.45815 4.89179 6.84998 5.37503 6.84998ZM10.5 5.97498C10.5 6.45815 10.1083 6.84998 9.62503 6.84998C9.14179 6.84998 8.75003 6.45815 8.75003 5.97498C8.75003 5.4918 9.14179 5.09998 9.62503 5.09998C10.1083 5.09998 10.5 5.4918 10.5 5.97498Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** The composer's post button — an up arrow. */
|
||||
export function SendIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M7.14645 2.14645C7.34171 1.95118 7.65829 1.95118 7.85355 2.14645L11.8536 6.14645C12.0488 6.34171 12.0488 6.65829 11.8536 6.85355C11.6583 7.04882 11.3417 7.04882 11.1464 6.85355L8 3.70711L8 12.5C8 12.7761 7.77614 13 7.5 13C7.22386 13 7 12.7761 7 12.5L7 3.70711L3.85355 6.85355C3.65829 7.04882 3.34171 7.04882 3.14645 6.85355C2.95118 6.65829 2.95118 6.34171 3.14645 6.14645L7.14645 2.14645Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Comment pins are showing. tldraw's `follow` glyph (icons/icon/follow.svg) inlined at its native
|
||||
* weight — the almond eye with a centred pupil. Pairs with `EyeClosedIcon`; the two must stay the
|
||||
* same family. */
|
||||
export function EyeOpenIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 30 30" fill="none" aria-hidden="true">
|
||||
<path fill="currentColor" d="M19 15a4 4 0 1 1-8 0 4 4 0 0 1 8 0" />
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
d="M26 15c0 1.77-1.077 3.496-3.07 4.825C20.946 21.149 18.145 22 15 22s-5.945-.851-7.93-2.175C5.076 18.496 4 16.77 4 15c0-1.77 1.077-3.496 3.07-4.825C9.054 8.851 11.855 8 15 8s5.945.851 7.93 2.175C24.924 11.504 26 13.23 26 15Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Comment pins are hidden. tldraw's `closed` eye glyph (icons/icon/closed.svg) — a shut lower lid
|
||||
* with lashes, the design pair to `follow`. Inlined like the open eye: the package renders without
|
||||
* an `AssetUrlsProvider`. Lash dots are `r=1.5` (vs the asset's `r=1`) because at the 15px header
|
||||
* size the asset's finer lashes fall below a pixel; the lid keeps `follow`'s native stroke. */
|
||||
export function EyeClosedIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 30 30" fill="none" aria-hidden="true">
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
d="M27 15C27 19.4183 21.6274 23 15 23C8.37258 23 3 19.4183 3 15"
|
||||
/>
|
||||
<circle cx="4" cy="20" r="1.5" fill="currentColor" />
|
||||
<circle cx="9" cy="23" r="1.5" fill="currentColor" />
|
||||
<circle cx="15" cy="24" r="1.5" fill="currentColor" />
|
||||
<circle cx="21" cy="23" r="1.5" fill="currentColor" />
|
||||
<circle cx="26" cy="20" r="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** The sidebar's comment-menu trigger — tldraw's `dots-vertical` glyph (icons/icon/dots-vertical.svg)
|
||||
* inlined, a vertical kebab. Inlined like the eye pair because the package renders without an
|
||||
* `AssetUrlsProvider`. */
|
||||
export function MoreMenuIcon() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 30 30" fill="none" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M15 9.48975C14.3167 9.48975 13.7292 9.24747 13.2375 8.76293C12.7458 8.27003 12.5 7.67688 12.5 6.98348C12.5 6.29843 12.7458 5.71364 13.2375 5.22909C13.7292 4.73619 14.3167 4.48975 15 4.48975C15.6833 4.48975 16.2708 4.73619 16.7625 5.22909C17.2542 5.71364 17.5 6.29843 17.5 6.98348C17.5 7.44296 17.3833 7.86485 17.15 8.24914C16.925 8.62508 16.625 8.92583 16.25 9.1514C15.875 9.37696 15.4583 9.48975 15 9.48975Z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M15 17.5C14.3167 17.5 13.7292 17.2577 13.2375 16.7732C12.7458 16.2803 12.5 15.6871 12.5 14.9937C12.5 14.3087 12.7458 13.7239 13.2375 13.2393C13.7292 12.7464 14.3167 12.5 15 12.5C15.6833 12.5 16.2708 12.7464 16.7625 13.2393C17.2542 13.7239 17.5 14.3087 17.5 14.9937C17.5 15.4532 17.3833 15.8751 17.15 16.2594C16.925 16.6353 16.625 16.9361 16.25 17.1617C15.875 17.3872 15.4583 17.5 15 17.5Z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M15 25.5103C14.3167 25.5103 13.7292 25.268 13.2375 24.7834C12.7458 24.2905 12.5 23.6974 12.5 23.004C12.5 22.319 12.7458 21.7342 13.2375 21.2496C13.7292 20.7567 14.3167 20.5103 15 20.5103C15.6833 20.5103 16.2708 20.7567 16.7625 21.2496C17.2542 21.7342 17.5 22.319 17.5 23.004C17.5 23.4635 17.3833 23.8854 17.15 24.2697C16.925 24.6456 16.625 24.9463 16.25 25.1719C15.875 25.3975 15.4583 25.5103 15 25.5103Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
36
packages/commenting/src/ui/reaction-picker.test.ts
Normal file
36
packages/commenting/src/ui/reaction-picker.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { MouseEvent } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isMultiSelectPick } from './reaction-picker'
|
||||
|
||||
function click(overrides: Partial<MouseEvent> = {}): MouseEvent {
|
||||
return {
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
button: 0,
|
||||
...overrides,
|
||||
} as MouseEvent
|
||||
}
|
||||
|
||||
describe('isMultiSelectPick', () => {
|
||||
it('keeps the palette open on a shift pick', () => {
|
||||
expect(isMultiSelectPick(click({ shiftKey: true }))).toBe(true)
|
||||
})
|
||||
|
||||
it('closes on a plain pick', () => {
|
||||
expect(isMultiSelectPick(click())).toBe(false)
|
||||
})
|
||||
|
||||
// custom palettes may call onSelect without an event; that must keep today's close-on-pick
|
||||
it('closes when no event is given', () => {
|
||||
expect(isMultiSelectPick(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['metaKey', 'ctrlKey', 'altKey'] as const)(
|
||||
'does not treat a %s pick as multi-select',
|
||||
(modifier) => {
|
||||
expect(isMultiSelectPick(click({ [modifier]: true }))).toBe(false)
|
||||
}
|
||||
)
|
||||
})
|
||||
113
packages/commenting/src/ui/reaction-picker.tsx
Normal file
113
packages/commenting/src/ui/reaction-picker.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useId, type ComponentType, type KeyboardEvent, type MouseEvent } from 'react'
|
||||
import {
|
||||
TldrawUiButton,
|
||||
TldrawUiDropdownMenuContent,
|
||||
TldrawUiDropdownMenuRoot,
|
||||
TldrawUiDropdownMenuTrigger,
|
||||
tlmenus,
|
||||
useMaybeEditor,
|
||||
useTranslation,
|
||||
} from 'tldraw'
|
||||
import { EmojiPicker, type EmojiPickerProps } from './emoji-picker'
|
||||
import { SmileyIcon } from './icons'
|
||||
import { RenderReaction } from './reaction'
|
||||
|
||||
/** @public */
|
||||
export interface ReactionPickerProps {
|
||||
/** The emoji to offer. Defaults to `DEFAULT_REACTION_EMOJI`. */
|
||||
emoji?: string[]
|
||||
/** Emoji the current user has already reacted with; shown as pressed in the grid. */
|
||||
selected?: string[]
|
||||
/** Called when an emoji is chosen. */
|
||||
onSelect?(emoji: string): void
|
||||
/** How to draw each emoji token. Defaults to the token string (OS emoji font). */
|
||||
renderReaction?: RenderReaction
|
||||
/**
|
||||
* What the button opens — the thing that produces a token. Defaults to `EmojiPicker`, the grid of
|
||||
* emoji. Swap it for any component taking the same props to offer something else entirely (the
|
||||
* drawn reactions example replaces it with a canvas you draw in); the props are passed straight
|
||||
* through either way.
|
||||
*/
|
||||
palette?: ComponentType<EmojiPickerProps>
|
||||
/**
|
||||
* Identifies the menu in tldraw's global menu registry, which keys open/closed state by id.
|
||||
* A thread renders one picker per comment, so this must differ per comment — sharing an id
|
||||
* makes every picker in the thread open at once. Defaults to a per-instance generated id;
|
||||
* pass one only for a stabler, more debuggable value.
|
||||
*/
|
||||
menuId?: string
|
||||
/** Class for the trigger button. Defaults to the card-action style, matching the ⋯ button. */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a palette pick asked to keep the palette open (shift held) for multi-select.
|
||||
* @internal
|
||||
*/
|
||||
export function isMultiSelectPick(event?: MouseEvent | KeyboardEvent): boolean {
|
||||
return !!event?.shiftKey
|
||||
}
|
||||
|
||||
/**
|
||||
* The add-reaction affordance: a smiley button that opens an emoji grid.
|
||||
*
|
||||
* Anchored to its own button rather than to the reactions row, so the menu keeps its position as
|
||||
* reactions are added and the row reflows.
|
||||
*
|
||||
* Picking an emoji dismisses the grid — adding and removing alike, since either way the picker has
|
||||
* done its job — and hands focus back to the trigger. Shift-picking keeps the grid open, for
|
||||
* choosing several reactions in a row.
|
||||
* @public @react
|
||||
*/
|
||||
export function ReactionPicker({
|
||||
emoji,
|
||||
selected,
|
||||
onSelect,
|
||||
renderReaction,
|
||||
palette: Palette = EmojiPicker,
|
||||
menuId,
|
||||
className = 'tlui-cmt-thread__action',
|
||||
}: ReactionPickerProps) {
|
||||
const msg = useTranslation()
|
||||
const editor = useMaybeEditor()
|
||||
// Unique per mounted picker unless the host supplies something stabler — see `menuId`.
|
||||
const generatedId = useId()
|
||||
const id = menuId ?? `comment-reactions-${generatedId}`
|
||||
|
||||
// The dropdown's open state lives in tldraw's global menu registry, so dropping the entry is
|
||||
// what closes it. The palette's tokens are plain buttons — a swappable component, not radix
|
||||
// menu items — so nothing dismisses the menu on its own.
|
||||
const handleSelect = useCallback(
|
||||
(value: string, event?: MouseEvent | KeyboardEvent) => {
|
||||
onSelect?.(value)
|
||||
if (!isMultiSelectPick(event)) {
|
||||
tlmenus.deleteOpenMenu(id, editor?.contextId)
|
||||
}
|
||||
},
|
||||
[onSelect, id, editor]
|
||||
)
|
||||
|
||||
return (
|
||||
<TldrawUiDropdownMenuRoot id={id}>
|
||||
<TldrawUiDropdownMenuTrigger>
|
||||
<TldrawUiButton
|
||||
type="icon"
|
||||
tooltip={msg('comments.add-reaction')}
|
||||
title={msg('comments.add-reaction')}
|
||||
className={className}
|
||||
>
|
||||
<SmileyIcon />
|
||||
</TldrawUiButton>
|
||||
</TldrawUiDropdownMenuTrigger>
|
||||
{/* left-aligned under the trigger, so the grid opens rightward off the card's edge */}
|
||||
<TldrawUiDropdownMenuContent side="bottom" align="start" alignOffset={0} sideOffset={4}>
|
||||
<Palette
|
||||
emoji={emoji}
|
||||
selected={selected}
|
||||
onSelect={handleSelect}
|
||||
renderReaction={renderReaction}
|
||||
/>
|
||||
</TldrawUiDropdownMenuContent>
|
||||
</TldrawUiDropdownMenuRoot>
|
||||
)
|
||||
}
|
||||
123
packages/commenting/src/ui/reaction.tsx
Normal file
123
packages/commenting/src/ui/reaction.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ComponentType, ReactNode } from 'react'
|
||||
import { TldrawUiTooltip, useTranslation } from 'tldraw'
|
||||
import { ReactionReactor } from './reactions'
|
||||
|
||||
/** Render a reaction token to its visual — the emoji glyph by default. @public */
|
||||
export type RenderReaction = (token: string) => ReactNode
|
||||
|
||||
/** The default reaction renderer: emits the token string for the OS emoji font to draw. @public */
|
||||
export function defaultRenderReaction(token: string): ReactNode {
|
||||
return token
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface ReactionProps {
|
||||
emoji: string
|
||||
count: number
|
||||
active: boolean
|
||||
/** How to draw the emoji token. Defaults to the token string (OS emoji font). */
|
||||
renderReaction?: RenderReaction
|
||||
/** Who reacted with this emoji, in reaction order — shown when the pill is hovered. */
|
||||
reactors: ReactionReactor[]
|
||||
/** Whether hovering shows the reactor list. Pass false to suppress it — e.g. while another
|
||||
* popup menu on the comment is open. Defaults to true. */
|
||||
enableHoverList?: boolean
|
||||
/** The hover affordance for the pill, naming who reacted. It receives the reactors and the pill
|
||||
* itself (as `children`) and returns the whole thing — so it owns the tooltip, its box, size,
|
||||
* shape, and position, not just the text inside. Defaults to `DefaultReactionTooltip`, which
|
||||
* hangs the built-in inline sentence below the pill. */
|
||||
ReactionTooltip?: ComponentType<ReactionTooltipProps>
|
||||
/** Called when the pill is pressed — toggles the current user's reaction. */
|
||||
onClick?(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* A single emoji reaction pill with a count, highlighted when the user reacted. Hovering it shows a
|
||||
* tooltip naming who reacted (the current user included), when `enableHoverList` is set.
|
||||
* @public @react
|
||||
*/
|
||||
export function Reaction({
|
||||
emoji,
|
||||
count,
|
||||
active,
|
||||
reactors,
|
||||
enableHoverList = true,
|
||||
renderReaction = defaultRenderReaction,
|
||||
ReactionTooltip = DefaultReactionTooltip,
|
||||
onClick,
|
||||
}: ReactionProps) {
|
||||
// An inert pill (no handler) is plain content, not a control — rendering it as a span keeps it
|
||||
// valid inside an anchor row (e.g. a notification list item) and out of the tab order.
|
||||
const Tag = onClick ? 'button' : 'span'
|
||||
const pill = (
|
||||
<Tag
|
||||
className={active ? 'tlui-cmt-reaction tlui-cmt-reaction--active' : 'tlui-cmt-reaction'}
|
||||
{...(onClick
|
||||
? { type: 'button' as const, 'aria-pressed': active, onClick }
|
||||
: { role: 'img' as const })}
|
||||
// The emoji alone announces as its unicode name ("thumbs up"); pairing it with the count
|
||||
// is what makes the pill's state legible to a screen reader. The inert span needs the
|
||||
// img role for the label to be announced at all.
|
||||
aria-label={`${emoji} ${count}`}
|
||||
>
|
||||
<span className="tlui-cmt-reaction__emoji">{renderReaction(emoji)}</span>
|
||||
<span className="tlui-cmt-reaction__count">{count}</span>
|
||||
</Tag>
|
||||
)
|
||||
|
||||
// A bare pill when hovering is suppressed, or when there's no one to name.
|
||||
if (!enableHoverList || reactors.length === 0) return pill
|
||||
|
||||
// Hand the whole hover affordance to the tooltip component: it wraps the pill and owns how the
|
||||
// reactor list is presented.
|
||||
return <ReactionTooltip reactors={reactors}>{pill}</ReactionTooltip>
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface ReactionTooltipProps {
|
||||
/** Who reacted with this emoji, in reaction order. */
|
||||
reactors: ReactionReactor[]
|
||||
/** The reaction pill to wrap. Anchor the hover affordance to this and render it. */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* The default reactor tooltip: hangs the built-in inline sentence (`DefaultReactionTooltipContent`)
|
||||
* in a standard tooltip below the pill — the same tooltip the toolbar uses. Replace it via
|
||||
* `CommentingComponents.ReactionTooltip` to present the reactor list any other way (a different box,
|
||||
* avatars, a banner, anywhere on screen); it receives the pill as `children`.
|
||||
* @public @react
|
||||
*/
|
||||
export function DefaultReactionTooltip({ reactors, children }: ReactionTooltipProps) {
|
||||
return (
|
||||
<TldrawUiTooltip side="bottom" content={<DefaultReactionTooltipContent reactors={reactors} />}>
|
||||
{children}
|
||||
</TldrawUiTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The default reactor sentence naming who reacted — up to three names spelled out, then "and N
|
||||
* others" (e.g. "You reacted", "You and Bo reacted", "You, Bo and Ada reacted", "You, Bo, Ada and 2
|
||||
* others reacted"). The wording lives in the `comments.reacted-*` translation strings so each locale
|
||||
* controls the grammar. Exported so a custom `ReactionTooltip` can reuse it inside its own box.
|
||||
* @public @react
|
||||
*/
|
||||
export function DefaultReactionTooltipContent({ reactors }: { reactors: ReactionReactor[] }) {
|
||||
const msg = useTranslation()
|
||||
const names = reactors.map((reactor) =>
|
||||
reactor.you ? msg('comments.mention-you') : reactor.name
|
||||
)
|
||||
if (names.length === 0) return null
|
||||
const [a, b, c] = names
|
||||
const others = names.length - 3
|
||||
const key =
|
||||
names.length <= 3
|
||||
? `comments.reacted-${names.length}`
|
||||
: others === 1
|
||||
? 'comments.reacted-more-one'
|
||||
: 'comments.reacted-more'
|
||||
// Single pass, so a substituted name containing a placeholder can't be re-substituted.
|
||||
const vars: Record<string, string | undefined> = { a, b, c, count: String(others) }
|
||||
return <>{msg(key).replace(/\{(a|b|c|count)\}/g, (_, k) => vars[k] ?? '')}</>
|
||||
}
|
||||
74
packages/commenting/src/ui/reactions.tsx
Normal file
74
packages/commenting/src/ui/reactions.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { ComponentType } from 'react'
|
||||
import { Reaction, ReactionTooltipProps, RenderReaction } from './reaction'
|
||||
|
||||
/** One person who reacted with a given emoji, for the hover list. @public */
|
||||
export interface ReactionReactor {
|
||||
/** Display name of the person who reacted. */
|
||||
name: string
|
||||
/** True for the current user. */
|
||||
you: boolean
|
||||
}
|
||||
|
||||
/** One emoji's reactions on a comment, already tallied. @public */
|
||||
export interface ReactionSummary {
|
||||
emoji: string
|
||||
/** How many people reacted with this emoji. */
|
||||
count: number
|
||||
/** True when the current user is one of them — renders the pill highlighted. */
|
||||
active: boolean
|
||||
/** Who reacted with this emoji, in reaction order — shown in the hover list. */
|
||||
reactors: ReactionReactor[]
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface ReactionsProps {
|
||||
/** The comment's reactions, grouped by emoji. Empty renders nothing. */
|
||||
reactions: ReactionSummary[]
|
||||
/** Toggles the current user's reaction for an emoji. */
|
||||
onToggle?(emoji: string): void
|
||||
/** Whether the current user may react. False makes the pills inert (e.g. a signed-out
|
||||
* viewer, or a read-only thread) while still showing counts. */
|
||||
canReact?: boolean
|
||||
/** Whether hovering a pill shows its reactor list. Pass false to suppress it — e.g. while
|
||||
* another popup menu on the comment is open. Defaults to true. */
|
||||
enableHoverList?: boolean
|
||||
/** How to draw each emoji token. Defaults to the token string (OS emoji font). */
|
||||
renderReaction?: RenderReaction
|
||||
/** The tooltip naming who reacted, shown when a pill is hovered. Defaults to an inline sentence
|
||||
* (`DefaultReactionTooltip`). Swap it to render the reactor list however you like. */
|
||||
ReactionTooltip?: ComponentType<ReactionTooltipProps>
|
||||
}
|
||||
|
||||
/**
|
||||
* The row of tallied reactions under a comment. Presentational — the host supplies the summaries
|
||||
* and owns what toggling does. Hovering a pill lists who reacted (see `Reaction`).
|
||||
*
|
||||
* The add-reaction affordance is a separate component (`ReactionPicker`) so it can live outside
|
||||
* this row — on a comment card it sits with the card's hover actions, which keeps its position
|
||||
* fixed as reactions are added here.
|
||||
* @public @react
|
||||
*/
|
||||
export function Reactions({
|
||||
reactions,
|
||||
onToggle,
|
||||
canReact = true,
|
||||
enableHoverList = true,
|
||||
renderReaction,
|
||||
ReactionTooltip,
|
||||
}: ReactionsProps) {
|
||||
if (reactions.length === 0) return null
|
||||
return (
|
||||
<div className="tlui-cmt-reactions">
|
||||
{reactions.map((reaction) => (
|
||||
<Reaction
|
||||
key={reaction.emoji}
|
||||
{...reaction}
|
||||
enableHoverList={enableHoverList}
|
||||
renderReaction={renderReaction}
|
||||
ReactionTooltip={ReactionTooltip}
|
||||
onClick={canReact ? () => onToggle?.(reaction.emoji) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
packages/commenting/src/ui/reply-count.ts
Normal file
16
packages/commenting/src/ui/reply-count.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useTranslation } from 'tldraw'
|
||||
|
||||
/**
|
||||
* The localized "N replies" label (e.g. "1 reply", "3 replies") for a thread with `replyCount`
|
||||
* replies — the comments after the opening one. Returns null when there are none, so callers render
|
||||
* nothing rather than "0 replies". Shared by the sidebar row and the hover preview so both read the
|
||||
* same, with each site owning where it puts the text.
|
||||
*/
|
||||
export function replyCountLabel(
|
||||
msg: ReturnType<typeof useTranslation>,
|
||||
replyCount: number
|
||||
): string | null {
|
||||
if (replyCount <= 0) return null
|
||||
const key = replyCount === 1 ? 'comments.replies-one' : 'comments.replies'
|
||||
return msg(key).replace('{count}', String(replyCount))
|
||||
}
|
||||
25
packages/commenting/src/ui/send-button.tsx
Normal file
25
packages/commenting/src/ui/send-button.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { SendIcon } from './icons'
|
||||
|
||||
/** @public */
|
||||
export interface SendButtonProps {
|
||||
/** The button's accessible name (e.g. "Send"). Shown as an up-arrow icon, so this is its label. */
|
||||
label: string
|
||||
disabled?: boolean
|
||||
onClick?(): void
|
||||
}
|
||||
|
||||
/** The button that posts a comment — an up arrow. @public @react */
|
||||
export function SendButton({ label, disabled, onClick }: SendButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className="tlui-cmt-send"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
<SendIcon />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
36
packages/commenting/src/ui/visual-viewport.test.ts
Normal file
36
packages/commenting/src/ui/visual-viewport.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getVisibleViewport } from './visual-viewport'
|
||||
|
||||
function makeWindow(
|
||||
innerWidth: number,
|
||||
innerHeight: number,
|
||||
vv?: { offsetTop: number; offsetLeft: number; width: number; height: number }
|
||||
): Window {
|
||||
return { innerWidth, innerHeight, visualViewport: vv ?? null } as unknown as Window
|
||||
}
|
||||
|
||||
describe('getVisibleViewport', () => {
|
||||
it('reads the visual viewport, carrying its offset into the derived edges', () => {
|
||||
const win = makeWindow(1024, 768, { offsetTop: 40, offsetLeft: 10, width: 900, height: 500 })
|
||||
expect(getVisibleViewport(win)).toEqual({
|
||||
top: 40,
|
||||
left: 10,
|
||||
width: 900,
|
||||
height: 500,
|
||||
bottom: 540,
|
||||
right: 910,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the layout viewport when visualViewport is absent', () => {
|
||||
const win = makeWindow(1024, 768)
|
||||
expect(getVisibleViewport(win)).toEqual({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 1024,
|
||||
height: 768,
|
||||
bottom: 768,
|
||||
right: 1024,
|
||||
})
|
||||
})
|
||||
})
|
||||
27
packages/commenting/src/ui/visual-viewport.ts
Normal file
27
packages/commenting/src/ui/visual-viewport.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// The visual viewport is the region actually on screen. The software keyboard (and pinch-zoom)
|
||||
// shrink it while leaving the layout viewport — and CSS `dvh` — unchanged, most notably on iOS.
|
||||
// So placing a panel above the keyboard means measuring against `window.visualViewport`, not CSS.
|
||||
// These helpers are the single place the commenting layer reads it.
|
||||
|
||||
/** The on-screen viewport region in window coordinates, falling back to the layout viewport where
|
||||
* `visualViewport` is unavailable. `top`/`left` carry the viewport's own offset — on iOS this is
|
||||
* non-zero while the keyboard is open, and dropping it misplaces anything measured against it. */
|
||||
export interface VisibleViewport {
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
height: number
|
||||
bottom: number
|
||||
right: number
|
||||
}
|
||||
|
||||
/** Read the visible viewport once. Call it inside a `visualViewport` resize/scroll handler to keep
|
||||
* a measurement current as the keyboard opens and closes. */
|
||||
export function getVisibleViewport(win: Window): VisibleViewport {
|
||||
const vv = win.visualViewport
|
||||
const top = vv ? vv.offsetTop : 0
|
||||
const left = vv ? vv.offsetLeft : 0
|
||||
const width = vv ? vv.width : win.innerWidth
|
||||
const height = vv ? vv.height : win.innerHeight
|
||||
return { top, left, width, height, bottom: top + height, right: left + width }
|
||||
}
|
||||
Reference in New Issue
Block a user