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, impreciseShapePinInset } 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(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. const pagePoint = anchorPagePoint(editor, first.anchor) if (!pagePoint) return null const viewportPoint = editor.pageToViewport(pagePoint) const inset = impreciseShapePinInset(editor, first.anchor) return inset ? { x: viewportPoint.x + inset.x, y: viewportPoint.y + inset.y } : viewportPoint }, [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 ( <>
{previewShown && !open && ( { openStackId.set(editor, stackId) openThreadId.set(editor, thread.id) }} {...previewHandlers} currentUserId={props.currentUserId} resolveAuthor={props.resolveAuthor} /> )} {open && (
{threads.map((thread) => thread.id === openId ? (
) : ( openThreadId.set(editor, thread.id)} /> ) )}
)} ) }) /** 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 (
{/* 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. */}
) }