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

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}

View 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>
}

View 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>
)
}

View 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)
})
})

View 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
}

View 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);
}

View 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>
}

View 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)
})
})

View 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>
)
}

View 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>
)
}

View 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('')
})
})

View 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)
}

View 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>
)
}

View 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)
}
)
})

View 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>
)
}

View 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] ?? '')}</>
}

View 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>
)
}

View 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))
}

View 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>
)
}

View 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,
})
})
})

View 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 }
}