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 @@
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)

View File

@@ -0,0 +1,16 @@
# @tldraw/mentions
The tldraw `@`-mention picker and TipTap mention node. Shared by `@tldraw/commenting` (the comment
composer) and tldraw shape rich text, so mentioning works the same wherever rich text is edited.
This package owns:
- `createMentionExtension` — the TipTap mention node, configured to render as a pill and (optionally)
drive the `@` picker.
- `createMentionSuggestion` — the TipTap `suggestion` config that shows the live member picker as you
type after `@`, anchored to the editor and (on canvas) re-anchored as the camera moves.
- `MentionList` / `Mention` / `Avatar` — the presentational picker and pill components.
- `filterMentionMembers`, `isMentionPickerOpen` — helpers for hosts.
The host owns the roster: pass a resolver that returns the members matching an `@`-query, and a
`resolveName` that maps a stored member id to its current display name.

View File

@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"extends": "../../internal/config/api-extractor.json"
}

View File

@@ -0,0 +1,92 @@
## API Report File for "@tldraw/mentions"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Editor } from 'tldraw';
import { ForwardRefExoticComponent } from 'react';
import { JsonObject } from 'tldraw';
import { JSX } from 'react/jsx-runtime';
import { MentionNodeAttrs } from '@tiptap/extension-mention';
import { MentionOptions } from '@tiptap/extension-mention';
import { Node as Node_2 } from 'tldraw';
import { ReactNode } from 'react';
import { RefAttributes } from 'react';
import type { SuggestionOptions } from '@tiptap/suggestion';
// @public
export function Avatar({ author }: AvatarProps): JSX.Element;
// @public (undocumented)
export interface AvatarProps {
// (undocumented)
author: CommentAuthor;
}
// @public
export interface CommentAuthor {
color?: string;
image?: string;
meta?: JsonObject;
// (undocumented)
name: string;
}
// @public
export function createMentionExtension({ resolveName, suggestion }?: MentionExtensionOptions): Node_2<MentionOptions<any, MentionNodeAttrs>, any>;
// @public
export function createMentionSuggestion(getSuggestions: (query: string) => MentionMember[] | Promise<MentionMember[]>, options?: MentionSuggestionOptions): Omit<SuggestionOptions<MentionMember, MentionNodeAttrs>, 'editor'>;
// @public
export function filterMentionMembers(members: MentionMember[], query: string): MentionMember[];
// @public
export function isMentionPickerOpen(): boolean;
// @public
export function Mention({ name }: MentionProps): JSX.Element;
// @public (undocumented)
export interface MentionExtensionOptions {
resolveName?(id: string): string | undefined;
suggestion?: MentionOptions['suggestion'];
}
// @public
export const MentionList: ForwardRefExoticComponent<MentionListProps & RefAttributes<HTMLDivElement>>;
// @public (undocumented)
export interface MentionListProps {
activeIndex?: number;
emptyLabel?: string;
members: MentionMember[];
onSelect?(member: MentionMember): void;
renderMember?(member: MentionMember): ReactNode;
}
// @public
export interface MentionMember extends CommentAuthor {
[key: string]: unknown;
// (undocumented)
id: string;
secondary?: string;
you?: boolean;
}
// @public (undocumented)
export interface MentionProps {
// (undocumented)
name: string;
}
// @public (undocumented)
export interface MentionSuggestionOptions {
editor?: null | Editor;
renderMember?(member: MentionMember): ReactNode;
}
// (No @packageDocumentation comment for this package)
```

View File

@@ -0,0 +1,109 @@
/* avatar — a person's image, or a single-initial coloured circle. Shared by the mention picker and,
via @tldraw/commenting, the comment UI. */
.tlui-cmt-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--tl-color-selected);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
.tlui-cmt-avatar--image {
object-fit: cover;
background: none;
}
/* mention pill */
.tlui-cmt-mention {
color: var(--tl-color-selected);
font-weight: 600;
background: color-mix(in srgb, var(--tl-color-selected) 12%, transparent);
padding: 0 4px;
border-radius: 4px;
}
/* mention picker */
.tlui-cmt-mention-list {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px;
width: 100%;
box-sizing: border-box;
max-height: 260px;
overflow-y: auto;
color: var(--tl-color-text-1, #1d1d1d);
background: var(--tl-color-panel, #fff);
border-radius: 10px;
/* shadow-3 (like the context menu) already carries a 1px inset edge — a separate `border`
on top of it double-outlines the popup. */
box-shadow: var(--tl-shadow-3, 0 2px 12px rgba(0, 0, 0, 0.14));
}
.tlui-cmt-mention-list__item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
text-align: left;
color: var(--tl-color-text-1);
font: inherit;
}
.tlui-cmt-mention-list__item--active,
.tlui-cmt-mention-list__item:hover {
background: var(--tl-color-muted-2, rgba(0, 0, 0, 0.06));
}
.tlui-cmt-mention-list__text {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.3;
}
.tlui-cmt-mention-list__name {
font-size: 12px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tlui-cmt-mention-list__you {
margin-left: 4px;
font-weight: 400;
color: var(--tl-color-text-3);
}
.tlui-cmt-mention-list__secondary {
font-size: 12px;
color: var(--tl-color-text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tlui-cmt-mention-list--empty {
padding: 8px 10px;
width: 100%;
box-sizing: border-box;
color: var(--tl-color-text-3);
font-size: 13px;
}
/* the @-picker popup — anchored under the editor field, positioned by the suggestion plugin */
.tlui-cmt-mention-popup {
position: fixed;
z-index: var(--tl-layer-menus);
}

View File

@@ -0,0 +1,67 @@
{
"name": "@tldraw/mentions",
"description": "tldraw @-mention picker and TipTap node, shared by commenting and shape rich text.",
"version": "5.4.2",
"author": {
"name": "tldraw Inc.",
"email": "hello@tldraw.com"
},
"homepage": "https://tldraw.dev",
"license": "SEE LICENSE IN LICENSE.md",
"tldraw_product": {
"stableId": "tldraw:commenting",
"name": "Commenting",
"type": "feature",
"parent": "tldraw:collaboration",
"premium": true,
"licenseFlag": "FEAT_COMMENTING"
},
"repository": {
"type": "git",
"url": "https://github.com/tldraw/tldraw"
},
"bugs": {
"url": "https://github.com/tldraw/tldraw/issues"
},
"main": "./src/index.ts",
"types": "./.tsbuild/index.d.ts",
"style": "./mentions.css",
"type": "module",
"files": [
"mentions.css"
],
"scripts": {
"test-ci": "yarn run -T vitest run --passWithNoTests",
"test": "yarn run -T vitest --passWithNoTests",
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
"postpack": "../../internal/scripts/postpack.sh",
"pack-tarball": "yarn pack",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
},
"devDependencies": {
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"vitest": "^5.0.0"
},
"peerDependencies": {
"react": "^18.2.0 || ^19.2.1",
"react-dom": "^18.2.0 || ^19.2.1"
},
"engines": {
"node": ">=22.12.0"
},
"dependencies": {
"@tiptap/core": "^3.12.1",
"@tiptap/extension-mention": "3.13.0",
"@tiptap/pm": "^3.12.1",
"@tiptap/react": "^3.12.1",
"@tiptap/suggestion": "3.13.0",
"@tldraw/utils": "workspace:*",
"tldraw": "workspace:*"
}
}

View File

@@ -0,0 +1,35 @@
import { getFirstCharacter } from 'tldraw'
import { CommentAuthor } from './comment-author'
/** @public */
export interface AvatarProps {
author: CommentAuthor
}
function initial(name: string) {
return (getFirstCharacter(name.trim()) || '?').toUpperCase()
}
/** A person's avatar — their image if provided, otherwise a single-initial coloured circle.
* @public @react */
export function Avatar({ author }: AvatarProps) {
if (author.image) {
return (
<img
className="tlui-cmt-avatar tlui-cmt-avatar--image"
src={author.image}
alt=""
aria-hidden="true"
/>
)
}
return (
<div
className="tlui-cmt-avatar"
aria-hidden="true"
style={author.color ? { backgroundColor: author.color } : undefined}
>
{initial(author.name)}
</div>
)
}

View File

@@ -0,0 +1,15 @@
import { JsonObject } from 'tldraw'
/** A comment author's display identity, resolved from an author id.
* @public */
export interface CommentAuthor {
name: string
/** The author's color (any CSS color) — colors their avatar and pins.
* Omit for the default tint. */
color?: string
/** Avatar image URL. When set, avatars show the image instead of the colored initial;
* the canvas pin keeps the initial-on-color. */
image?: string
/** User-defined metadata for the author, never read by the toolkit (like a shape's `meta`). */
meta?: JsonObject
}

View File

@@ -0,0 +1,25 @@
import { registerTldrawLibraryVersion } from '@tldraw/utils'
// Presentational mention components. These are tldraw-independent and can be used to build custom
// mention UI.
export { Avatar, type AvatarProps } from './avatar'
export { type CommentAuthor } from './comment-author'
export { Mention, type MentionProps } from './mention'
export { MentionList, type MentionListProps, type MentionMember } from './mention-list'
// The TipTap mention node + `@`-picker suggestion. Add the node to a rich-text editor's extensions
// (comments composer, shape rich text) to render mention pills and — when a suggestion is provided —
// drive the picker.
export { createMentionExtension, type MentionExtensionOptions } from './mention-extension'
export {
createMentionSuggestion,
filterMentionMembers,
isMentionPickerOpen,
type MentionSuggestionOptions,
} from './mention-suggestion'
registerTldrawLibraryVersion(
(globalThis as any).TLDRAW_LIBRARY_NAME,
(globalThis as any).TLDRAW_LIBRARY_VERSION,
(globalThis as any).TLDRAW_LIBRARY_MODULES
)

View File

@@ -0,0 +1,41 @@
import { generateHTML } from '@tiptap/core'
import { getTipTapDefaultExtensions } from 'tldraw'
import { describe, expect, it } from 'vitest'
import { createMentionExtension } from './mention-extension'
// The exact render path shape text uses: tldraw's default rich-text extensions plus the mention
// node, run through TipTap's `generateHTML` (what `renderHtmlFromRichText` does under the hood).
const render = (body: any, resolveName?: (id: string) => string | undefined) =>
generateHTML(body, [...getTipTapDefaultExtensions(), createMentionExtension({ resolveName })])
const doc = (...content: any[]) => ({ type: 'doc', content }) as any
const para = (...content: any[]) => ({ type: 'paragraph', content })
const text = (value: string) => ({ type: 'text', text: value })
const mention = (id: string, label?: string) => ({ type: 'mention', attrs: { id, label } })
describe('createMentionExtension', () => {
it('renders a mention node as a pill carrying its member id', () => {
const html = render(doc(para(text('hi '), mention('ada', 'Ada'))))
expect(html).toContain('tlui-cmt-mention')
expect(html).toContain('data-mention-id="ada"')
})
it('resolves the id to the current name, not the label frozen at insert time', () => {
const html = render(doc(para(mention('ada', 'Ada'))), (id) =>
id === 'ada' ? 'Ada Lovelace' : undefined
)
expect(html).toContain('@Ada Lovelace')
expect(html).not.toContain('@Ada<')
})
it('falls back to the stored label when the id can not be resolved', () => {
const html = render(doc(para(mention('ghost', 'Ada'))), () => undefined)
expect(html).toContain('@Ada')
})
it('escapes a resolved name so it can never inject markup', () => {
const html = render(doc(para(mention('ada'))), () => '<img src=x onerror=alert(1)>')
expect(html).not.toContain('<img')
expect(html).toContain('&lt;img')
})
})

View File

@@ -0,0 +1,51 @@
import { mergeAttributes } from '@tiptap/core'
import { Mention, type MentionNodeAttrs, type MentionOptions } from '@tiptap/extension-mention'
/**
* A mention node's display name: the member's current name, resolved live from its id when a
* resolver is given (so a rename shows through), falling back to the label captured at insert time
* when no source can name the id — e.g. a deleted account. The stored id is always the source of
* truth; live resolution only freshens the displayed name over that captured label.
*/
function mentionName(
attrs: MentionNodeAttrs,
resolveName?: (id: string) => string | undefined
): string {
const resolved = attrs.id && resolveName ? resolveName(attrs.id) : undefined
return resolved ?? attrs.label ?? attrs.id ?? ''
}
/** @public */
export interface MentionExtensionOptions {
/**
* Resolve a member id to its current display name — for the read-only render paths, and for the
* editor while a mention is displayed. Returns `undefined` when the id can't be resolved, so the
* render falls back to the mention's stored label.
*/
resolveName?(id: string): string | undefined
/** The `@`-picker suggestion config — for editing (omit on read-only render paths). */
suggestion?: MentionOptions['suggestion']
}
/**
* The \@-mention node — TipTap's `Mention` configured to render as a `.tlui-cmt-mention` pill.
*
* A factory rather than a shared constant because it's configured differently per context: the
* read-only render paths pass `resolveName` (so the stored `{ id }` node always shows the member's
* current name, not a copy frozen at insert time), while an editor passes a `suggestion` (the `@`
* picker). Shape rich text passes both — the same extension both edits and renders. The node schema
* is identical either way — only these two levers differ.
* @public
*/
export function createMentionExtension({ resolveName, suggestion }: MentionExtensionOptions = {}) {
return Mention.configure({
HTMLAttributes: { class: 'tlui-cmt-mention' },
renderText: ({ node }) => `@${mentionName(node.attrs as MentionNodeAttrs, resolveName)}`,
renderHTML: ({ node, options }) => [
'span',
mergeAttributes(options.HTMLAttributes, { 'data-mention-id': node.attrs.id }),
`@${mentionName(node.attrs as MentionNodeAttrs, resolveName)}`,
],
...(suggestion ? { suggestion } : {}),
})
}

View File

@@ -0,0 +1,98 @@
import { ReactNode, forwardRef } from 'react'
import { useTranslation } from 'tldraw'
import { Avatar } from './avatar'
import { CommentAuthor } from './comment-author'
/** A person the composer's \@-mention picker can offer: a {@link CommentAuthor} plus its id and
* picker-only display fields. @public */
export interface MentionMember extends CommentAuthor {
id: string
/** A secondary line under the name — e.g. an email or handle. Omit for a single-line row. */
secondary?: string
/** Marks the current user; shown as a "(You)" suffix after the name. */
you?: boolean
/** Hosts may carry extra fields; a custom `renderMember` can read them. */
[key: string]: unknown
}
/** @public */
export interface MentionListProps {
/** Members to choose from — already resolved for the current query by the host. */
members: MentionMember[]
/** Index of the highlighted row, driven by the composer's keyboard navigation. */
activeIndex?: number
/** Called when a member is chosen (click, or Enter on the active row). */
onSelect?(member: MentionMember): void
/** Shown when no member matches the query. Defaults to a translated "No matches". */
emptyLabel?: string
/** Override a row's content (inside the selectable button). Defaults to avatar + name (+ secondary). */
renderMember?(member: MentionMember): ReactNode
}
/** The default row content: avatar, name (with a "(You)" marker), and an optional secondary line. */
function DefaultMemberRow({ member }: { member: MentionMember }) {
const msg = useTranslation()
return (
<>
<Avatar author={member} />
<span className="tlui-cmt-mention-list__text">
<span className="tlui-cmt-mention-list__name">
{member.name}
{member.you && (
<span className="tlui-cmt-mention-list__you">{`(${msg('comments.mention-you')})`}</span>
)}
</span>
{member.secondary && (
<span className="tlui-cmt-mention-list__secondary">{member.secondary}</span>
)}
</span>
</>
)
}
/**
* The \@-mention member picker: a popover list of people to mention, shown as the user types after
* `@`. Presentational — the composer's suggestion plugin owns resolution, keyboard navigation, and
* placement, and drives this with the host-resolved `members` and the highlighted `activeIndex`.
* @public @react
*/
export const MentionList = forwardRef<HTMLDivElement, MentionListProps>(function MentionList(
{ members, activeIndex = 0, onSelect, emptyLabel, renderMember },
ref
) {
const msg = useTranslation()
if (members.length === 0) {
return (
<div ref={ref} className="tlui-cmt-mention-list tlui-cmt-mention-list--empty">
{emptyLabel ?? msg('comments.mention-no-matches')}
</div>
)
}
return (
<div ref={ref} className="tlui-cmt-mention-list" role="listbox">
{members.map((member, i) => {
const active = i === activeIndex
return (
<button
key={member.id}
type="button"
role="option"
aria-selected={active}
className={
active
? 'tlui-cmt-mention-list__item tlui-cmt-mention-list__item--active'
: 'tlui-cmt-mention-list__item'
}
// Keep the composer focused on a row press: it's portaled outside the editor, so a
// bare mousedown would blur the composer — which now dismisses the picker — before
// the click could select. Selection runs on click; focus stays put.
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect?.(member)}
>
{renderMember ? renderMember(member) : <DefaultMemberRow member={member} />}
</button>
)
})}
</div>
)
})

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { filterMentionMembers } from './mention-suggestion'
const members = [
{ id: 'u1', name: 'Ada Lovelace' },
{ id: 'u2', name: 'Alan Turing' },
{ id: 'u3', name: 'Grace Hopper' },
]
const ids = (q: string) => filterMentionMembers(members, q).map((m) => m.id)
describe('filterMentionMembers', () => {
it('matches a case-insensitive substring; empty query keeps all', () => {
expect(ids('')).toEqual(['u1', 'u2', 'u3'])
expect(ids('grace')).toEqual(['u3'])
expect(ids('AL')).toEqual(['u2']) // "Alan", case-insensitive; "Ada Lovelace" has no "al"
expect(ids('zzz')).toEqual([])
})
})

View File

@@ -0,0 +1,260 @@
import type { MentionNodeAttrs } from '@tiptap/extension-mention'
import { ReactRenderer } from '@tiptap/react'
import type { SuggestionKeyDownProps, SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'
import { type ReactNode, forwardRef, useImperativeHandle, useRef, useState } from 'react'
import { type Editor as TldrawEditor, atom, react, usePassThroughWheelEvents } from 'tldraw'
import { MentionList, MentionMember } from './mention-list'
/** The handle the suggestion plugin drives — it forwards navigation keys into the popup. */
interface MentionPopupHandle {
onKeyDown(props: SuggestionKeyDownProps): boolean
}
interface MentionPopupProps {
items: MentionMember[]
command(attrs: MentionNodeAttrs): void
renderMember?(member: MentionMember): ReactNode
}
/** The live @-picker popup: owns the highlighted index and keyboard, renders the presentational list. */
const MentionPopup = forwardRef<MentionPopupHandle, MentionPopupProps>(function MentionPopup(
{ items, command, renderMember },
ref
) {
const [activeIndex, setActiveIndex] = useState(0)
// A wheel over the popup drives the canvas beneath it, the same pass-through every tldraw panel gets;
// the hook leaves the list alone while it scrolls its own overflow. The suggestion plugin builds the
// popup imperatively, but `ReactRenderer` portals this into the composer's tree, so context reaches it.
const listRef = useRef<HTMLDivElement>(null)
usePassThroughWheelEvents(listRef)
// A new query yields new items; reset the highlight to the top during render — not in an effect,
// which would leave a frame where `activeIndex` still points past a shrunk list and Enter selects
// its (now out-of-range, undefined) item, swallowing the key without inserting a mention.
const [prevItems, setPrevItems] = useState(items)
if (items !== prevItems) {
setPrevItems(items)
setActiveIndex(0)
}
const select = (member: MentionMember | undefined) => {
if (member) command({ id: member.id, label: member.name })
}
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (items.length === 0) return false
if (event.key === 'ArrowUp') {
setActiveIndex((i) => (i + items.length - 1) % items.length)
return true
}
if (event.key === 'ArrowDown') {
setActiveIndex((i) => (i + 1) % items.length)
return true
}
// Enter and Tab both complete the highlighted member (falling back to the top match so a
// stale index never selects `undefined`). The empty-roster case is handled a level up, in
// the suggestion's onKeyDown, which cancels the picker.
if (event.key === 'Enter' || event.key === 'Tab') {
select(items[activeIndex] ?? items[0])
return true
}
return false
},
}))
return (
<MentionList
ref={listRef}
members={items}
activeIndex={activeIndex}
onSelect={select}
renderMember={renderMember}
/>
)
})
const MAX_SUGGESTIONS = 8
/** Members whose name contains the query (case-insensitive), capped to the popup's length.
* @public */
export function filterMentionMembers(members: MentionMember[], query: string): MentionMember[] {
const q = query.toLowerCase()
return members.filter((m) => m.name.toLowerCase().includes(q)).slice(0, MAX_SUGGESTIONS)
}
// Deliberately NOT tldraw's open-menu registry: registering there mounts MenuClickCapture, which
// makes the canvas inert. The picker is an inline autocomplete, not a modal — the canvas must stay
// pannable beneath it — so track "open" ourselves.
const mentionPickerOpen = atom('isMentionPickerOpen', false)
/**
* Whether the \@-mention picker is currently showing. Host dismissal (Escape, outside-click) checks
* this so it can defer to the picker instead of tearing down the composer or thread beneath it.
* @public
*/
export function isMentionPickerOpen(): boolean {
return mentionPickerOpen.get()
}
/** @public */
export interface MentionSuggestionOptions {
/** Override a member row's content in the picker. Defaults to avatar + name (+ secondary). */
renderMember?(member: MentionMember): ReactNode
/**
* The tldraw editor the composer lives in. When provided, the popup re-anchors reactively as the
* canvas camera moves (the composer rides it) instead of polling every frame. Omit off-canvas.
*/
editor?: TldrawEditor | null
}
/**
* Build the TipTap `suggestion` config for the \@-picker. `getSuggestions(query)` is the host's
* resolver — the SDK owns neither the roster nor the filtering. The plugin runs outside React, so
* `render` mounts `MentionPopup` via a `ReactRenderer` and forwards navigation keys to it.
* @public
*/
export function createMentionSuggestion(
getSuggestions: (query: string) => MentionMember[] | Promise<MentionMember[]>,
options: MentionSuggestionOptions = {}
): Omit<SuggestionOptions<MentionMember, MentionNodeAttrs>, 'editor'> {
return {
char: '@',
items: ({ query }) => getSuggestions(query),
render: () => {
let renderer: ReactRenderer<MentionPopupHandle, MentionPopupProps> | null = null
let container: HTMLElement | null = null
let editorEl: HTMLElement | null = null
let stopCameraReaction: (() => void) | null = null
// The composer field's top-left in page space, plus the popup's screen width. Captured on a
// fresh read so camera moves can re-derive the popup's screen position from the page anchor
// (see reposition) rather than the field's DOM rect.
let anchorPage: { x: number; y: number } | null = null
let popupWidth = 0
const applyScreen = (left: number, top: number, width: number) => {
if (!container) return
container.style.left = `${left}px`
container.style.top = `${top + 4}px`
container.style.width = `${width}px`
}
// Fresh placement: read the field's real screen rect, position the popup flush under it (not
// the caret, matching its width), and remember the field's page-space anchor for reposition.
const place = () => {
if (!container || !editorEl || container.style.display === 'none') return
const field = editorEl.closest('.tlui-cmt-composer__field') ?? editorEl
const rect = field.getBoundingClientRect()
popupWidth = rect.width
anchorPage = options.editor?.screenToPage({ x: rect.left, y: rect.bottom }) ?? null
applyScreen(rect.left, rect.bottom, rect.width)
}
// Re-derived from the remembered page anchor — pure camera math, always current. Reading the field's
// DOM rect would lag a frame: the composer re-positions on a React commit, after the camera reaction.
const reposition = () => {
if (!anchorPage || !options.editor) return
const s = options.editor.pageToScreen(anchorPage)
applyScreen(s.x, s.y, popupWidth)
}
// The popup is `position: fixed`, but a canvas composer rides the camera, which moves it with no
// scroll/resize event to hook. Re-anchor from a tldraw reaction rather than polling every frame,
// plus on window scroll/resize for the off-canvas case.
const startFollowing = () => {
window.addEventListener('scroll', place, true)
window.addEventListener('resize', place)
if (options.editor) {
stopCameraReaction = react('anchor mention popup to camera', () => {
options.editor!.getCamera() // track the camera so this re-runs as it moves
reposition()
})
}
}
const stopFollowing = () => {
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
stopCameraReaction?.()
stopCameraReaction = null
anchorPage = null
}
// Dismiss the roster on Escape or blur, clearing the open flag so isMentionPickerOpen() stays
// accurate and the thread's own dismissal can take over. Typing re-shows it via onUpdate.
const hide = () => {
if (container) container.style.display = 'none'
mentionPickerOpen.set(false)
}
const popupProps = (
props: SuggestionProps<MentionMember, MentionNodeAttrs>
): MentionPopupProps => ({
items: props.items,
command: props.command,
renderMember: options.renderMember,
})
return {
onStart: (props) => {
renderer = new ReactRenderer(MentionPopup, {
props: popupProps(props),
editor: props.editor,
})
editorEl = props.editor.view.dom as HTMLElement
// The TipTap suggestion has no blur handling, so without this the picker would stay
// "open" (and the thread would defer its Escape to it) after focus moved away from the
// composer — leaving Escape a no-op and the roster stuck on screen.
editorEl.addEventListener('blur', hide)
container = document.createElement('div')
container.className = 'tlui-cmt-mention-popup'
container.appendChild(renderer.element)
// Mounted inside the tldraw container so the popup inherits the theme variables and the pass-through
// hooks can find the canvas. Outside one, fall back to the body — the picker still works.
;(editorEl.closest('.tl-container') ?? document.body).appendChild(container)
place()
startFollowing()
mentionPickerOpen.set(true)
},
onUpdate: (props) => {
renderer?.updateProps(popupProps(props))
// Typing after an Escape re-shows the roster.
if (container) container.style.display = ''
mentionPickerOpen.set(true)
place()
},
onKeyDown: (props) => {
// Once the roster is hidden, the suggestion stays active but this handler goes inert — keys pass
// through so a second Escape closes the composer. Typing re-shows the roster via onUpdate.
if (!isMentionPickerOpen()) return false
if (props.event.key === 'Escape') {
// Dismiss only the roster: hide it and stop the key so the composer/thread beneath
// doesn't also treat Escape as "abandon".
hide()
props.event.stopPropagation()
return true
}
if (props.event.key === 'Enter' || props.event.key === 'Tab') {
// Complete the highlighted member if there is one to complete; if the roster is empty
// there's nothing to pick, so cancel the picker and swallow the key — the composer
// beneath neither submits (Enter) nor moves focus / indents (Tab).
const completed = renderer?.ref?.onKeyDown(props) ?? false
if (!completed) {
hide()
props.event.stopPropagation()
}
return true
}
return renderer?.ref?.onKeyDown(props) ?? false
},
onExit: () => {
stopFollowing()
editorEl?.removeEventListener('blur', hide)
container?.remove()
renderer?.destroy()
renderer = null
container = null
mentionPickerOpen.set(false)
},
}
},
}
}

View File

@@ -0,0 +1,9 @@
/** @public */
export interface MentionProps {
name: string
}
/** An \@-mention pill for referencing a person. @public @react */
export function Mention({ name }: MentionProps) {
return <span className="tlui-cmt-mention">@{name}</span>
}

View File

@@ -0,0 +1,24 @@
{
"extends": "../../internal/config/tsconfig.base.json",
"include": [
"src"
],
"exclude": [
"node_modules",
"dist",
"**/*.css",
".tsbuild*"
],
"compilerOptions": {
"outDir": "./.tsbuild",
"rootDir": "src"
},
"references": [
{
"path": "../tldraw"
},
{
"path": "../utils"
}
]
}

View File

@@ -0,0 +1,6 @@
/// <reference types="vitest" />
import { mergeConfig } from 'vitest/config'
import baseConfig from '../../internal/config/vitest/node-preset'
// Mentions package uses the standard node preset with no overrides
export default mergeConfig(baseConfig, {})