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
220 lines
10 KiB
Markdown
220 lines
10 KiB
Markdown
# Reaction palettes
|
|
|
|
Comment reactions store a **token** — an opaque string the commenting layer only ever stores,
|
|
syncs, and hands back to a renderer. It never assumes the token is an emoji glyph. Out of the box
|
|
the token _is_ the glyph (drawn by the OS emoji font), but you can swap the palette to make a
|
|
reaction be anything: custom emoji images, SVGs, drawings, a fixed brand set, etc.
|
|
|
|
## Overrides are opt-in
|
|
|
|
Every extension point below — the palette, the renderer, the validator, and the reactor tooltip —
|
|
is **opt-in**. Each is a slot on the comment tool that defaults to a built-in when you leave it
|
|
unset. So an unconfigured tool gives you the full standard experience: OS-font emoji reactions, the
|
|
default emoji picker, and the default hover tooltip.
|
|
|
|
```tsx
|
|
// All of these are equivalent — the built-in emoji reactions + default tooltip:
|
|
const commentTools = [CommentTool]
|
|
const commentTools = [CommentTool.configure({})]
|
|
const commentTools = [CommentTool.configure({ components: {} })]
|
|
```
|
|
|
|
You only pass an override to change that specific slot; everything you don't pass stays the built-in.
|
|
`configure({})` is not "turn reactions off" — it's "use every default." Reverting any customisation
|
|
is just deleting the override, never adding config back.
|
|
|
|
## The model
|
|
|
|
A reaction is one record per `(comment, user, token)` (see `TLCommentReaction` in `@tldraw/tlschema`).
|
|
The `token` is stored verbatim. Three things decide how a palette behaves, and **they must agree** —
|
|
they're facets of one palette:
|
|
|
|
| Concern | Extension point | Default |
|
|
| ------------------------------------------ | ---------------------------- | ----------------------------------------------------------------- |
|
|
| **What tokens exist / how they're picked** | `components.ReactionPalette` | `EmojiPicker` (a grid of `DEFAULT_REACTION_EMOJI`) |
|
|
| **How a token is drawn** | `components.ReactionContent` | the token string (OS emoji font) |
|
|
| **Which tokens may be written** | `isAllowedReaction(token)` | `isAllowedReactionEmoji` (membership in `DEFAULT_REACTION_EMOJI`) |
|
|
|
|
All three are set on the comment tool:
|
|
|
|
```tsx
|
|
CommentTool.configure({
|
|
components: {
|
|
ReactionPalette: MyPalette,
|
|
ReactionContent: MyReactionContent,
|
|
},
|
|
isAllowedReaction: (token) => /* is this one of mine? */,
|
|
})
|
|
```
|
|
|
|
If you set none of them, you get the default emoji palette. **Reverting a custom palette is just
|
|
deleting these three overrides** — the defaults are the emoji system.
|
|
|
|
> **Why three, and why they must agree:** the picker emits tokens, the renderer draws them, and the
|
|
> validator gates writes. If the picker emits a token the renderer doesn't understand, the pill shows
|
|
> the raw token string; if the validator rejects it, the write is silently dropped. Keep the three in
|
|
> lockstep. (This is a known rough edge — the intent is to collapse them into a single
|
|
> `reactionPalette` object; until then, set all three together.)
|
|
|
|
## The three pieces in detail
|
|
|
|
### 1. The palette — `components.ReactionPalette`
|
|
|
|
A component with the same props as the built-in `EmojiPicker` (`EmojiPickerProps`):
|
|
|
|
```tsx
|
|
interface EmojiPickerProps {
|
|
emoji?: string[] // tokens to offer (your palette)
|
|
selected?: string[] // tokens the user has already reacted with (show as pressed)
|
|
onSelect?(token: string): void // call this with the chosen token
|
|
renderReaction?: RenderReaction // draw a token (pass through to your renderer)
|
|
}
|
|
```
|
|
|
|
It renders inside the add-reaction dropdown. When the user picks something, call
|
|
`onSelect(token)` — that token is what gets stored. It can be anything: an emoji glyph, a
|
|
shortcode, a `data:` URL, an id into your own store.
|
|
|
|
### 2. The renderer — `components.ReactionContent`
|
|
|
|
A component `({ token }) => ReactNode` used to draw a token wherever a reaction appears (the count
|
|
pills, and — via `renderReaction` — inside the palette). Return an `<img>`, an SVG, or plain text:
|
|
|
|
```tsx
|
|
function MyReactionContent({ token }: { token: string }) {
|
|
return isMyToken(token) ? <img src={resolveImage(token)} alt="" /> : <>{token}</>
|
|
}
|
|
```
|
|
|
|
Fall through to `<>{token}</>` for tokens you don't own, so a custom palette and the stock emoji
|
|
palette can coexist on the same comment.
|
|
|
|
The presentational components (`Reaction`, `Reactions`, `EmojiPicker`, `ReactionPicker`) also take a
|
|
`renderReaction(token): ReactNode` **function** prop directly, if you're composing them yourself
|
|
rather than through `CanvasComments`. `defaultRenderReaction` is the token-string default.
|
|
|
|
### 3. Validation — `isAllowedReaction(token)`
|
|
|
|
Gates which tokens may be **added** (removals always go through, so a reaction carrying an
|
|
off-palette token can still be cleared). This is the client-side guard that stops a token the picker
|
|
would never offer. It should accept exactly the tokens your palette emits:
|
|
|
|
```ts
|
|
isAllowedReaction: (token) => isMyToken(token) || isAllowedReactionEmoji(token)
|
|
```
|
|
|
|
> Server-side, a reaction is still verified for identity/ownership by the sync authorizer, but the
|
|
> _palette membership_ check is client-side. If you need a hard server bound, enforce your token set
|
|
> in the sync server too.
|
|
|
|
## Worked example: draw-your-own reactions
|
|
|
|
The [drawn reactions example][drawn] implements a complete custom palette — a small tldraw canvas
|
|
you draw in, stored as a `data:` image token. It lives in the examples app rather than in this
|
|
package, since it's one shape a custom palette can take rather than something the SDK has an
|
|
opinion about. Wiring it is the three overrides:
|
|
|
|
[drawn]: ../../apps/examples/src/examples/collaboration/comment-drawing-reactions
|
|
|
|
```tsx
|
|
import { CommentTool, isAllowedReactionEmoji, type EmojiPickerProps } from '@tldraw/commenting'
|
|
import {
|
|
DrawingReactionContent,
|
|
DrawingReactionPalette,
|
|
isDrawingReactionToken,
|
|
} from './drawing-reactions'
|
|
|
|
// DrawingReactionPalette is a nested tldraw editor, so bind its license key here.
|
|
function ReactionPalette(props: EmojiPickerProps) {
|
|
return <DrawingReactionPalette {...props} licenseKey={getLicenseKey()} />
|
|
}
|
|
|
|
const commentTools = [
|
|
CommentTool.configure({
|
|
components: {
|
|
ReactionPalette,
|
|
ReactionContent: DrawingReactionContent, // draws data: tokens as <img>, emoji fall through
|
|
},
|
|
// drawings OR emoji are valid, so old emoji reactions still work after the switch
|
|
isAllowedReaction: (token) => isDrawingReactionToken(token) || isAllowedReactionEmoji(token),
|
|
}),
|
|
]
|
|
```
|
|
|
|
To go back to plain emoji, delete those overrides:
|
|
|
|
```tsx
|
|
const commentTools = [CommentTool.configure({})]
|
|
```
|
|
|
|
That's the entire revert — no package changes, because the defaults _are_ the emoji palette.
|
|
|
|
## The reactor tooltip
|
|
|
|
Separate from the palette (which is about the tokens themselves), the **hover tooltip that names who
|
|
reacted** is its own override. By default, hovering a reaction pill shows an inline sentence —
|
|
`You reacted`, `You and Bo reacted`, `You, Bo and Ada reacted`, then `You, Bo, Ada and N others
|
|
reacted` past three names.
|
|
|
|
There are two levels of customisation, smallest first:
|
|
|
|
1. **Reword it — translation, no code.** The sentence is built from the `comments.reacted-1`,
|
|
`comments.reacted-2`, `comments.reacted-3`, `comments.reacted-more`, and `comments.reacted-more-one`
|
|
strings (plus `comments.mention-you` for "You"). Override them through the standard tldraw
|
|
translation `overrides` to change the words or reorder the `{a}` / `{b}` / `{c}` / `{count}`
|
|
placeholders per locale. This is also how the grammar gets localised.
|
|
|
|
2. **Replace it — `components.ReactionTooltip`.** To change more than the wording, replace the whole
|
|
affordance. The component receives the reactors **and the pill itself** (as `children`) and
|
|
returns the entire thing — so it owns the tooltip, its box, size, shape, and position, not just
|
|
the text inside. That's what lets it be anything: a different box, avatars, or a banner painted
|
|
anywhere on screen.
|
|
|
|
The default just hangs the built-in sentence in a standard tooltip. To keep that box but change
|
|
the contents, wrap `children` in `TldrawUiTooltip` yourself:
|
|
|
|
```tsx
|
|
import { CommentTool, type ReactionTooltipProps } from '@tldraw/commenting'
|
|
import { TldrawUiTooltip } from 'tldraw'
|
|
|
|
function AvatarReactionTooltip({ reactors, children }: ReactionTooltipProps) {
|
|
return (
|
|
<TldrawUiTooltip
|
|
side="bottom"
|
|
content={reactors.map((r, i) => (
|
|
<Avatar key={i} name={r.you ? 'You' : r.name} />
|
|
))}
|
|
>
|
|
{children}
|
|
</TldrawUiTooltip>
|
|
)
|
|
}
|
|
|
|
const commentTools = [
|
|
CommentTool.configure({ components: { ReactionTooltip: AvatarReactionTooltip } }),
|
|
]
|
|
```
|
|
|
|
To break out of the tooltip entirely — your own popover, a fixed banner, no box at all — don't
|
|
render `TldrawUiTooltip`; wrap `children` however you like and own the hover state yourself.
|
|
|
|
`ReactionTooltipProps` gives you `reactors: { name: string; you: boolean }[]` in reaction order,
|
|
plus `children` (the pill). Unset, it falls back to `DefaultReactionTooltip` (the default wrapper).
|
|
Two exports help you compose: `DefaultReactionTooltip` (the wrapper) and
|
|
`DefaultReactionTooltipContent` (just the inline sentence, to drop inside your own box). This is
|
|
the same override mechanism as `ReactionContent` / `ReactionPalette`, just for the reactor list.
|
|
|
|
> `enableHoverList` still applies upstream: when another menu on the comment is open the pill
|
|
> renders bare and your `ReactionTooltip` isn't mounted, so you never have to handle that case.
|
|
|
|
## Token gotchas
|
|
|
|
- **The token goes into the record id** (`id = f(comment, user, token)`), so keep tokens small.
|
|
Large tokens (e.g. an inlined drawing) bloat the id and the Postgres primary key. The drawn
|
|
reactions example caps token length for this reason.
|
|
- **Tokens arrive from other users over sync.** If a token renders as HTML (an SVG, an image),
|
|
render it in a way that can't execute script or reach the network — the drawing renderer uses
|
|
`<img src>` (never inlined SVG) and gates on an explicit `data:` prefix allowlist.
|
|
- **Tokens are effectively immutable.** A stored reaction keeps its token forever; don't repurpose a
|
|
token to mean something else, or old reactions change meaning.
|