snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
219
packages/commenting/REACTIONS.md
Normal file
219
packages/commenting/REACTIONS.md
Normal file
@@ -0,0 +1,219 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user