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:
1
packages/mentions/LICENSE.md
Normal file
1
packages/mentions/LICENSE.md
Normal file
@@ -0,0 +1 @@
|
||||
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)
|
||||
16
packages/mentions/README.md
Normal file
16
packages/mentions/README.md
Normal 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.
|
||||
4
packages/mentions/api-extractor.json
Normal file
4
packages/mentions/api-extractor.json
Normal 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"
|
||||
}
|
||||
92
packages/mentions/api-report.api.md
Normal file
92
packages/mentions/api-report.api.md
Normal 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)
|
||||
|
||||
```
|
||||
109
packages/mentions/mentions.css
Normal file
109
packages/mentions/mentions.css
Normal 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);
|
||||
}
|
||||
67
packages/mentions/package.json
Normal file
67
packages/mentions/package.json
Normal 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:*"
|
||||
}
|
||||
}
|
||||
35
packages/mentions/src/avatar.tsx
Normal file
35
packages/mentions/src/avatar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
packages/mentions/src/comment-author.ts
Normal file
15
packages/mentions/src/comment-author.ts
Normal 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
|
||||
}
|
||||
25
packages/mentions/src/index.ts
Normal file
25
packages/mentions/src/index.ts
Normal 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
|
||||
)
|
||||
41
packages/mentions/src/mention-extension.test.ts
Normal file
41
packages/mentions/src/mention-extension.test.ts
Normal 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('<img')
|
||||
})
|
||||
})
|
||||
51
packages/mentions/src/mention-extension.ts
Normal file
51
packages/mentions/src/mention-extension.ts
Normal 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 } : {}),
|
||||
})
|
||||
}
|
||||
98
packages/mentions/src/mention-list.tsx
Normal file
98
packages/mentions/src/mention-list.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
18
packages/mentions/src/mention-suggestion.test.ts
Normal file
18
packages/mentions/src/mention-suggestion.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
260
packages/mentions/src/mention-suggestion.tsx
Normal file
260
packages/mentions/src/mention-suggestion.tsx
Normal 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)
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
9
packages/mentions/src/mention.tsx
Normal file
9
packages/mentions/src/mention.tsx
Normal 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>
|
||||
}
|
||||
24
packages/mentions/tsconfig.json
Normal file
24
packages/mentions/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/mentions/vitest.config.ts
Normal file
6
packages/mentions/vitest.config.ts
Normal 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, {})
|
||||
Reference in New Issue
Block a user