@tldraw/mentions 0.0.0-bootstrap → 5.3.0-canary.04044ed9e96d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,12 @@
1
1
  import { Editor } from 'tldraw';
2
+ import { ForwardRefExoticComponent } from 'react';
2
3
  import { JsonObject } from 'tldraw';
3
4
  import { JSX } from 'react/jsx-runtime';
4
5
  import { MentionNodeAttrs } from '@tiptap/extension-mention';
5
6
  import { MentionOptions } from '@tiptap/extension-mention';
6
7
  import { Node as Node_2 } from '@tiptap/core';
7
8
  import { ReactNode } from 'react';
9
+ import { RefAttributes } from 'react';
8
10
  import type { SuggestionOptions } from '@tiptap/suggestion';
9
11
 
10
12
  /** A person's avatar — their image if provided, otherwise a single-initial coloured circle.
@@ -44,10 +46,8 @@ export declare function createMentionExtension({ resolveName, suggestion }?: Men
44
46
 
45
47
  /**
46
48
  * Build the TipTap `suggestion` config for the \@-picker. `getSuggestions(query)` is the host's
47
- * resolver — it returns the members matching the query (sync or async); the SDK owns neither the
48
- * roster nor the filtering. The plugin runs outside React, so `render` mounts `MentionPopup` via a
49
- * `ReactRenderer`, forwards navigation keys through the popup's imperative handle, and lets it call
50
- * `command` to insert.
49
+ * resolver — the SDK owns neither the roster nor the filtering. The plugin runs outside React, so
50
+ * `render` mounts `MentionPopup` via a `ReactRenderer` and forwards navigation keys to it.
51
51
  * @public
52
52
  */
53
53
  export declare function createMentionSuggestion(getSuggestions: (query: string) => MentionMember[] | Promise<MentionMember[]>, options?: MentionSuggestionOptions): Omit<SuggestionOptions<MentionMember, MentionNodeAttrs>, 'editor'>;
@@ -84,7 +84,7 @@ export declare interface MentionExtensionOptions {
84
84
  * placement, and drives this with the host-resolved `members` and the highlighted `activeIndex`.
85
85
  * @public @react
86
86
  */
87
- export declare function MentionList({ members, activeIndex, onSelect, emptyLabel, renderMember }: MentionListProps): JSX.Element;
87
+ export declare const MentionList: ForwardRefExoticComponent<MentionListProps & RefAttributes<HTMLDivElement>>;
88
88
 
89
89
  /** @public */
90
90
  export declare interface MentionListProps {
package/dist-cjs/index.js CHANGED
@@ -35,7 +35,7 @@ var import_mention_extension = require("./mention-extension");
35
35
  var import_mention_suggestion = require("./mention-suggestion");
36
36
  (0, import_utils.registerTldrawLibraryVersion)(
37
37
  "@tldraw/mentions",
38
- "0.0.0-bootstrap",
38
+ "5.3.0-canary.04044ed9e96d",
39
39
  "cjs"
40
40
  );
41
41
  //# sourceMappingURL=index.js.map
@@ -22,6 +22,7 @@ __export(mention_list_exports, {
22
22
  });
23
23
  module.exports = __toCommonJS(mention_list_exports);
24
24
  var import_jsx_runtime = require("react/jsx-runtime");
25
+ var import_react = require("react");
25
26
  var import_tldraw = require("tldraw");
26
27
  var import_avatar = require("./avatar");
27
28
  function DefaultMemberRow({ member }) {
@@ -37,18 +38,12 @@ function DefaultMemberRow({ member }) {
37
38
  ] })
38
39
  ] });
39
40
  }
40
- function MentionList({
41
- members,
42
- activeIndex = 0,
43
- onSelect,
44
- emptyLabel,
45
- renderMember
46
- }) {
41
+ const MentionList = (0, import_react.forwardRef)(function MentionList2({ members, activeIndex = 0, onSelect, emptyLabel, renderMember }, ref) {
47
42
  const msg = (0, import_tldraw.useTranslation)();
48
43
  if (members.length === 0) {
49
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "tlui-cmt-mention-list tlui-cmt-mention-list--empty", children: emptyLabel ?? msg("comments.mention-no-matches") });
44
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { ref, className: "tlui-cmt-mention-list tlui-cmt-mention-list--empty", children: emptyLabel ?? msg("comments.mention-no-matches") });
50
45
  }
51
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "tlui-cmt-mention-list", role: "listbox", children: members.map((member, i) => {
46
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { ref, className: "tlui-cmt-mention-list", role: "listbox", children: members.map((member, i) => {
52
47
  const active = i === activeIndex;
53
48
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
54
49
  "button",
@@ -66,5 +61,5 @@ function MentionList({
66
61
  member.id
67
62
  );
68
63
  }) });
69
- }
64
+ });
70
65
  //# sourceMappingURL=mention-list.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/mention-list.tsx"],
4
- "sourcesContent": ["import { ReactNode } from 'react'\nimport { useTranslation } from 'tldraw'\nimport { Avatar } from './avatar'\nimport { CommentAuthor } from './comment-author'\n\n/** A person the composer's \\@-mention picker can offer: a {@link CommentAuthor} plus its id and\n * picker-only display fields. @public */\nexport interface MentionMember extends CommentAuthor {\n\tid: string\n\t/** A secondary line under the name \u2014 e.g. an email or handle. Omit for a single-line row. */\n\tsecondary?: string\n\t/** Marks the current user; shown as a \"(You)\" suffix after the name. */\n\tyou?: boolean\n\t/** Hosts may carry extra fields; a custom `renderMember` can read them. */\n\t[key: string]: unknown\n}\n\n/** @public */\nexport interface MentionListProps {\n\t/** Members to choose from \u2014 already resolved for the current query by the host. */\n\tmembers: MentionMember[]\n\t/** Index of the highlighted row, driven by the composer's keyboard navigation. */\n\tactiveIndex?: number\n\t/** Called when a member is chosen (click, or Enter on the active row). */\n\tonSelect?(member: MentionMember): void\n\t/** Shown when no member matches the query. Defaults to a translated \"No matches\". */\n\temptyLabel?: string\n\t/** Override a row's content (inside the selectable button). Defaults to avatar + name (+ secondary). */\n\trenderMember?(member: MentionMember): ReactNode\n}\n\n/** The default row content: avatar, name (with a \"(You)\" marker), and an optional secondary line. */\nfunction DefaultMemberRow({ member }: { member: MentionMember }) {\n\tconst msg = useTranslation()\n\treturn (\n\t\t<>\n\t\t\t<Avatar author={member} />\n\t\t\t<span className=\"tlui-cmt-mention-list__text\">\n\t\t\t\t<span className=\"tlui-cmt-mention-list__name\">\n\t\t\t\t\t{member.name}\n\t\t\t\t\t{member.you && (\n\t\t\t\t\t\t<span className=\"tlui-cmt-mention-list__you\">{`(${msg('comments.mention-you')})`}</span>\n\t\t\t\t\t)}\n\t\t\t\t</span>\n\t\t\t\t{member.secondary && (\n\t\t\t\t\t<span className=\"tlui-cmt-mention-list__secondary\">{member.secondary}</span>\n\t\t\t\t)}\n\t\t\t</span>\n\t\t</>\n\t)\n}\n\n/**\n * The \\@-mention member picker: a popover list of people to mention, shown as the user types after\n * `@`. Presentational \u2014 the composer's suggestion plugin owns resolution, keyboard navigation, and\n * placement, and drives this with the host-resolved `members` and the highlighted `activeIndex`.\n * @public @react\n */\nexport function MentionList({\n\tmembers,\n\tactiveIndex = 0,\n\tonSelect,\n\temptyLabel,\n\trenderMember,\n}: MentionListProps) {\n\tconst msg = useTranslation()\n\tif (members.length === 0) {\n\t\treturn (\n\t\t\t<div className=\"tlui-cmt-mention-list tlui-cmt-mention-list--empty\">\n\t\t\t\t{emptyLabel ?? msg('comments.mention-no-matches')}\n\t\t\t</div>\n\t\t)\n\t}\n\treturn (\n\t\t<div className=\"tlui-cmt-mention-list\" role=\"listbox\">\n\t\t\t{members.map((member, i) => {\n\t\t\t\tconst active = i === activeIndex\n\t\t\t\treturn (\n\t\t\t\t\t<button\n\t\t\t\t\t\tkey={member.id}\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\trole=\"option\"\n\t\t\t\t\t\taria-selected={active}\n\t\t\t\t\t\tclassName={\n\t\t\t\t\t\t\tactive\n\t\t\t\t\t\t\t\t? 'tlui-cmt-mention-list__item tlui-cmt-mention-list__item--active'\n\t\t\t\t\t\t\t\t: 'tlui-cmt-mention-list__item'\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Keep the composer focused on a row press: it's portaled outside the editor, so a\n\t\t\t\t\t\t// bare mousedown would blur the composer \u2014 which now dismisses the picker \u2014 before\n\t\t\t\t\t\t// the click could select. Selection runs on click; focus stays put.\n\t\t\t\t\t\tonMouseDown={(e) => e.preventDefault()}\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tif (onSelect) onSelect(member)\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{renderMember ? renderMember(member) : <DefaultMemberRow member={member} />}\n\t\t\t\t\t</button>\n\t\t\t\t)\n\t\t\t})}\n\t\t</div>\n\t)\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAmCE;AAlCF,oBAA+B;AAC/B,oBAAuB;AA8BvB,SAAS,iBAAiB,EAAE,OAAO,GAA8B;AAChE,QAAM,UAAM,8BAAe;AAC3B,SACC,4EACC;AAAA,gDAAC,wBAAO,QAAQ,QAAQ;AAAA,IACxB,6CAAC,UAAK,WAAU,+BACf;AAAA,mDAAC,UAAK,WAAU,+BACd;AAAA,eAAO;AAAA,QACP,OAAO,OACP,4CAAC,UAAK,WAAU,8BAA8B,cAAI,IAAI,sBAAsB,CAAC,KAAI;AAAA,SAEnF;AAAA,MACC,OAAO,aACP,4CAAC,UAAK,WAAU,oCAAoC,iBAAO,WAAU;AAAA,OAEvE;AAAA,KACD;AAEF;AAQO,SAAS,YAAY;AAAA,EAC3B;AAAA,EACA,cAAc;AAAA,EACd;AAAA,EACA;AAAA,EACA;AACD,GAAqB;AACpB,QAAM,UAAM,8BAAe;AAC3B,MAAI,QAAQ,WAAW,GAAG;AACzB,WACC,4CAAC,SAAI,WAAU,sDACb,wBAAc,IAAI,6BAA6B,GACjD;AAAA,EAEF;AACA,SACC,4CAAC,SAAI,WAAU,yBAAwB,MAAK,WAC1C,kBAAQ,IAAI,CAAC,QAAQ,MAAM;AAC3B,UAAM,SAAS,MAAM;AACrB,WACC;AAAA,MAAC;AAAA;AAAA,QAEA,MAAK;AAAA,QACL,MAAK;AAAA,QACL,iBAAe;AAAA,QACf,WACC,SACG,oEACA;AAAA,QAKJ,aAAa,CAAC,MAAM,EAAE,eAAe;AAAA,QACrC,SAAS,MAAM;AACd,cAAI,SAAU,UAAS,MAAM;AAAA,QAC9B;AAAA,QAEC,yBAAe,aAAa,MAAM,IAAI,4CAAC,oBAAiB,QAAgB;AAAA;AAAA,MAjBpE,OAAO;AAAA,IAkBb;AAAA,EAEF,CAAC,GACF;AAEF;",
6
- "names": []
4
+ "sourcesContent": ["import { ReactNode, forwardRef } from 'react'\nimport { useTranslation } from 'tldraw'\nimport { Avatar } from './avatar'\nimport { CommentAuthor } from './comment-author'\n\n/** A person the composer's \\@-mention picker can offer: a {@link CommentAuthor} plus its id and\n * picker-only display fields. @public */\nexport interface MentionMember extends CommentAuthor {\n\tid: string\n\t/** A secondary line under the name \u2014 e.g. an email or handle. Omit for a single-line row. */\n\tsecondary?: string\n\t/** Marks the current user; shown as a \"(You)\" suffix after the name. */\n\tyou?: boolean\n\t/** Hosts may carry extra fields; a custom `renderMember` can read them. */\n\t[key: string]: unknown\n}\n\n/** @public */\nexport interface MentionListProps {\n\t/** Members to choose from \u2014 already resolved for the current query by the host. */\n\tmembers: MentionMember[]\n\t/** Index of the highlighted row, driven by the composer's keyboard navigation. */\n\tactiveIndex?: number\n\t/** Called when a member is chosen (click, or Enter on the active row). */\n\tonSelect?(member: MentionMember): void\n\t/** Shown when no member matches the query. Defaults to a translated \"No matches\". */\n\temptyLabel?: string\n\t/** Override a row's content (inside the selectable button). Defaults to avatar + name (+ secondary). */\n\trenderMember?(member: MentionMember): ReactNode\n}\n\n/** The default row content: avatar, name (with a \"(You)\" marker), and an optional secondary line. */\nfunction DefaultMemberRow({ member }: { member: MentionMember }) {\n\tconst msg = useTranslation()\n\treturn (\n\t\t<>\n\t\t\t<Avatar author={member} />\n\t\t\t<span className=\"tlui-cmt-mention-list__text\">\n\t\t\t\t<span className=\"tlui-cmt-mention-list__name\">\n\t\t\t\t\t{member.name}\n\t\t\t\t\t{member.you && (\n\t\t\t\t\t\t<span className=\"tlui-cmt-mention-list__you\">{`(${msg('comments.mention-you')})`}</span>\n\t\t\t\t\t)}\n\t\t\t\t</span>\n\t\t\t\t{member.secondary && (\n\t\t\t\t\t<span className=\"tlui-cmt-mention-list__secondary\">{member.secondary}</span>\n\t\t\t\t)}\n\t\t\t</span>\n\t\t</>\n\t)\n}\n\n/**\n * The \\@-mention member picker: a popover list of people to mention, shown as the user types after\n * `@`. Presentational \u2014 the composer's suggestion plugin owns resolution, keyboard navigation, and\n * placement, and drives this with the host-resolved `members` and the highlighted `activeIndex`.\n * @public @react\n */\nexport const MentionList = forwardRef<HTMLDivElement, MentionListProps>(function MentionList(\n\t{ members, activeIndex = 0, onSelect, emptyLabel, renderMember },\n\tref\n) {\n\tconst msg = useTranslation()\n\tif (members.length === 0) {\n\t\treturn (\n\t\t\t<div ref={ref} className=\"tlui-cmt-mention-list tlui-cmt-mention-list--empty\">\n\t\t\t\t{emptyLabel ?? msg('comments.mention-no-matches')}\n\t\t\t</div>\n\t\t)\n\t}\n\treturn (\n\t\t<div ref={ref} className=\"tlui-cmt-mention-list\" role=\"listbox\">\n\t\t\t{members.map((member, i) => {\n\t\t\t\tconst active = i === activeIndex\n\t\t\t\treturn (\n\t\t\t\t\t<button\n\t\t\t\t\t\tkey={member.id}\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\trole=\"option\"\n\t\t\t\t\t\taria-selected={active}\n\t\t\t\t\t\tclassName={\n\t\t\t\t\t\t\tactive\n\t\t\t\t\t\t\t\t? 'tlui-cmt-mention-list__item tlui-cmt-mention-list__item--active'\n\t\t\t\t\t\t\t\t: 'tlui-cmt-mention-list__item'\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Keep the composer focused on a row press: it's portaled outside the editor, so a\n\t\t\t\t\t\t// bare mousedown would blur the composer \u2014 which now dismisses the picker \u2014 before\n\t\t\t\t\t\t// the click could select. Selection runs on click; focus stays put.\n\t\t\t\t\t\tonMouseDown={(e) => e.preventDefault()}\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tif (onSelect) onSelect(member)\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{renderMember ? renderMember(member) : <DefaultMemberRow member={member} />}\n\t\t\t\t\t</button>\n\t\t\t\t)\n\t\t\t})}\n\t\t</div>\n\t)\n})\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAmCE;AAnCF,mBAAsC;AACtC,oBAA+B;AAC/B,oBAAuB;AA8BvB,SAAS,iBAAiB,EAAE,OAAO,GAA8B;AAChE,QAAM,UAAM,8BAAe;AAC3B,SACC,4EACC;AAAA,gDAAC,wBAAO,QAAQ,QAAQ;AAAA,IACxB,6CAAC,UAAK,WAAU,+BACf;AAAA,mDAAC,UAAK,WAAU,+BACd;AAAA,eAAO;AAAA,QACP,OAAO,OACP,4CAAC,UAAK,WAAU,8BAA8B,cAAI,IAAI,sBAAsB,CAAC,KAAI;AAAA,SAEnF;AAAA,MACC,OAAO,aACP,4CAAC,UAAK,WAAU,oCAAoC,iBAAO,WAAU;AAAA,OAEvE;AAAA,KACD;AAEF;AAQO,MAAM,kBAAc,yBAA6C,SAASA,aAChF,EAAE,SAAS,cAAc,GAAG,UAAU,YAAY,aAAa,GAC/D,KACC;AACD,QAAM,UAAM,8BAAe;AAC3B,MAAI,QAAQ,WAAW,GAAG;AACzB,WACC,4CAAC,SAAI,KAAU,WAAU,sDACvB,wBAAc,IAAI,6BAA6B,GACjD;AAAA,EAEF;AACA,SACC,4CAAC,SAAI,KAAU,WAAU,yBAAwB,MAAK,WACpD,kBAAQ,IAAI,CAAC,QAAQ,MAAM;AAC3B,UAAM,SAAS,MAAM;AACrB,WACC;AAAA,MAAC;AAAA;AAAA,QAEA,MAAK;AAAA,QACL,MAAK;AAAA,QACL,iBAAe;AAAA,QACf,WACC,SACG,oEACA;AAAA,QAKJ,aAAa,CAAC,MAAM,EAAE,eAAe;AAAA,QACrC,SAAS,MAAM;AACd,cAAI,SAAU,UAAS,MAAM;AAAA,QAC9B;AAAA,QAEC,yBAAe,aAAa,MAAM,IAAI,4CAAC,oBAAiB,QAAgB;AAAA;AAAA,MAjBpE,OAAO;AAAA,IAkBb;AAAA,EAEF,CAAC,GACF;AAEF,CAAC;",
6
+ "names": ["MentionList"]
7
7
  }
@@ -30,6 +30,8 @@ var import_tldraw = require("tldraw");
30
30
  var import_mention_list = require("./mention-list");
31
31
  const MentionPopup = (0, import_react2.forwardRef)(function MentionPopup2({ items, command, renderMember }, ref) {
32
32
  const [activeIndex, setActiveIndex] = (0, import_react2.useState)(0);
33
+ const listRef = (0, import_react2.useRef)(null);
34
+ (0, import_tldraw.usePassThroughWheelEvents)(listRef);
33
35
  const [prevItems, setPrevItems] = (0, import_react2.useState)(items);
34
36
  if (items !== prevItems) {
35
37
  setPrevItems(items);
@@ -59,6 +61,7 @@ const MentionPopup = (0, import_react2.forwardRef)(function MentionPopup2({ item
59
61
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
60
62
  import_mention_list.MentionList,
61
63
  {
64
+ ref: listRef,
62
65
  members: items,
63
66
  activeIndex,
64
67
  onSelect: select,
@@ -83,7 +86,6 @@ function createMentionSuggestion(getSuggestions, options = {}) {
83
86
  let renderer = null;
84
87
  let container = null;
85
88
  let editorEl = null;
86
- let canvasEl = null;
87
89
  let stopCameraReaction = null;
88
90
  let anchorPage = null;
89
91
  let popupWidth = 0;
@@ -127,15 +129,6 @@ function createMentionSuggestion(getSuggestions, options = {}) {
127
129
  if (container) container.style.display = "none";
128
130
  mentionPickerOpen.set(false);
129
131
  };
130
- const onWheel = (e) => {
131
- if (e.isSpecialRedispatchedEvent || !canvasEl) return;
132
- const list = container?.querySelector(".tlui-cmt-mention-list");
133
- if (list && list.scrollHeight > list.clientHeight) return;
134
- e.preventDefault();
135
- const redispatched = new WheelEvent("wheel", e);
136
- redispatched.isSpecialRedispatchedEvent = true;
137
- canvasEl.dispatchEvent(redispatched);
138
- };
139
132
  return {
140
133
  onStart: (props) => {
141
134
  renderer = new import_react.ReactRenderer(MentionPopup, {
@@ -151,10 +144,7 @@ function createMentionSuggestion(getSuggestions, options = {}) {
151
144
  container = document.createElement("div");
152
145
  container.className = "tlui-cmt-mention-popup";
153
146
  container.appendChild(renderer.element);
154
- const themed = editorEl.closest(".tl-container");
155
- (themed ?? document.body).appendChild(container);
156
- canvasEl = themed?.querySelector(".tl-canvas") ?? null;
157
- container.addEventListener("wheel", onWheel, { passive: false });
147
+ (editorEl.closest(".tl-container") ?? document.body).appendChild(container);
158
148
  place();
159
149
  startFollowing();
160
150
  mentionPickerOpen.set(true);
@@ -191,12 +181,10 @@ function createMentionSuggestion(getSuggestions, options = {}) {
191
181
  onExit: () => {
192
182
  stopFollowing();
193
183
  if (editorEl) editorEl.removeEventListener("blur", hide);
194
- if (container) container.removeEventListener("wheel", onWheel);
195
184
  if (container) container.remove();
196
185
  if (renderer) renderer.destroy();
197
186
  renderer = null;
198
187
  container = null;
199
- canvasEl = null;
200
188
  mentionPickerOpen.set(false);
201
189
  }
202
190
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/mention-suggestion.tsx"],
4
- "sourcesContent": ["import type { MentionNodeAttrs } from '@tiptap/extension-mention'\nimport { ReactRenderer } from '@tiptap/react'\nimport type { SuggestionKeyDownProps, SuggestionOptions } from '@tiptap/suggestion'\nimport { type ReactNode, forwardRef, useImperativeHandle, useState } from 'react'\nimport { type Editor as TldrawEditor, atom, react } from 'tldraw'\nimport { MentionList, MentionMember } from './mention-list'\n\n/** The handle the suggestion plugin drives \u2014 it forwards navigation keys into the popup. */\nexport interface MentionPopupHandle {\n\tonKeyDown(props: SuggestionKeyDownProps): boolean\n}\n\ninterface MentionPopupProps {\n\titems: MentionMember[]\n\tcommand(attrs: MentionNodeAttrs): void\n\trenderMember?(member: MentionMember): ReactNode\n}\n\n/** The live @-picker popup: owns the highlighted index and keyboard, renders the presentational list. */\nconst MentionPopup = forwardRef<MentionPopupHandle, MentionPopupProps>(function MentionPopup(\n\t{ items, command, renderMember },\n\tref\n) {\n\tconst [activeIndex, setActiveIndex] = useState(0)\n\t// A new query yields new items; reset the highlight to the top during render \u2014 not in an effect,\n\t// which would leave a frame where `activeIndex` still points past a shrunk list and Enter selects\n\t// its (now out-of-range, undefined) item, swallowing the key without inserting a mention.\n\tconst [prevItems, setPrevItems] = useState(items)\n\tif (items !== prevItems) {\n\t\tsetPrevItems(items)\n\t\tsetActiveIndex(0)\n\t}\n\n\tconst select = (member: MentionMember | undefined) => {\n\t\tif (member) command({ id: member.id, label: member.name })\n\t}\n\n\tuseImperativeHandle(ref, () => ({\n\t\tonKeyDown: ({ event }) => {\n\t\t\tif (items.length === 0) return false\n\t\t\tif (event.key === 'ArrowUp') {\n\t\t\t\tsetActiveIndex((i) => (i + items.length - 1) % items.length)\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tif (event.key === 'ArrowDown') {\n\t\t\t\tsetActiveIndex((i) => (i + 1) % items.length)\n\t\t\t\treturn true\n\t\t\t}\n\t\t\t// Enter and Tab both complete the highlighted member (falling back to the top match so a\n\t\t\t// stale index never selects `undefined`). The empty-roster case is handled a level up, in\n\t\t\t// the suggestion's onKeyDown, which cancels the picker.\n\t\t\tif (event.key === 'Enter' || event.key === 'Tab') {\n\t\t\t\tselect(items[activeIndex] ?? items[0])\n\t\t\t\treturn true\n\t\t\t}\n\t\t\treturn false\n\t\t},\n\t}))\n\n\treturn (\n\t\t<MentionList\n\t\t\tmembers={items}\n\t\t\tactiveIndex={activeIndex}\n\t\t\tonSelect={select}\n\t\t\trenderMember={renderMember}\n\t\t/>\n\t)\n})\n\nconst MAX_SUGGESTIONS = 8\n\n/** Members whose name contains the query (case-insensitive), capped to the popup's length.\n * @public */\nexport function filterMentionMembers(members: MentionMember[], query: string): MentionMember[] {\n\tconst q = query.toLowerCase()\n\treturn members.filter((m) => m.name.toLowerCase().includes(q)).slice(0, MAX_SUGGESTIONS)\n}\n\n// A reactive flag for whether the @-picker is showing. Deliberately NOT tldraw's open-menu registry:\n// registering there mounts MenuClickCapture, which covers the canvas with a click-capture overlay to\n// make it inert while a menu is open. But the picker is an inline autocomplete, not a modal \u2014 the\n// canvas must stay pannable/zoomable beneath it \u2014 so we track \"open\" ourselves instead.\nconst mentionPickerOpen = atom('isMentionPickerOpen', false)\n\n/**\n * Whether the \\@-mention picker is currently showing. Host dismissal (Escape, outside-click) checks\n * this so it can defer to the picker instead of tearing down the composer or thread beneath it.\n * @public\n */\nexport function isMentionPickerOpen(): boolean {\n\treturn mentionPickerOpen.get()\n}\n\n/** @public */\nexport interface MentionSuggestionOptions {\n\t/** Override a member row's content in the picker. Defaults to avatar + name (+ secondary). */\n\trenderMember?(member: MentionMember): ReactNode\n\t/**\n\t * The tldraw editor the composer lives in. When provided, the popup re-anchors reactively as the\n\t * canvas camera moves (the composer rides it) instead of polling every frame. Omit off-canvas.\n\t */\n\teditor?: TldrawEditor | null\n}\n\n/**\n * Build the TipTap `suggestion` config for the \\@-picker. `getSuggestions(query)` is the host's\n * resolver \u2014 it returns the members matching the query (sync or async); the SDK owns neither the\n * roster nor the filtering. The plugin runs outside React, so `render` mounts `MentionPopup` via a\n * `ReactRenderer`, forwards navigation keys through the popup's imperative handle, and lets it call\n * `command` to insert.\n * @public\n */\nexport function createMentionSuggestion(\n\tgetSuggestions: (query: string) => MentionMember[] | Promise<MentionMember[]>,\n\toptions: MentionSuggestionOptions = {}\n): Omit<SuggestionOptions<MentionMember, MentionNodeAttrs>, 'editor'> {\n\treturn {\n\t\tchar: '@',\n\t\titems: ({ query }) => getSuggestions(query),\n\t\trender: () => {\n\t\t\tlet renderer: ReactRenderer<MentionPopupHandle, MentionPopupProps> | null = null\n\t\t\tlet container: HTMLElement | null = null\n\t\t\tlet editorEl: HTMLElement | null = null\n\t\t\tlet canvasEl: Element | null = null\n\t\t\tlet stopCameraReaction: (() => void) | null = null\n\t\t\t// The composer field's top-left in page space, plus the popup's screen width. Captured on a\n\t\t\t// fresh read so camera moves can re-derive the popup's screen position from the page anchor\n\t\t\t// (see reposition) rather than the field's DOM rect.\n\t\t\tlet anchorPage: { x: number; y: number } | null = null\n\t\t\tlet popupWidth = 0\n\n\t\t\tconst applyScreen = (left: number, top: number, width: number) => {\n\t\t\t\tif (!container) return\n\t\t\t\tcontainer.style.left = `${left}px`\n\t\t\t\tcontainer.style.top = `${top + 4}px`\n\t\t\t\tcontainer.style.width = `${width}px`\n\t\t\t}\n\n\t\t\t// Fresh placement: read the field's real screen rect, position the popup flush under it (not\n\t\t\t// the caret, matching its width), and remember the field's page-space anchor for reposition.\n\t\t\tconst place = () => {\n\t\t\t\tif (!container || !editorEl || container.style.display === 'none') return\n\t\t\t\tconst field = editorEl.closest('.tlui-cmt-composer__field') ?? editorEl\n\t\t\t\tconst rect = field.getBoundingClientRect()\n\t\t\t\tpopupWidth = rect.width\n\t\t\t\tanchorPage = options.editor?.screenToPage({ x: rect.left, y: rect.bottom }) ?? null\n\t\t\t\tapplyScreen(rect.left, rect.bottom, rect.width)\n\t\t\t}\n\n\t\t\t// Re-derive the popup's screen position from the remembered page anchor \u2014 pure camera math,\n\t\t\t// always current. Reading the field's DOM rect here instead would lag by a frame: the canvas\n\t\t\t// composer re-positions itself on a React commit (a `useValue(pageToViewport(...))`), which\n\t\t\t// lands after a camera reaction runs, so the rect is still last frame's during a pan.\n\t\t\tconst reposition = () => {\n\t\t\t\tif (!anchorPage || !options.editor) return\n\t\t\t\tconst s = options.editor.pageToScreen(anchorPage)\n\t\t\t\tapplyScreen(s.x, s.y, popupWidth)\n\t\t\t}\n\n\t\t\t// The popup is `position: fixed`, but its anchor \u2014 a canvas composer \u2014 rides the camera:\n\t\t\t// panning or zooming moves the composer with no scroll/resize event to hook. Re-anchor when\n\t\t\t// the camera actually changes (via a tldraw reaction) rather than polling every frame, plus\n\t\t\t// on window scroll/resize for the off-canvas case (which re-read the field directly).\n\t\t\tconst startFollowing = () => {\n\t\t\t\twindow.addEventListener('scroll', place, true)\n\t\t\t\twindow.addEventListener('resize', place)\n\t\t\t\tif (options.editor) {\n\t\t\t\t\tstopCameraReaction = react('anchor mention popup to camera', () => {\n\t\t\t\t\t\toptions.editor!.getCamera() // track the camera so this re-runs as it moves\n\t\t\t\t\t\treposition()\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst stopFollowing = () => {\n\t\t\t\twindow.removeEventListener('scroll', place, true)\n\t\t\t\twindow.removeEventListener('resize', place)\n\t\t\t\tstopCameraReaction?.()\n\t\t\t\tstopCameraReaction = null\n\t\t\t\tanchorPage = null\n\t\t\t}\n\n\t\t\t// Dismiss the roster \u2014 on Escape, or when the composer loses focus \u2014 by hiding it and\n\t\t\t// clearing the open flag, so isMentionPickerOpen() stays accurate and the thread's own\n\t\t\t// Escape/outside-click dismissal can take over. The TipTap suggestion stays active, so typing\n\t\t\t// re-shows the roster via onUpdate.\n\t\t\tconst hide = () => {\n\t\t\t\tif (container) container.style.display = 'none'\n\t\t\t\tmentionPickerOpen.set(false)\n\t\t\t}\n\n\t\t\t// Wheel/panning over the popup drives the canvas beneath it, so scrolling to pan or zoom\n\t\t\t// isn't swallowed by the roster \u2014 the same passthrough the rest of the comments UI gets from\n\t\t\t// `usePassThroughWheelEvents`. The list still scrolls itself when the roster overflows (we\n\t\t\t// only redispatch when it can't). Done imperatively because the popup lives outside React.\n\t\t\tconst onWheel = (e: WheelEvent) => {\n\t\t\t\tif ((e as any).isSpecialRedispatchedEvent || !canvasEl) return\n\t\t\t\tconst list = container?.querySelector('.tlui-cmt-mention-list')\n\t\t\t\tif (list && list.scrollHeight > list.clientHeight) return\n\t\t\t\te.preventDefault()\n\t\t\t\tconst redispatched = new WheelEvent('wheel', e)\n\t\t\t\t;(redispatched as any).isSpecialRedispatchedEvent = true\n\t\t\t\tcanvasEl.dispatchEvent(redispatched)\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tonStart: (props) => {\n\t\t\t\t\trenderer = new ReactRenderer(MentionPopup, {\n\t\t\t\t\t\tprops: {\n\t\t\t\t\t\t\titems: props.items,\n\t\t\t\t\t\t\tcommand: props.command,\n\t\t\t\t\t\t\trenderMember: options.renderMember,\n\t\t\t\t\t\t},\n\t\t\t\t\t\teditor: props.editor,\n\t\t\t\t\t})\n\t\t\t\t\teditorEl = props.editor.view.dom as HTMLElement\n\t\t\t\t\t// The TipTap suggestion has no blur handling, so without this the picker would stay\n\t\t\t\t\t// \"open\" (and the thread would defer its Escape to it) after focus moved away from the\n\t\t\t\t\t// composer \u2014 leaving Escape a no-op and the roster stuck on screen.\n\t\t\t\t\teditorEl.addEventListener('blur', hide)\n\t\t\t\t\tcontainer = document.createElement('div')\n\t\t\t\t\tcontainer.className = 'tlui-cmt-mention-popup'\n\t\t\t\t\tcontainer.appendChild(renderer.element)\n\t\t\t\t\t// Mount inside the tldraw container so the popup inherits the theme variables\n\t\t\t\t\t// (--tl-color-*); portaling to document.body would strip them and lose the panel.\n\t\t\t\t\tconst themed = editorEl.closest('.tl-container')\n\t\t\t\t\t;(themed ?? document.body).appendChild(container)\n\t\t\t\t\tcanvasEl = themed?.querySelector('.tl-canvas') ?? null\n\t\t\t\t\tcontainer.addEventListener('wheel', onWheel, { passive: false })\n\t\t\t\t\tplace()\n\t\t\t\t\tstartFollowing()\n\t\t\t\t\tmentionPickerOpen.set(true)\n\t\t\t\t},\n\t\t\t\tonUpdate: (props) => {\n\t\t\t\t\tif (renderer)\n\t\t\t\t\t\trenderer.updateProps({\n\t\t\t\t\t\t\titems: props.items,\n\t\t\t\t\t\t\tcommand: props.command,\n\t\t\t\t\t\t\trenderMember: options.renderMember,\n\t\t\t\t\t\t})\n\t\t\t\t\t// Typing after an Escape re-shows the roster.\n\t\t\t\t\tif (container) container.style.display = ''\n\t\t\t\t\tmentionPickerOpen.set(true)\n\t\t\t\t\tplace()\n\t\t\t\t},\n\t\t\t\tonKeyDown: (props) => {\n\t\t\t\t\t// Once the roster is hidden (a prior Escape), the TipTap suggestion is still active but\n\t\t\t\t\t// this handler goes inert \u2014 every key passes through to the composer/thread (so a second\n\t\t\t\t\t// Escape closes them, and Arrow/Enter don't select from an invisible list). Typing\n\t\t\t\t\t// re-shows the roster via onUpdate.\n\t\t\t\t\tif (!isMentionPickerOpen()) return false\n\t\t\t\t\tif (props.event.key === 'Escape') {\n\t\t\t\t\t\t// Dismiss only the roster: hide it and stop the key so the composer/thread beneath\n\t\t\t\t\t\t// doesn't also treat Escape as \"abandon\".\n\t\t\t\t\t\thide()\n\t\t\t\t\t\tprops.event.stopPropagation()\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\t\t\t\t\tif (props.event.key === 'Enter' || props.event.key === 'Tab') {\n\t\t\t\t\t\t// Complete the highlighted member if there is one to complete; if the roster is empty\n\t\t\t\t\t\t// there's nothing to pick, so cancel the picker and swallow the key \u2014 the composer\n\t\t\t\t\t\t// beneath neither submits (Enter) nor moves focus / indents (Tab).\n\t\t\t\t\t\tconst completed = renderer?.ref?.onKeyDown(props) ?? false\n\t\t\t\t\t\tif (!completed) {\n\t\t\t\t\t\t\thide()\n\t\t\t\t\t\t\tprops.event.stopPropagation()\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\t\t\t\t\tif (renderer && renderer.ref) return renderer.ref.onKeyDown(props)\n\t\t\t\t\treturn false\n\t\t\t\t},\n\t\t\t\tonExit: () => {\n\t\t\t\t\tstopFollowing()\n\t\t\t\t\tif (editorEl) editorEl.removeEventListener('blur', hide)\n\t\t\t\t\tif (container) container.removeEventListener('wheel', onWheel)\n\t\t\t\t\tif (container) container.remove()\n\t\t\t\t\tif (renderer) renderer.destroy()\n\t\t\t\t\trenderer = null\n\t\t\t\t\tcontainer = null\n\t\t\t\t\tcanvasEl = null\n\t\t\t\t\tmentionPickerOpen.set(false)\n\t\t\t\t},\n\t\t\t}\n\t\t},\n\t}\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { MentionNodeAttrs } from '@tiptap/extension-mention'\nimport { ReactRenderer } from '@tiptap/react'\nimport type { SuggestionKeyDownProps, SuggestionOptions } from '@tiptap/suggestion'\nimport { type ReactNode, forwardRef, useImperativeHandle, useRef, useState } from 'react'\nimport { type Editor as TldrawEditor, atom, react, usePassThroughWheelEvents } from 'tldraw'\nimport { MentionList, MentionMember } from './mention-list'\n\n/** The handle the suggestion plugin drives \u2014 it forwards navigation keys into the popup. */\nexport interface MentionPopupHandle {\n\tonKeyDown(props: SuggestionKeyDownProps): boolean\n}\n\ninterface MentionPopupProps {\n\titems: MentionMember[]\n\tcommand(attrs: MentionNodeAttrs): void\n\trenderMember?(member: MentionMember): ReactNode\n}\n\n/** The live @-picker popup: owns the highlighted index and keyboard, renders the presentational list. */\nconst MentionPopup = forwardRef<MentionPopupHandle, MentionPopupProps>(function MentionPopup(\n\t{ items, command, renderMember },\n\tref\n) {\n\tconst [activeIndex, setActiveIndex] = useState(0)\n\t// A wheel over the popup drives the canvas beneath it, the same pass-through every tldraw panel gets;\n\t// the hook leaves the list alone while it scrolls its own overflow. The suggestion plugin builds the\n\t// popup imperatively, but `ReactRenderer` portals this into the composer's tree, so context reaches it.\n\tconst listRef = useRef<HTMLDivElement>(null)\n\tusePassThroughWheelEvents(listRef)\n\t// A new query yields new items; reset the highlight to the top during render \u2014 not in an effect,\n\t// which would leave a frame where `activeIndex` still points past a shrunk list and Enter selects\n\t// its (now out-of-range, undefined) item, swallowing the key without inserting a mention.\n\tconst [prevItems, setPrevItems] = useState(items)\n\tif (items !== prevItems) {\n\t\tsetPrevItems(items)\n\t\tsetActiveIndex(0)\n\t}\n\n\tconst select = (member: MentionMember | undefined) => {\n\t\tif (member) command({ id: member.id, label: member.name })\n\t}\n\n\tuseImperativeHandle(ref, () => ({\n\t\tonKeyDown: ({ event }) => {\n\t\t\tif (items.length === 0) return false\n\t\t\tif (event.key === 'ArrowUp') {\n\t\t\t\tsetActiveIndex((i) => (i + items.length - 1) % items.length)\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tif (event.key === 'ArrowDown') {\n\t\t\t\tsetActiveIndex((i) => (i + 1) % items.length)\n\t\t\t\treturn true\n\t\t\t}\n\t\t\t// Enter and Tab both complete the highlighted member (falling back to the top match so a\n\t\t\t// stale index never selects `undefined`). The empty-roster case is handled a level up, in\n\t\t\t// the suggestion's onKeyDown, which cancels the picker.\n\t\t\tif (event.key === 'Enter' || event.key === 'Tab') {\n\t\t\t\tselect(items[activeIndex] ?? items[0])\n\t\t\t\treturn true\n\t\t\t}\n\t\t\treturn false\n\t\t},\n\t}))\n\n\treturn (\n\t\t<MentionList\n\t\t\tref={listRef}\n\t\t\tmembers={items}\n\t\t\tactiveIndex={activeIndex}\n\t\t\tonSelect={select}\n\t\t\trenderMember={renderMember}\n\t\t/>\n\t)\n})\n\nconst MAX_SUGGESTIONS = 8\n\n/** Members whose name contains the query (case-insensitive), capped to the popup's length.\n * @public */\nexport function filterMentionMembers(members: MentionMember[], query: string): MentionMember[] {\n\tconst q = query.toLowerCase()\n\treturn members.filter((m) => m.name.toLowerCase().includes(q)).slice(0, MAX_SUGGESTIONS)\n}\n\n// Deliberately NOT tldraw's open-menu registry: registering there mounts MenuClickCapture, which\n// makes the canvas inert. The picker is an inline autocomplete, not a modal \u2014 the canvas must stay\n// pannable beneath it \u2014 so track \"open\" ourselves.\nconst mentionPickerOpen = atom('isMentionPickerOpen', false)\n\n/**\n * Whether the \\@-mention picker is currently showing. Host dismissal (Escape, outside-click) checks\n * this so it can defer to the picker instead of tearing down the composer or thread beneath it.\n * @public\n */\nexport function isMentionPickerOpen(): boolean {\n\treturn mentionPickerOpen.get()\n}\n\n/** @public */\nexport interface MentionSuggestionOptions {\n\t/** Override a member row's content in the picker. Defaults to avatar + name (+ secondary). */\n\trenderMember?(member: MentionMember): ReactNode\n\t/**\n\t * The tldraw editor the composer lives in. When provided, the popup re-anchors reactively as the\n\t * canvas camera moves (the composer rides it) instead of polling every frame. Omit off-canvas.\n\t */\n\teditor?: TldrawEditor | null\n}\n\n/**\n * Build the TipTap `suggestion` config for the \\@-picker. `getSuggestions(query)` is the host's\n * resolver \u2014 the SDK owns neither the roster nor the filtering. The plugin runs outside React, so\n * `render` mounts `MentionPopup` via a `ReactRenderer` and forwards navigation keys to it.\n * @public\n */\nexport function createMentionSuggestion(\n\tgetSuggestions: (query: string) => MentionMember[] | Promise<MentionMember[]>,\n\toptions: MentionSuggestionOptions = {}\n): Omit<SuggestionOptions<MentionMember, MentionNodeAttrs>, 'editor'> {\n\treturn {\n\t\tchar: '@',\n\t\titems: ({ query }) => getSuggestions(query),\n\t\trender: () => {\n\t\t\tlet renderer: ReactRenderer<MentionPopupHandle, MentionPopupProps> | null = null\n\t\t\tlet container: HTMLElement | null = null\n\t\t\tlet editorEl: HTMLElement | null = null\n\t\t\tlet stopCameraReaction: (() => void) | null = null\n\t\t\t// The composer field's top-left in page space, plus the popup's screen width. Captured on a\n\t\t\t// fresh read so camera moves can re-derive the popup's screen position from the page anchor\n\t\t\t// (see reposition) rather than the field's DOM rect.\n\t\t\tlet anchorPage: { x: number; y: number } | null = null\n\t\t\tlet popupWidth = 0\n\n\t\t\tconst applyScreen = (left: number, top: number, width: number) => {\n\t\t\t\tif (!container) return\n\t\t\t\tcontainer.style.left = `${left}px`\n\t\t\t\tcontainer.style.top = `${top + 4}px`\n\t\t\t\tcontainer.style.width = `${width}px`\n\t\t\t}\n\n\t\t\t// Fresh placement: read the field's real screen rect, position the popup flush under it (not\n\t\t\t// the caret, matching its width), and remember the field's page-space anchor for reposition.\n\t\t\tconst place = () => {\n\t\t\t\tif (!container || !editorEl || container.style.display === 'none') return\n\t\t\t\tconst field = editorEl.closest('.tlui-cmt-composer__field') ?? editorEl\n\t\t\t\tconst rect = field.getBoundingClientRect()\n\t\t\t\tpopupWidth = rect.width\n\t\t\t\tanchorPage = options.editor?.screenToPage({ x: rect.left, y: rect.bottom }) ?? null\n\t\t\t\tapplyScreen(rect.left, rect.bottom, rect.width)\n\t\t\t}\n\n\t\t\t// Re-derived from the remembered page anchor \u2014 pure camera math, always current. Reading the field's\n\t\t\t// DOM rect would lag a frame: the composer re-positions on a React commit, after the camera reaction.\n\t\t\tconst reposition = () => {\n\t\t\t\tif (!anchorPage || !options.editor) return\n\t\t\t\tconst s = options.editor.pageToScreen(anchorPage)\n\t\t\t\tapplyScreen(s.x, s.y, popupWidth)\n\t\t\t}\n\n\t\t\t// The popup is `position: fixed`, but a canvas composer rides the camera, which moves it with no\n\t\t\t// scroll/resize event to hook. Re-anchor from a tldraw reaction rather than polling every frame,\n\t\t\t// plus on window scroll/resize for the off-canvas case.\n\t\t\tconst startFollowing = () => {\n\t\t\t\twindow.addEventListener('scroll', place, true)\n\t\t\t\twindow.addEventListener('resize', place)\n\t\t\t\tif (options.editor) {\n\t\t\t\t\tstopCameraReaction = react('anchor mention popup to camera', () => {\n\t\t\t\t\t\toptions.editor!.getCamera() // track the camera so this re-runs as it moves\n\t\t\t\t\t\treposition()\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst stopFollowing = () => {\n\t\t\t\twindow.removeEventListener('scroll', place, true)\n\t\t\t\twindow.removeEventListener('resize', place)\n\t\t\t\tstopCameraReaction?.()\n\t\t\t\tstopCameraReaction = null\n\t\t\t\tanchorPage = null\n\t\t\t}\n\n\t\t\t// Dismiss the roster on Escape or blur, clearing the open flag so isMentionPickerOpen() stays\n\t\t\t// accurate and the thread's own dismissal can take over. Typing re-shows it via onUpdate.\n\t\t\tconst hide = () => {\n\t\t\t\tif (container) container.style.display = 'none'\n\t\t\t\tmentionPickerOpen.set(false)\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tonStart: (props) => {\n\t\t\t\t\trenderer = new ReactRenderer(MentionPopup, {\n\t\t\t\t\t\tprops: {\n\t\t\t\t\t\t\titems: props.items,\n\t\t\t\t\t\t\tcommand: props.command,\n\t\t\t\t\t\t\trenderMember: options.renderMember,\n\t\t\t\t\t\t},\n\t\t\t\t\t\teditor: props.editor,\n\t\t\t\t\t})\n\t\t\t\t\teditorEl = props.editor.view.dom as HTMLElement\n\t\t\t\t\t// The TipTap suggestion has no blur handling, so without this the picker would stay\n\t\t\t\t\t// \"open\" (and the thread would defer its Escape to it) after focus moved away from the\n\t\t\t\t\t// composer \u2014 leaving Escape a no-op and the roster stuck on screen.\n\t\t\t\t\teditorEl.addEventListener('blur', hide)\n\t\t\t\t\tcontainer = document.createElement('div')\n\t\t\t\t\tcontainer.className = 'tlui-cmt-mention-popup'\n\t\t\t\t\tcontainer.appendChild(renderer.element)\n\t\t\t\t\t// Mounted inside the tldraw container so the popup inherits the theme variables and the pass-through\n\t\t\t\t\t// hooks can find the canvas. Outside one, fall back to the body \u2014 the picker still works.\n\t\t\t\t\t;(editorEl.closest('.tl-container') ?? document.body).appendChild(container)\n\t\t\t\t\tplace()\n\t\t\t\t\tstartFollowing()\n\t\t\t\t\tmentionPickerOpen.set(true)\n\t\t\t\t},\n\t\t\t\tonUpdate: (props) => {\n\t\t\t\t\tif (renderer)\n\t\t\t\t\t\trenderer.updateProps({\n\t\t\t\t\t\t\titems: props.items,\n\t\t\t\t\t\t\tcommand: props.command,\n\t\t\t\t\t\t\trenderMember: options.renderMember,\n\t\t\t\t\t\t})\n\t\t\t\t\t// Typing after an Escape re-shows the roster.\n\t\t\t\t\tif (container) container.style.display = ''\n\t\t\t\t\tmentionPickerOpen.set(true)\n\t\t\t\t\tplace()\n\t\t\t\t},\n\t\t\t\tonKeyDown: (props) => {\n\t\t\t\t\t// Once the roster is hidden, the suggestion stays active but this handler goes inert \u2014 keys pass\n\t\t\t\t\t// through so a second Escape closes the composer. Typing re-shows the roster via onUpdate.\n\t\t\t\t\tif (!isMentionPickerOpen()) return false\n\t\t\t\t\tif (props.event.key === 'Escape') {\n\t\t\t\t\t\t// Dismiss only the roster: hide it and stop the key so the composer/thread beneath\n\t\t\t\t\t\t// doesn't also treat Escape as \"abandon\".\n\t\t\t\t\t\thide()\n\t\t\t\t\t\tprops.event.stopPropagation()\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\t\t\t\t\tif (props.event.key === 'Enter' || props.event.key === 'Tab') {\n\t\t\t\t\t\t// Complete the highlighted member if there is one to complete; if the roster is empty\n\t\t\t\t\t\t// there's nothing to pick, so cancel the picker and swallow the key \u2014 the composer\n\t\t\t\t\t\t// beneath neither submits (Enter) nor moves focus / indents (Tab).\n\t\t\t\t\t\tconst completed = renderer?.ref?.onKeyDown(props) ?? false\n\t\t\t\t\t\tif (!completed) {\n\t\t\t\t\t\t\thide()\n\t\t\t\t\t\t\tprops.event.stopPropagation()\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\t\t\t\t\tif (renderer && renderer.ref) return renderer.ref.onKeyDown(props)\n\t\t\t\t\treturn false\n\t\t\t\t},\n\t\t\t\tonExit: () => {\n\t\t\t\t\tstopFollowing()\n\t\t\t\t\tif (editorEl) editorEl.removeEventListener('blur', hide)\n\t\t\t\t\tif (container) container.remove()\n\t\t\t\t\tif (renderer) renderer.destroy()\n\t\t\t\t\trenderer = null\n\t\t\t\t\tcontainer = null\n\t\t\t\t\tmentionPickerOpen.set(false)\n\t\t\t\t},\n\t\t\t}\n\t\t},\n\t}\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["import_react", "MentionPopup"]
7
7
  }
@@ -1,10 +1,12 @@
1
1
  import { Editor } from 'tldraw';
2
+ import { ForwardRefExoticComponent } from 'react';
2
3
  import { JsonObject } from 'tldraw';
3
4
  import { JSX } from 'react/jsx-runtime';
4
5
  import { MentionNodeAttrs } from '@tiptap/extension-mention';
5
6
  import { MentionOptions } from '@tiptap/extension-mention';
6
7
  import { Node as Node_2 } from '@tiptap/core';
7
8
  import { ReactNode } from 'react';
9
+ import { RefAttributes } from 'react';
8
10
  import type { SuggestionOptions } from '@tiptap/suggestion';
9
11
 
10
12
  /** A person's avatar — their image if provided, otherwise a single-initial coloured circle.
@@ -44,10 +46,8 @@ export declare function createMentionExtension({ resolveName, suggestion }?: Men
44
46
 
45
47
  /**
46
48
  * Build the TipTap `suggestion` config for the \@-picker. `getSuggestions(query)` is the host's
47
- * resolver — it returns the members matching the query (sync or async); the SDK owns neither the
48
- * roster nor the filtering. The plugin runs outside React, so `render` mounts `MentionPopup` via a
49
- * `ReactRenderer`, forwards navigation keys through the popup's imperative handle, and lets it call
50
- * `command` to insert.
49
+ * resolver — the SDK owns neither the roster nor the filtering. The plugin runs outside React, so
50
+ * `render` mounts `MentionPopup` via a `ReactRenderer` and forwards navigation keys to it.
51
51
  * @public
52
52
  */
53
53
  export declare function createMentionSuggestion(getSuggestions: (query: string) => MentionMember[] | Promise<MentionMember[]>, options?: MentionSuggestionOptions): Omit<SuggestionOptions<MentionMember, MentionNodeAttrs>, 'editor'>;
@@ -84,7 +84,7 @@ export declare interface MentionExtensionOptions {
84
84
  * placement, and drives this with the host-resolved `members` and the highlighted `activeIndex`.
85
85
  * @public @react
86
86
  */
87
- export declare function MentionList({ members, activeIndex, onSelect, emptyLabel, renderMember }: MentionListProps): JSX.Element;
87
+ export declare const MentionList: ForwardRefExoticComponent<MentionListProps & RefAttributes<HTMLDivElement>>;
88
88
 
89
89
  /** @public */
90
90
  export declare interface MentionListProps {
@@ -10,7 +10,7 @@ import {
10
10
  } from "./mention-suggestion.mjs";
11
11
  registerTldrawLibraryVersion(
12
12
  "@tldraw/mentions",
13
- "0.0.0-bootstrap",
13
+ "5.3.0-canary.04044ed9e96d",
14
14
  "esm"
15
15
  );
16
16
  export {
@@ -1,4 +1,5 @@
1
1
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
+ import { forwardRef } from "react";
2
3
  import { useTranslation } from "tldraw";
3
4
  import { Avatar } from "./avatar.mjs";
4
5
  function DefaultMemberRow({ member }) {
@@ -14,18 +15,12 @@ function DefaultMemberRow({ member }) {
14
15
  ] })
15
16
  ] });
16
17
  }
17
- function MentionList({
18
- members,
19
- activeIndex = 0,
20
- onSelect,
21
- emptyLabel,
22
- renderMember
23
- }) {
18
+ const MentionList = forwardRef(function MentionList2({ members, activeIndex = 0, onSelect, emptyLabel, renderMember }, ref) {
24
19
  const msg = useTranslation();
25
20
  if (members.length === 0) {
26
- return /* @__PURE__ */ jsx("div", { className: "tlui-cmt-mention-list tlui-cmt-mention-list--empty", children: emptyLabel ?? msg("comments.mention-no-matches") });
21
+ return /* @__PURE__ */ jsx("div", { ref, className: "tlui-cmt-mention-list tlui-cmt-mention-list--empty", children: emptyLabel ?? msg("comments.mention-no-matches") });
27
22
  }
28
- return /* @__PURE__ */ jsx("div", { className: "tlui-cmt-mention-list", role: "listbox", children: members.map((member, i) => {
23
+ return /* @__PURE__ */ jsx("div", { ref, className: "tlui-cmt-mention-list", role: "listbox", children: members.map((member, i) => {
29
24
  const active = i === activeIndex;
30
25
  return /* @__PURE__ */ jsx(
31
26
  "button",
@@ -43,7 +38,7 @@ function MentionList({
43
38
  member.id
44
39
  );
45
40
  }) });
46
- }
41
+ });
47
42
  export {
48
43
  MentionList
49
44
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/mention-list.tsx"],
4
- "sourcesContent": ["import { ReactNode } from 'react'\nimport { useTranslation } from 'tldraw'\nimport { Avatar } from './avatar'\nimport { CommentAuthor } from './comment-author'\n\n/** A person the composer's \\@-mention picker can offer: a {@link CommentAuthor} plus its id and\n * picker-only display fields. @public */\nexport interface MentionMember extends CommentAuthor {\n\tid: string\n\t/** A secondary line under the name \u2014 e.g. an email or handle. Omit for a single-line row. */\n\tsecondary?: string\n\t/** Marks the current user; shown as a \"(You)\" suffix after the name. */\n\tyou?: boolean\n\t/** Hosts may carry extra fields; a custom `renderMember` can read them. */\n\t[key: string]: unknown\n}\n\n/** @public */\nexport interface MentionListProps {\n\t/** Members to choose from \u2014 already resolved for the current query by the host. */\n\tmembers: MentionMember[]\n\t/** Index of the highlighted row, driven by the composer's keyboard navigation. */\n\tactiveIndex?: number\n\t/** Called when a member is chosen (click, or Enter on the active row). */\n\tonSelect?(member: MentionMember): void\n\t/** Shown when no member matches the query. Defaults to a translated \"No matches\". */\n\temptyLabel?: string\n\t/** Override a row's content (inside the selectable button). Defaults to avatar + name (+ secondary). */\n\trenderMember?(member: MentionMember): ReactNode\n}\n\n/** The default row content: avatar, name (with a \"(You)\" marker), and an optional secondary line. */\nfunction DefaultMemberRow({ member }: { member: MentionMember }) {\n\tconst msg = useTranslation()\n\treturn (\n\t\t<>\n\t\t\t<Avatar author={member} />\n\t\t\t<span className=\"tlui-cmt-mention-list__text\">\n\t\t\t\t<span className=\"tlui-cmt-mention-list__name\">\n\t\t\t\t\t{member.name}\n\t\t\t\t\t{member.you && (\n\t\t\t\t\t\t<span className=\"tlui-cmt-mention-list__you\">{`(${msg('comments.mention-you')})`}</span>\n\t\t\t\t\t)}\n\t\t\t\t</span>\n\t\t\t\t{member.secondary && (\n\t\t\t\t\t<span className=\"tlui-cmt-mention-list__secondary\">{member.secondary}</span>\n\t\t\t\t)}\n\t\t\t</span>\n\t\t</>\n\t)\n}\n\n/**\n * The \\@-mention member picker: a popover list of people to mention, shown as the user types after\n * `@`. Presentational \u2014 the composer's suggestion plugin owns resolution, keyboard navigation, and\n * placement, and drives this with the host-resolved `members` and the highlighted `activeIndex`.\n * @public @react\n */\nexport function MentionList({\n\tmembers,\n\tactiveIndex = 0,\n\tonSelect,\n\temptyLabel,\n\trenderMember,\n}: MentionListProps) {\n\tconst msg = useTranslation()\n\tif (members.length === 0) {\n\t\treturn (\n\t\t\t<div className=\"tlui-cmt-mention-list tlui-cmt-mention-list--empty\">\n\t\t\t\t{emptyLabel ?? msg('comments.mention-no-matches')}\n\t\t\t</div>\n\t\t)\n\t}\n\treturn (\n\t\t<div className=\"tlui-cmt-mention-list\" role=\"listbox\">\n\t\t\t{members.map((member, i) => {\n\t\t\t\tconst active = i === activeIndex\n\t\t\t\treturn (\n\t\t\t\t\t<button\n\t\t\t\t\t\tkey={member.id}\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\trole=\"option\"\n\t\t\t\t\t\taria-selected={active}\n\t\t\t\t\t\tclassName={\n\t\t\t\t\t\t\tactive\n\t\t\t\t\t\t\t\t? 'tlui-cmt-mention-list__item tlui-cmt-mention-list__item--active'\n\t\t\t\t\t\t\t\t: 'tlui-cmt-mention-list__item'\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Keep the composer focused on a row press: it's portaled outside the editor, so a\n\t\t\t\t\t\t// bare mousedown would blur the composer \u2014 which now dismisses the picker \u2014 before\n\t\t\t\t\t\t// the click could select. Selection runs on click; focus stays put.\n\t\t\t\t\t\tonMouseDown={(e) => e.preventDefault()}\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tif (onSelect) onSelect(member)\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{renderMember ? renderMember(member) : <DefaultMemberRow member={member} />}\n\t\t\t\t\t</button>\n\t\t\t\t)\n\t\t\t})}\n\t\t</div>\n\t)\n}\n"],
5
- "mappings": "AAmCE,mBACC,KAEC,YAHF;AAlCF,SAAS,sBAAsB;AAC/B,SAAS,cAAc;AA8BvB,SAAS,iBAAiB,EAAE,OAAO,GAA8B;AAChE,QAAM,MAAM,eAAe;AAC3B,SACC,iCACC;AAAA,wBAAC,UAAO,QAAQ,QAAQ;AAAA,IACxB,qBAAC,UAAK,WAAU,+BACf;AAAA,2BAAC,UAAK,WAAU,+BACd;AAAA,eAAO;AAAA,QACP,OAAO,OACP,oBAAC,UAAK,WAAU,8BAA8B,cAAI,IAAI,sBAAsB,CAAC,KAAI;AAAA,SAEnF;AAAA,MACC,OAAO,aACP,oBAAC,UAAK,WAAU,oCAAoC,iBAAO,WAAU;AAAA,OAEvE;AAAA,KACD;AAEF;AAQO,SAAS,YAAY;AAAA,EAC3B;AAAA,EACA,cAAc;AAAA,EACd;AAAA,EACA;AAAA,EACA;AACD,GAAqB;AACpB,QAAM,MAAM,eAAe;AAC3B,MAAI,QAAQ,WAAW,GAAG;AACzB,WACC,oBAAC,SAAI,WAAU,sDACb,wBAAc,IAAI,6BAA6B,GACjD;AAAA,EAEF;AACA,SACC,oBAAC,SAAI,WAAU,yBAAwB,MAAK,WAC1C,kBAAQ,IAAI,CAAC,QAAQ,MAAM;AAC3B,UAAM,SAAS,MAAM;AACrB,WACC;AAAA,MAAC;AAAA;AAAA,QAEA,MAAK;AAAA,QACL,MAAK;AAAA,QACL,iBAAe;AAAA,QACf,WACC,SACG,oEACA;AAAA,QAKJ,aAAa,CAAC,MAAM,EAAE,eAAe;AAAA,QACrC,SAAS,MAAM;AACd,cAAI,SAAU,UAAS,MAAM;AAAA,QAC9B;AAAA,QAEC,yBAAe,aAAa,MAAM,IAAI,oBAAC,oBAAiB,QAAgB;AAAA;AAAA,MAjBpE,OAAO;AAAA,IAkBb;AAAA,EAEF,CAAC,GACF;AAEF;",
6
- "names": []
4
+ "sourcesContent": ["import { ReactNode, forwardRef } from 'react'\nimport { useTranslation } from 'tldraw'\nimport { Avatar } from './avatar'\nimport { CommentAuthor } from './comment-author'\n\n/** A person the composer's \\@-mention picker can offer: a {@link CommentAuthor} plus its id and\n * picker-only display fields. @public */\nexport interface MentionMember extends CommentAuthor {\n\tid: string\n\t/** A secondary line under the name \u2014 e.g. an email or handle. Omit for a single-line row. */\n\tsecondary?: string\n\t/** Marks the current user; shown as a \"(You)\" suffix after the name. */\n\tyou?: boolean\n\t/** Hosts may carry extra fields; a custom `renderMember` can read them. */\n\t[key: string]: unknown\n}\n\n/** @public */\nexport interface MentionListProps {\n\t/** Members to choose from \u2014 already resolved for the current query by the host. */\n\tmembers: MentionMember[]\n\t/** Index of the highlighted row, driven by the composer's keyboard navigation. */\n\tactiveIndex?: number\n\t/** Called when a member is chosen (click, or Enter on the active row). */\n\tonSelect?(member: MentionMember): void\n\t/** Shown when no member matches the query. Defaults to a translated \"No matches\". */\n\temptyLabel?: string\n\t/** Override a row's content (inside the selectable button). Defaults to avatar + name (+ secondary). */\n\trenderMember?(member: MentionMember): ReactNode\n}\n\n/** The default row content: avatar, name (with a \"(You)\" marker), and an optional secondary line. */\nfunction DefaultMemberRow({ member }: { member: MentionMember }) {\n\tconst msg = useTranslation()\n\treturn (\n\t\t<>\n\t\t\t<Avatar author={member} />\n\t\t\t<span className=\"tlui-cmt-mention-list__text\">\n\t\t\t\t<span className=\"tlui-cmt-mention-list__name\">\n\t\t\t\t\t{member.name}\n\t\t\t\t\t{member.you && (\n\t\t\t\t\t\t<span className=\"tlui-cmt-mention-list__you\">{`(${msg('comments.mention-you')})`}</span>\n\t\t\t\t\t)}\n\t\t\t\t</span>\n\t\t\t\t{member.secondary && (\n\t\t\t\t\t<span className=\"tlui-cmt-mention-list__secondary\">{member.secondary}</span>\n\t\t\t\t)}\n\t\t\t</span>\n\t\t</>\n\t)\n}\n\n/**\n * The \\@-mention member picker: a popover list of people to mention, shown as the user types after\n * `@`. Presentational \u2014 the composer's suggestion plugin owns resolution, keyboard navigation, and\n * placement, and drives this with the host-resolved `members` and the highlighted `activeIndex`.\n * @public @react\n */\nexport const MentionList = forwardRef<HTMLDivElement, MentionListProps>(function MentionList(\n\t{ members, activeIndex = 0, onSelect, emptyLabel, renderMember },\n\tref\n) {\n\tconst msg = useTranslation()\n\tif (members.length === 0) {\n\t\treturn (\n\t\t\t<div ref={ref} className=\"tlui-cmt-mention-list tlui-cmt-mention-list--empty\">\n\t\t\t\t{emptyLabel ?? msg('comments.mention-no-matches')}\n\t\t\t</div>\n\t\t)\n\t}\n\treturn (\n\t\t<div ref={ref} className=\"tlui-cmt-mention-list\" role=\"listbox\">\n\t\t\t{members.map((member, i) => {\n\t\t\t\tconst active = i === activeIndex\n\t\t\t\treturn (\n\t\t\t\t\t<button\n\t\t\t\t\t\tkey={member.id}\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\trole=\"option\"\n\t\t\t\t\t\taria-selected={active}\n\t\t\t\t\t\tclassName={\n\t\t\t\t\t\t\tactive\n\t\t\t\t\t\t\t\t? 'tlui-cmt-mention-list__item tlui-cmt-mention-list__item--active'\n\t\t\t\t\t\t\t\t: 'tlui-cmt-mention-list__item'\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// Keep the composer focused on a row press: it's portaled outside the editor, so a\n\t\t\t\t\t\t// bare mousedown would blur the composer \u2014 which now dismisses the picker \u2014 before\n\t\t\t\t\t\t// the click could select. Selection runs on click; focus stays put.\n\t\t\t\t\t\tonMouseDown={(e) => e.preventDefault()}\n\t\t\t\t\t\tonClick={() => {\n\t\t\t\t\t\t\tif (onSelect) onSelect(member)\n\t\t\t\t\t\t}}\n\t\t\t\t\t>\n\t\t\t\t\t\t{renderMember ? renderMember(member) : <DefaultMemberRow member={member} />}\n\t\t\t\t\t</button>\n\t\t\t\t)\n\t\t\t})}\n\t\t</div>\n\t)\n})\n"],
5
+ "mappings": "AAmCE,mBACC,KAEC,YAHF;AAnCF,SAAoB,kBAAkB;AACtC,SAAS,sBAAsB;AAC/B,SAAS,cAAc;AA8BvB,SAAS,iBAAiB,EAAE,OAAO,GAA8B;AAChE,QAAM,MAAM,eAAe;AAC3B,SACC,iCACC;AAAA,wBAAC,UAAO,QAAQ,QAAQ;AAAA,IACxB,qBAAC,UAAK,WAAU,+BACf;AAAA,2BAAC,UAAK,WAAU,+BACd;AAAA,eAAO;AAAA,QACP,OAAO,OACP,oBAAC,UAAK,WAAU,8BAA8B,cAAI,IAAI,sBAAsB,CAAC,KAAI;AAAA,SAEnF;AAAA,MACC,OAAO,aACP,oBAAC,UAAK,WAAU,oCAAoC,iBAAO,WAAU;AAAA,OAEvE;AAAA,KACD;AAEF;AAQO,MAAM,cAAc,WAA6C,SAASA,aAChF,EAAE,SAAS,cAAc,GAAG,UAAU,YAAY,aAAa,GAC/D,KACC;AACD,QAAM,MAAM,eAAe;AAC3B,MAAI,QAAQ,WAAW,GAAG;AACzB,WACC,oBAAC,SAAI,KAAU,WAAU,sDACvB,wBAAc,IAAI,6BAA6B,GACjD;AAAA,EAEF;AACA,SACC,oBAAC,SAAI,KAAU,WAAU,yBAAwB,MAAK,WACpD,kBAAQ,IAAI,CAAC,QAAQ,MAAM;AAC3B,UAAM,SAAS,MAAM;AACrB,WACC;AAAA,MAAC;AAAA;AAAA,QAEA,MAAK;AAAA,QACL,MAAK;AAAA,QACL,iBAAe;AAAA,QACf,WACC,SACG,oEACA;AAAA,QAKJ,aAAa,CAAC,MAAM,EAAE,eAAe;AAAA,QACrC,SAAS,MAAM;AACd,cAAI,SAAU,UAAS,MAAM;AAAA,QAC9B;AAAA,QAEC,yBAAe,aAAa,MAAM,IAAI,oBAAC,oBAAiB,QAAgB;AAAA;AAAA,MAjBpE,OAAO;AAAA,IAkBb;AAAA,EAEF,CAAC,GACF;AAEF,CAAC;",
6
+ "names": ["MentionList"]
7
7
  }
@@ -1,10 +1,12 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
2
  import { ReactRenderer } from "@tiptap/react";
3
- import { forwardRef, useImperativeHandle, useState } from "react";
4
- import { atom, react } from "tldraw";
3
+ import { forwardRef, useImperativeHandle, useRef, useState } from "react";
4
+ import { atom, react, usePassThroughWheelEvents } from "tldraw";
5
5
  import { MentionList } from "./mention-list.mjs";
6
6
  const MentionPopup = forwardRef(function MentionPopup2({ items, command, renderMember }, ref) {
7
7
  const [activeIndex, setActiveIndex] = useState(0);
8
+ const listRef = useRef(null);
9
+ usePassThroughWheelEvents(listRef);
8
10
  const [prevItems, setPrevItems] = useState(items);
9
11
  if (items !== prevItems) {
10
12
  setPrevItems(items);
@@ -34,6 +36,7 @@ const MentionPopup = forwardRef(function MentionPopup2({ items, command, renderM
34
36
  return /* @__PURE__ */ jsx(
35
37
  MentionList,
36
38
  {
39
+ ref: listRef,
37
40
  members: items,
38
41
  activeIndex,
39
42
  onSelect: select,
@@ -58,7 +61,6 @@ function createMentionSuggestion(getSuggestions, options = {}) {
58
61
  let renderer = null;
59
62
  let container = null;
60
63
  let editorEl = null;
61
- let canvasEl = null;
62
64
  let stopCameraReaction = null;
63
65
  let anchorPage = null;
64
66
  let popupWidth = 0;
@@ -102,15 +104,6 @@ function createMentionSuggestion(getSuggestions, options = {}) {
102
104
  if (container) container.style.display = "none";
103
105
  mentionPickerOpen.set(false);
104
106
  };
105
- const onWheel = (e) => {
106
- if (e.isSpecialRedispatchedEvent || !canvasEl) return;
107
- const list = container?.querySelector(".tlui-cmt-mention-list");
108
- if (list && list.scrollHeight > list.clientHeight) return;
109
- e.preventDefault();
110
- const redispatched = new WheelEvent("wheel", e);
111
- redispatched.isSpecialRedispatchedEvent = true;
112
- canvasEl.dispatchEvent(redispatched);
113
- };
114
107
  return {
115
108
  onStart: (props) => {
116
109
  renderer = new ReactRenderer(MentionPopup, {
@@ -126,10 +119,7 @@ function createMentionSuggestion(getSuggestions, options = {}) {
126
119
  container = document.createElement("div");
127
120
  container.className = "tlui-cmt-mention-popup";
128
121
  container.appendChild(renderer.element);
129
- const themed = editorEl.closest(".tl-container");
130
- (themed ?? document.body).appendChild(container);
131
- canvasEl = themed?.querySelector(".tl-canvas") ?? null;
132
- container.addEventListener("wheel", onWheel, { passive: false });
122
+ (editorEl.closest(".tl-container") ?? document.body).appendChild(container);
133
123
  place();
134
124
  startFollowing();
135
125
  mentionPickerOpen.set(true);
@@ -166,12 +156,10 @@ function createMentionSuggestion(getSuggestions, options = {}) {
166
156
  onExit: () => {
167
157
  stopFollowing();
168
158
  if (editorEl) editorEl.removeEventListener("blur", hide);
169
- if (container) container.removeEventListener("wheel", onWheel);
170
159
  if (container) container.remove();
171
160
  if (renderer) renderer.destroy();
172
161
  renderer = null;
173
162
  container = null;
174
- canvasEl = null;
175
163
  mentionPickerOpen.set(false);
176
164
  }
177
165
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/mention-suggestion.tsx"],
4
- "sourcesContent": ["import type { MentionNodeAttrs } from '@tiptap/extension-mention'\nimport { ReactRenderer } from '@tiptap/react'\nimport type { SuggestionKeyDownProps, SuggestionOptions } from '@tiptap/suggestion'\nimport { type ReactNode, forwardRef, useImperativeHandle, useState } from 'react'\nimport { type Editor as TldrawEditor, atom, react } from 'tldraw'\nimport { MentionList, MentionMember } from './mention-list'\n\n/** The handle the suggestion plugin drives \u2014 it forwards navigation keys into the popup. */\nexport interface MentionPopupHandle {\n\tonKeyDown(props: SuggestionKeyDownProps): boolean\n}\n\ninterface MentionPopupProps {\n\titems: MentionMember[]\n\tcommand(attrs: MentionNodeAttrs): void\n\trenderMember?(member: MentionMember): ReactNode\n}\n\n/** The live @-picker popup: owns the highlighted index and keyboard, renders the presentational list. */\nconst MentionPopup = forwardRef<MentionPopupHandle, MentionPopupProps>(function MentionPopup(\n\t{ items, command, renderMember },\n\tref\n) {\n\tconst [activeIndex, setActiveIndex] = useState(0)\n\t// A new query yields new items; reset the highlight to the top during render \u2014 not in an effect,\n\t// which would leave a frame where `activeIndex` still points past a shrunk list and Enter selects\n\t// its (now out-of-range, undefined) item, swallowing the key without inserting a mention.\n\tconst [prevItems, setPrevItems] = useState(items)\n\tif (items !== prevItems) {\n\t\tsetPrevItems(items)\n\t\tsetActiveIndex(0)\n\t}\n\n\tconst select = (member: MentionMember | undefined) => {\n\t\tif (member) command({ id: member.id, label: member.name })\n\t}\n\n\tuseImperativeHandle(ref, () => ({\n\t\tonKeyDown: ({ event }) => {\n\t\t\tif (items.length === 0) return false\n\t\t\tif (event.key === 'ArrowUp') {\n\t\t\t\tsetActiveIndex((i) => (i + items.length - 1) % items.length)\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tif (event.key === 'ArrowDown') {\n\t\t\t\tsetActiveIndex((i) => (i + 1) % items.length)\n\t\t\t\treturn true\n\t\t\t}\n\t\t\t// Enter and Tab both complete the highlighted member (falling back to the top match so a\n\t\t\t// stale index never selects `undefined`). The empty-roster case is handled a level up, in\n\t\t\t// the suggestion's onKeyDown, which cancels the picker.\n\t\t\tif (event.key === 'Enter' || event.key === 'Tab') {\n\t\t\t\tselect(items[activeIndex] ?? items[0])\n\t\t\t\treturn true\n\t\t\t}\n\t\t\treturn false\n\t\t},\n\t}))\n\n\treturn (\n\t\t<MentionList\n\t\t\tmembers={items}\n\t\t\tactiveIndex={activeIndex}\n\t\t\tonSelect={select}\n\t\t\trenderMember={renderMember}\n\t\t/>\n\t)\n})\n\nconst MAX_SUGGESTIONS = 8\n\n/** Members whose name contains the query (case-insensitive), capped to the popup's length.\n * @public */\nexport function filterMentionMembers(members: MentionMember[], query: string): MentionMember[] {\n\tconst q = query.toLowerCase()\n\treturn members.filter((m) => m.name.toLowerCase().includes(q)).slice(0, MAX_SUGGESTIONS)\n}\n\n// A reactive flag for whether the @-picker is showing. Deliberately NOT tldraw's open-menu registry:\n// registering there mounts MenuClickCapture, which covers the canvas with a click-capture overlay to\n// make it inert while a menu is open. But the picker is an inline autocomplete, not a modal \u2014 the\n// canvas must stay pannable/zoomable beneath it \u2014 so we track \"open\" ourselves instead.\nconst mentionPickerOpen = atom('isMentionPickerOpen', false)\n\n/**\n * Whether the \\@-mention picker is currently showing. Host dismissal (Escape, outside-click) checks\n * this so it can defer to the picker instead of tearing down the composer or thread beneath it.\n * @public\n */\nexport function isMentionPickerOpen(): boolean {\n\treturn mentionPickerOpen.get()\n}\n\n/** @public */\nexport interface MentionSuggestionOptions {\n\t/** Override a member row's content in the picker. Defaults to avatar + name (+ secondary). */\n\trenderMember?(member: MentionMember): ReactNode\n\t/**\n\t * The tldraw editor the composer lives in. When provided, the popup re-anchors reactively as the\n\t * canvas camera moves (the composer rides it) instead of polling every frame. Omit off-canvas.\n\t */\n\teditor?: TldrawEditor | null\n}\n\n/**\n * Build the TipTap `suggestion` config for the \\@-picker. `getSuggestions(query)` is the host's\n * resolver \u2014 it returns the members matching the query (sync or async); the SDK owns neither the\n * roster nor the filtering. The plugin runs outside React, so `render` mounts `MentionPopup` via a\n * `ReactRenderer`, forwards navigation keys through the popup's imperative handle, and lets it call\n * `command` to insert.\n * @public\n */\nexport function createMentionSuggestion(\n\tgetSuggestions: (query: string) => MentionMember[] | Promise<MentionMember[]>,\n\toptions: MentionSuggestionOptions = {}\n): Omit<SuggestionOptions<MentionMember, MentionNodeAttrs>, 'editor'> {\n\treturn {\n\t\tchar: '@',\n\t\titems: ({ query }) => getSuggestions(query),\n\t\trender: () => {\n\t\t\tlet renderer: ReactRenderer<MentionPopupHandle, MentionPopupProps> | null = null\n\t\t\tlet container: HTMLElement | null = null\n\t\t\tlet editorEl: HTMLElement | null = null\n\t\t\tlet canvasEl: Element | null = null\n\t\t\tlet stopCameraReaction: (() => void) | null = null\n\t\t\t// The composer field's top-left in page space, plus the popup's screen width. Captured on a\n\t\t\t// fresh read so camera moves can re-derive the popup's screen position from the page anchor\n\t\t\t// (see reposition) rather than the field's DOM rect.\n\t\t\tlet anchorPage: { x: number; y: number } | null = null\n\t\t\tlet popupWidth = 0\n\n\t\t\tconst applyScreen = (left: number, top: number, width: number) => {\n\t\t\t\tif (!container) return\n\t\t\t\tcontainer.style.left = `${left}px`\n\t\t\t\tcontainer.style.top = `${top + 4}px`\n\t\t\t\tcontainer.style.width = `${width}px`\n\t\t\t}\n\n\t\t\t// Fresh placement: read the field's real screen rect, position the popup flush under it (not\n\t\t\t// the caret, matching its width), and remember the field's page-space anchor for reposition.\n\t\t\tconst place = () => {\n\t\t\t\tif (!container || !editorEl || container.style.display === 'none') return\n\t\t\t\tconst field = editorEl.closest('.tlui-cmt-composer__field') ?? editorEl\n\t\t\t\tconst rect = field.getBoundingClientRect()\n\t\t\t\tpopupWidth = rect.width\n\t\t\t\tanchorPage = options.editor?.screenToPage({ x: rect.left, y: rect.bottom }) ?? null\n\t\t\t\tapplyScreen(rect.left, rect.bottom, rect.width)\n\t\t\t}\n\n\t\t\t// Re-derive the popup's screen position from the remembered page anchor \u2014 pure camera math,\n\t\t\t// always current. Reading the field's DOM rect here instead would lag by a frame: the canvas\n\t\t\t// composer re-positions itself on a React commit (a `useValue(pageToViewport(...))`), which\n\t\t\t// lands after a camera reaction runs, so the rect is still last frame's during a pan.\n\t\t\tconst reposition = () => {\n\t\t\t\tif (!anchorPage || !options.editor) return\n\t\t\t\tconst s = options.editor.pageToScreen(anchorPage)\n\t\t\t\tapplyScreen(s.x, s.y, popupWidth)\n\t\t\t}\n\n\t\t\t// The popup is `position: fixed`, but its anchor \u2014 a canvas composer \u2014 rides the camera:\n\t\t\t// panning or zooming moves the composer with no scroll/resize event to hook. Re-anchor when\n\t\t\t// the camera actually changes (via a tldraw reaction) rather than polling every frame, plus\n\t\t\t// on window scroll/resize for the off-canvas case (which re-read the field directly).\n\t\t\tconst startFollowing = () => {\n\t\t\t\twindow.addEventListener('scroll', place, true)\n\t\t\t\twindow.addEventListener('resize', place)\n\t\t\t\tif (options.editor) {\n\t\t\t\t\tstopCameraReaction = react('anchor mention popup to camera', () => {\n\t\t\t\t\t\toptions.editor!.getCamera() // track the camera so this re-runs as it moves\n\t\t\t\t\t\treposition()\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst stopFollowing = () => {\n\t\t\t\twindow.removeEventListener('scroll', place, true)\n\t\t\t\twindow.removeEventListener('resize', place)\n\t\t\t\tstopCameraReaction?.()\n\t\t\t\tstopCameraReaction = null\n\t\t\t\tanchorPage = null\n\t\t\t}\n\n\t\t\t// Dismiss the roster \u2014 on Escape, or when the composer loses focus \u2014 by hiding it and\n\t\t\t// clearing the open flag, so isMentionPickerOpen() stays accurate and the thread's own\n\t\t\t// Escape/outside-click dismissal can take over. The TipTap suggestion stays active, so typing\n\t\t\t// re-shows the roster via onUpdate.\n\t\t\tconst hide = () => {\n\t\t\t\tif (container) container.style.display = 'none'\n\t\t\t\tmentionPickerOpen.set(false)\n\t\t\t}\n\n\t\t\t// Wheel/panning over the popup drives the canvas beneath it, so scrolling to pan or zoom\n\t\t\t// isn't swallowed by the roster \u2014 the same passthrough the rest of the comments UI gets from\n\t\t\t// `usePassThroughWheelEvents`. The list still scrolls itself when the roster overflows (we\n\t\t\t// only redispatch when it can't). Done imperatively because the popup lives outside React.\n\t\t\tconst onWheel = (e: WheelEvent) => {\n\t\t\t\tif ((e as any).isSpecialRedispatchedEvent || !canvasEl) return\n\t\t\t\tconst list = container?.querySelector('.tlui-cmt-mention-list')\n\t\t\t\tif (list && list.scrollHeight > list.clientHeight) return\n\t\t\t\te.preventDefault()\n\t\t\t\tconst redispatched = new WheelEvent('wheel', e)\n\t\t\t\t;(redispatched as any).isSpecialRedispatchedEvent = true\n\t\t\t\tcanvasEl.dispatchEvent(redispatched)\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tonStart: (props) => {\n\t\t\t\t\trenderer = new ReactRenderer(MentionPopup, {\n\t\t\t\t\t\tprops: {\n\t\t\t\t\t\t\titems: props.items,\n\t\t\t\t\t\t\tcommand: props.command,\n\t\t\t\t\t\t\trenderMember: options.renderMember,\n\t\t\t\t\t\t},\n\t\t\t\t\t\teditor: props.editor,\n\t\t\t\t\t})\n\t\t\t\t\teditorEl = props.editor.view.dom as HTMLElement\n\t\t\t\t\t// The TipTap suggestion has no blur handling, so without this the picker would stay\n\t\t\t\t\t// \"open\" (and the thread would defer its Escape to it) after focus moved away from the\n\t\t\t\t\t// composer \u2014 leaving Escape a no-op and the roster stuck on screen.\n\t\t\t\t\teditorEl.addEventListener('blur', hide)\n\t\t\t\t\tcontainer = document.createElement('div')\n\t\t\t\t\tcontainer.className = 'tlui-cmt-mention-popup'\n\t\t\t\t\tcontainer.appendChild(renderer.element)\n\t\t\t\t\t// Mount inside the tldraw container so the popup inherits the theme variables\n\t\t\t\t\t// (--tl-color-*); portaling to document.body would strip them and lose the panel.\n\t\t\t\t\tconst themed = editorEl.closest('.tl-container')\n\t\t\t\t\t;(themed ?? document.body).appendChild(container)\n\t\t\t\t\tcanvasEl = themed?.querySelector('.tl-canvas') ?? null\n\t\t\t\t\tcontainer.addEventListener('wheel', onWheel, { passive: false })\n\t\t\t\t\tplace()\n\t\t\t\t\tstartFollowing()\n\t\t\t\t\tmentionPickerOpen.set(true)\n\t\t\t\t},\n\t\t\t\tonUpdate: (props) => {\n\t\t\t\t\tif (renderer)\n\t\t\t\t\t\trenderer.updateProps({\n\t\t\t\t\t\t\titems: props.items,\n\t\t\t\t\t\t\tcommand: props.command,\n\t\t\t\t\t\t\trenderMember: options.renderMember,\n\t\t\t\t\t\t})\n\t\t\t\t\t// Typing after an Escape re-shows the roster.\n\t\t\t\t\tif (container) container.style.display = ''\n\t\t\t\t\tmentionPickerOpen.set(true)\n\t\t\t\t\tplace()\n\t\t\t\t},\n\t\t\t\tonKeyDown: (props) => {\n\t\t\t\t\t// Once the roster is hidden (a prior Escape), the TipTap suggestion is still active but\n\t\t\t\t\t// this handler goes inert \u2014 every key passes through to the composer/thread (so a second\n\t\t\t\t\t// Escape closes them, and Arrow/Enter don't select from an invisible list). Typing\n\t\t\t\t\t// re-shows the roster via onUpdate.\n\t\t\t\t\tif (!isMentionPickerOpen()) return false\n\t\t\t\t\tif (props.event.key === 'Escape') {\n\t\t\t\t\t\t// Dismiss only the roster: hide it and stop the key so the composer/thread beneath\n\t\t\t\t\t\t// doesn't also treat Escape as \"abandon\".\n\t\t\t\t\t\thide()\n\t\t\t\t\t\tprops.event.stopPropagation()\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\t\t\t\t\tif (props.event.key === 'Enter' || props.event.key === 'Tab') {\n\t\t\t\t\t\t// Complete the highlighted member if there is one to complete; if the roster is empty\n\t\t\t\t\t\t// there's nothing to pick, so cancel the picker and swallow the key \u2014 the composer\n\t\t\t\t\t\t// beneath neither submits (Enter) nor moves focus / indents (Tab).\n\t\t\t\t\t\tconst completed = renderer?.ref?.onKeyDown(props) ?? false\n\t\t\t\t\t\tif (!completed) {\n\t\t\t\t\t\t\thide()\n\t\t\t\t\t\t\tprops.event.stopPropagation()\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\t\t\t\t\tif (renderer && renderer.ref) return renderer.ref.onKeyDown(props)\n\t\t\t\t\treturn false\n\t\t\t\t},\n\t\t\t\tonExit: () => {\n\t\t\t\t\tstopFollowing()\n\t\t\t\t\tif (editorEl) editorEl.removeEventListener('blur', hide)\n\t\t\t\t\tif (container) container.removeEventListener('wheel', onWheel)\n\t\t\t\t\tif (container) container.remove()\n\t\t\t\t\tif (renderer) renderer.destroy()\n\t\t\t\t\trenderer = null\n\t\t\t\t\tcontainer = null\n\t\t\t\t\tcanvasEl = null\n\t\t\t\t\tmentionPickerOpen.set(false)\n\t\t\t\t},\n\t\t\t}\n\t\t},\n\t}\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { MentionNodeAttrs } from '@tiptap/extension-mention'\nimport { ReactRenderer } from '@tiptap/react'\nimport type { SuggestionKeyDownProps, SuggestionOptions } from '@tiptap/suggestion'\nimport { type ReactNode, forwardRef, useImperativeHandle, useRef, useState } from 'react'\nimport { type Editor as TldrawEditor, atom, react, usePassThroughWheelEvents } from 'tldraw'\nimport { MentionList, MentionMember } from './mention-list'\n\n/** The handle the suggestion plugin drives \u2014 it forwards navigation keys into the popup. */\nexport interface MentionPopupHandle {\n\tonKeyDown(props: SuggestionKeyDownProps): boolean\n}\n\ninterface MentionPopupProps {\n\titems: MentionMember[]\n\tcommand(attrs: MentionNodeAttrs): void\n\trenderMember?(member: MentionMember): ReactNode\n}\n\n/** The live @-picker popup: owns the highlighted index and keyboard, renders the presentational list. */\nconst MentionPopup = forwardRef<MentionPopupHandle, MentionPopupProps>(function MentionPopup(\n\t{ items, command, renderMember },\n\tref\n) {\n\tconst [activeIndex, setActiveIndex] = useState(0)\n\t// A wheel over the popup drives the canvas beneath it, the same pass-through every tldraw panel gets;\n\t// the hook leaves the list alone while it scrolls its own overflow. The suggestion plugin builds the\n\t// popup imperatively, but `ReactRenderer` portals this into the composer's tree, so context reaches it.\n\tconst listRef = useRef<HTMLDivElement>(null)\n\tusePassThroughWheelEvents(listRef)\n\t// A new query yields new items; reset the highlight to the top during render \u2014 not in an effect,\n\t// which would leave a frame where `activeIndex` still points past a shrunk list and Enter selects\n\t// its (now out-of-range, undefined) item, swallowing the key without inserting a mention.\n\tconst [prevItems, setPrevItems] = useState(items)\n\tif (items !== prevItems) {\n\t\tsetPrevItems(items)\n\t\tsetActiveIndex(0)\n\t}\n\n\tconst select = (member: MentionMember | undefined) => {\n\t\tif (member) command({ id: member.id, label: member.name })\n\t}\n\n\tuseImperativeHandle(ref, () => ({\n\t\tonKeyDown: ({ event }) => {\n\t\t\tif (items.length === 0) return false\n\t\t\tif (event.key === 'ArrowUp') {\n\t\t\t\tsetActiveIndex((i) => (i + items.length - 1) % items.length)\n\t\t\t\treturn true\n\t\t\t}\n\t\t\tif (event.key === 'ArrowDown') {\n\t\t\t\tsetActiveIndex((i) => (i + 1) % items.length)\n\t\t\t\treturn true\n\t\t\t}\n\t\t\t// Enter and Tab both complete the highlighted member (falling back to the top match so a\n\t\t\t// stale index never selects `undefined`). The empty-roster case is handled a level up, in\n\t\t\t// the suggestion's onKeyDown, which cancels the picker.\n\t\t\tif (event.key === 'Enter' || event.key === 'Tab') {\n\t\t\t\tselect(items[activeIndex] ?? items[0])\n\t\t\t\treturn true\n\t\t\t}\n\t\t\treturn false\n\t\t},\n\t}))\n\n\treturn (\n\t\t<MentionList\n\t\t\tref={listRef}\n\t\t\tmembers={items}\n\t\t\tactiveIndex={activeIndex}\n\t\t\tonSelect={select}\n\t\t\trenderMember={renderMember}\n\t\t/>\n\t)\n})\n\nconst MAX_SUGGESTIONS = 8\n\n/** Members whose name contains the query (case-insensitive), capped to the popup's length.\n * @public */\nexport function filterMentionMembers(members: MentionMember[], query: string): MentionMember[] {\n\tconst q = query.toLowerCase()\n\treturn members.filter((m) => m.name.toLowerCase().includes(q)).slice(0, MAX_SUGGESTIONS)\n}\n\n// Deliberately NOT tldraw's open-menu registry: registering there mounts MenuClickCapture, which\n// makes the canvas inert. The picker is an inline autocomplete, not a modal \u2014 the canvas must stay\n// pannable beneath it \u2014 so track \"open\" ourselves.\nconst mentionPickerOpen = atom('isMentionPickerOpen', false)\n\n/**\n * Whether the \\@-mention picker is currently showing. Host dismissal (Escape, outside-click) checks\n * this so it can defer to the picker instead of tearing down the composer or thread beneath it.\n * @public\n */\nexport function isMentionPickerOpen(): boolean {\n\treturn mentionPickerOpen.get()\n}\n\n/** @public */\nexport interface MentionSuggestionOptions {\n\t/** Override a member row's content in the picker. Defaults to avatar + name (+ secondary). */\n\trenderMember?(member: MentionMember): ReactNode\n\t/**\n\t * The tldraw editor the composer lives in. When provided, the popup re-anchors reactively as the\n\t * canvas camera moves (the composer rides it) instead of polling every frame. Omit off-canvas.\n\t */\n\teditor?: TldrawEditor | null\n}\n\n/**\n * Build the TipTap `suggestion` config for the \\@-picker. `getSuggestions(query)` is the host's\n * resolver \u2014 the SDK owns neither the roster nor the filtering. The plugin runs outside React, so\n * `render` mounts `MentionPopup` via a `ReactRenderer` and forwards navigation keys to it.\n * @public\n */\nexport function createMentionSuggestion(\n\tgetSuggestions: (query: string) => MentionMember[] | Promise<MentionMember[]>,\n\toptions: MentionSuggestionOptions = {}\n): Omit<SuggestionOptions<MentionMember, MentionNodeAttrs>, 'editor'> {\n\treturn {\n\t\tchar: '@',\n\t\titems: ({ query }) => getSuggestions(query),\n\t\trender: () => {\n\t\t\tlet renderer: ReactRenderer<MentionPopupHandle, MentionPopupProps> | null = null\n\t\t\tlet container: HTMLElement | null = null\n\t\t\tlet editorEl: HTMLElement | null = null\n\t\t\tlet stopCameraReaction: (() => void) | null = null\n\t\t\t// The composer field's top-left in page space, plus the popup's screen width. Captured on a\n\t\t\t// fresh read so camera moves can re-derive the popup's screen position from the page anchor\n\t\t\t// (see reposition) rather than the field's DOM rect.\n\t\t\tlet anchorPage: { x: number; y: number } | null = null\n\t\t\tlet popupWidth = 0\n\n\t\t\tconst applyScreen = (left: number, top: number, width: number) => {\n\t\t\t\tif (!container) return\n\t\t\t\tcontainer.style.left = `${left}px`\n\t\t\t\tcontainer.style.top = `${top + 4}px`\n\t\t\t\tcontainer.style.width = `${width}px`\n\t\t\t}\n\n\t\t\t// Fresh placement: read the field's real screen rect, position the popup flush under it (not\n\t\t\t// the caret, matching its width), and remember the field's page-space anchor for reposition.\n\t\t\tconst place = () => {\n\t\t\t\tif (!container || !editorEl || container.style.display === 'none') return\n\t\t\t\tconst field = editorEl.closest('.tlui-cmt-composer__field') ?? editorEl\n\t\t\t\tconst rect = field.getBoundingClientRect()\n\t\t\t\tpopupWidth = rect.width\n\t\t\t\tanchorPage = options.editor?.screenToPage({ x: rect.left, y: rect.bottom }) ?? null\n\t\t\t\tapplyScreen(rect.left, rect.bottom, rect.width)\n\t\t\t}\n\n\t\t\t// Re-derived from the remembered page anchor \u2014 pure camera math, always current. Reading the field's\n\t\t\t// DOM rect would lag a frame: the composer re-positions on a React commit, after the camera reaction.\n\t\t\tconst reposition = () => {\n\t\t\t\tif (!anchorPage || !options.editor) return\n\t\t\t\tconst s = options.editor.pageToScreen(anchorPage)\n\t\t\t\tapplyScreen(s.x, s.y, popupWidth)\n\t\t\t}\n\n\t\t\t// The popup is `position: fixed`, but a canvas composer rides the camera, which moves it with no\n\t\t\t// scroll/resize event to hook. Re-anchor from a tldraw reaction rather than polling every frame,\n\t\t\t// plus on window scroll/resize for the off-canvas case.\n\t\t\tconst startFollowing = () => {\n\t\t\t\twindow.addEventListener('scroll', place, true)\n\t\t\t\twindow.addEventListener('resize', place)\n\t\t\t\tif (options.editor) {\n\t\t\t\t\tstopCameraReaction = react('anchor mention popup to camera', () => {\n\t\t\t\t\t\toptions.editor!.getCamera() // track the camera so this re-runs as it moves\n\t\t\t\t\t\treposition()\n\t\t\t\t\t})\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst stopFollowing = () => {\n\t\t\t\twindow.removeEventListener('scroll', place, true)\n\t\t\t\twindow.removeEventListener('resize', place)\n\t\t\t\tstopCameraReaction?.()\n\t\t\t\tstopCameraReaction = null\n\t\t\t\tanchorPage = null\n\t\t\t}\n\n\t\t\t// Dismiss the roster on Escape or blur, clearing the open flag so isMentionPickerOpen() stays\n\t\t\t// accurate and the thread's own dismissal can take over. Typing re-shows it via onUpdate.\n\t\t\tconst hide = () => {\n\t\t\t\tif (container) container.style.display = 'none'\n\t\t\t\tmentionPickerOpen.set(false)\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\tonStart: (props) => {\n\t\t\t\t\trenderer = new ReactRenderer(MentionPopup, {\n\t\t\t\t\t\tprops: {\n\t\t\t\t\t\t\titems: props.items,\n\t\t\t\t\t\t\tcommand: props.command,\n\t\t\t\t\t\t\trenderMember: options.renderMember,\n\t\t\t\t\t\t},\n\t\t\t\t\t\teditor: props.editor,\n\t\t\t\t\t})\n\t\t\t\t\teditorEl = props.editor.view.dom as HTMLElement\n\t\t\t\t\t// The TipTap suggestion has no blur handling, so without this the picker would stay\n\t\t\t\t\t// \"open\" (and the thread would defer its Escape to it) after focus moved away from the\n\t\t\t\t\t// composer \u2014 leaving Escape a no-op and the roster stuck on screen.\n\t\t\t\t\teditorEl.addEventListener('blur', hide)\n\t\t\t\t\tcontainer = document.createElement('div')\n\t\t\t\t\tcontainer.className = 'tlui-cmt-mention-popup'\n\t\t\t\t\tcontainer.appendChild(renderer.element)\n\t\t\t\t\t// Mounted inside the tldraw container so the popup inherits the theme variables and the pass-through\n\t\t\t\t\t// hooks can find the canvas. Outside one, fall back to the body \u2014 the picker still works.\n\t\t\t\t\t;(editorEl.closest('.tl-container') ?? document.body).appendChild(container)\n\t\t\t\t\tplace()\n\t\t\t\t\tstartFollowing()\n\t\t\t\t\tmentionPickerOpen.set(true)\n\t\t\t\t},\n\t\t\t\tonUpdate: (props) => {\n\t\t\t\t\tif (renderer)\n\t\t\t\t\t\trenderer.updateProps({\n\t\t\t\t\t\t\titems: props.items,\n\t\t\t\t\t\t\tcommand: props.command,\n\t\t\t\t\t\t\trenderMember: options.renderMember,\n\t\t\t\t\t\t})\n\t\t\t\t\t// Typing after an Escape re-shows the roster.\n\t\t\t\t\tif (container) container.style.display = ''\n\t\t\t\t\tmentionPickerOpen.set(true)\n\t\t\t\t\tplace()\n\t\t\t\t},\n\t\t\t\tonKeyDown: (props) => {\n\t\t\t\t\t// Once the roster is hidden, the suggestion stays active but this handler goes inert \u2014 keys pass\n\t\t\t\t\t// through so a second Escape closes the composer. Typing re-shows the roster via onUpdate.\n\t\t\t\t\tif (!isMentionPickerOpen()) return false\n\t\t\t\t\tif (props.event.key === 'Escape') {\n\t\t\t\t\t\t// Dismiss only the roster: hide it and stop the key so the composer/thread beneath\n\t\t\t\t\t\t// doesn't also treat Escape as \"abandon\".\n\t\t\t\t\t\thide()\n\t\t\t\t\t\tprops.event.stopPropagation()\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\t\t\t\t\tif (props.event.key === 'Enter' || props.event.key === 'Tab') {\n\t\t\t\t\t\t// Complete the highlighted member if there is one to complete; if the roster is empty\n\t\t\t\t\t\t// there's nothing to pick, so cancel the picker and swallow the key \u2014 the composer\n\t\t\t\t\t\t// beneath neither submits (Enter) nor moves focus / indents (Tab).\n\t\t\t\t\t\tconst completed = renderer?.ref?.onKeyDown(props) ?? false\n\t\t\t\t\t\tif (!completed) {\n\t\t\t\t\t\t\thide()\n\t\t\t\t\t\t\tprops.event.stopPropagation()\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn true\n\t\t\t\t\t}\n\t\t\t\t\tif (renderer && renderer.ref) return renderer.ref.onKeyDown(props)\n\t\t\t\t\treturn false\n\t\t\t\t},\n\t\t\t\tonExit: () => {\n\t\t\t\t\tstopFollowing()\n\t\t\t\t\tif (editorEl) editorEl.removeEventListener('blur', hide)\n\t\t\t\t\tif (container) container.remove()\n\t\t\t\t\tif (renderer) renderer.destroy()\n\t\t\t\t\trenderer = null\n\t\t\t\t\tcontainer = null\n\t\t\t\t\tmentionPickerOpen.set(false)\n\t\t\t\t},\n\t\t\t}\n\t\t},\n\t}\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["MentionPopup"]
7
7
  }
package/mentions.css CHANGED
@@ -32,7 +32,7 @@
32
32
  .tlui-cmt-mention-list {
33
33
  display: flex;
34
34
  flex-direction: column;
35
- gap: 1px;
35
+ gap: 4px;
36
36
  padding: 4px;
37
37
  width: 100%;
38
38
  box-sizing: border-box;
@@ -40,9 +40,10 @@
40
40
  overflow-y: auto;
41
41
  color: var(--tl-color-text-1, #1d1d1d);
42
42
  background: var(--tl-color-panel, #fff);
43
- border: 1px solid var(--tl-color-divider, rgba(0, 0, 0, 0.08));
44
43
  border-radius: 10px;
45
- box-shadow: var(--tl-shadow-2, 0 2px 12px rgba(0, 0, 0, 0.14));
44
+ /* shadow-3 (like the context menu) already carries a 1px inset edge — a separate `border`
45
+ on top of it double-outlines the popup. */
46
+ box-shadow: var(--tl-shadow-3, 0 2px 12px rgba(0, 0, 0, 0.14));
46
47
  }
47
48
 
48
49
  .tlui-cmt-mention-list__item {
@@ -72,7 +73,7 @@
72
73
  }
73
74
 
74
75
  .tlui-cmt-mention-list__name {
75
- font-size: 13px;
76
+ font-size: 12px;
76
77
  font-weight: 500;
77
78
  white-space: nowrap;
78
79
  overflow: hidden;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tldraw/mentions",
3
3
  "description": "tldraw @-mention picker and TipTap node, shared by commenting and shape rich text.",
4
- "version": "0.0.0-bootstrap",
4
+ "version": "5.3.0-canary.04044ed9e96d",
5
5
  "author": {
6
6
  "name": "tldraw Inc.",
7
7
  "email": "hello@tldraw.com"
@@ -53,8 +53,8 @@
53
53
  "@tiptap/pm": "^3.12.1",
54
54
  "@tiptap/react": "^3.12.1",
55
55
  "@tiptap/suggestion": "3.13.0",
56
- "@tldraw/utils": "5.2.5",
57
- "tldraw": "5.2.5"
56
+ "@tldraw/utils": "5.3.0-canary.04044ed9e96d",
57
+ "tldraw": "5.3.0-canary.04044ed9e96d"
58
58
  },
59
59
  "module": "dist-esm/index.mjs",
60
60
  "source": "src/index.ts",
@@ -1,4 +1,4 @@
1
- import { ReactNode } from 'react'
1
+ import { ReactNode, forwardRef } from 'react'
2
2
  import { useTranslation } from 'tldraw'
3
3
  import { Avatar } from './avatar'
4
4
  import { CommentAuthor } from './comment-author'
@@ -56,23 +56,20 @@ function DefaultMemberRow({ member }: { member: MentionMember }) {
56
56
  * placement, and drives this with the host-resolved `members` and the highlighted `activeIndex`.
57
57
  * @public @react
58
58
  */
59
- export function MentionList({
60
- members,
61
- activeIndex = 0,
62
- onSelect,
63
- emptyLabel,
64
- renderMember,
65
- }: MentionListProps) {
59
+ export const MentionList = forwardRef<HTMLDivElement, MentionListProps>(function MentionList(
60
+ { members, activeIndex = 0, onSelect, emptyLabel, renderMember },
61
+ ref
62
+ ) {
66
63
  const msg = useTranslation()
67
64
  if (members.length === 0) {
68
65
  return (
69
- <div className="tlui-cmt-mention-list tlui-cmt-mention-list--empty">
66
+ <div ref={ref} className="tlui-cmt-mention-list tlui-cmt-mention-list--empty">
70
67
  {emptyLabel ?? msg('comments.mention-no-matches')}
71
68
  </div>
72
69
  )
73
70
  }
74
71
  return (
75
- <div className="tlui-cmt-mention-list" role="listbox">
72
+ <div ref={ref} className="tlui-cmt-mention-list" role="listbox">
76
73
  {members.map((member, i) => {
77
74
  const active = i === activeIndex
78
75
  return (
@@ -100,4 +97,4 @@ export function MentionList({
100
97
  })}
101
98
  </div>
102
99
  )
103
- }
100
+ })
@@ -1,8 +1,8 @@
1
1
  import type { MentionNodeAttrs } from '@tiptap/extension-mention'
2
2
  import { ReactRenderer } from '@tiptap/react'
3
3
  import type { SuggestionKeyDownProps, SuggestionOptions } from '@tiptap/suggestion'
4
- import { type ReactNode, forwardRef, useImperativeHandle, useState } from 'react'
5
- import { type Editor as TldrawEditor, atom, react } from 'tldraw'
4
+ import { type ReactNode, forwardRef, useImperativeHandle, useRef, useState } from 'react'
5
+ import { type Editor as TldrawEditor, atom, react, usePassThroughWheelEvents } from 'tldraw'
6
6
  import { MentionList, MentionMember } from './mention-list'
7
7
 
8
8
  /** The handle the suggestion plugin drives — it forwards navigation keys into the popup. */
@@ -22,6 +22,11 @@ const MentionPopup = forwardRef<MentionPopupHandle, MentionPopupProps>(function
22
22
  ref
23
23
  ) {
24
24
  const [activeIndex, setActiveIndex] = useState(0)
25
+ // A wheel over the popup drives the canvas beneath it, the same pass-through every tldraw panel gets;
26
+ // the hook leaves the list alone while it scrolls its own overflow. The suggestion plugin builds the
27
+ // popup imperatively, but `ReactRenderer` portals this into the composer's tree, so context reaches it.
28
+ const listRef = useRef<HTMLDivElement>(null)
29
+ usePassThroughWheelEvents(listRef)
25
30
  // A new query yields new items; reset the highlight to the top during render — not in an effect,
26
31
  // which would leave a frame where `activeIndex` still points past a shrunk list and Enter selects
27
32
  // its (now out-of-range, undefined) item, swallowing the key without inserting a mention.
@@ -59,6 +64,7 @@ const MentionPopup = forwardRef<MentionPopupHandle, MentionPopupProps>(function
59
64
 
60
65
  return (
61
66
  <MentionList
67
+ ref={listRef}
62
68
  members={items}
63
69
  activeIndex={activeIndex}
64
70
  onSelect={select}
@@ -76,10 +82,9 @@ export function filterMentionMembers(members: MentionMember[], query: string): M
76
82
  return members.filter((m) => m.name.toLowerCase().includes(q)).slice(0, MAX_SUGGESTIONS)
77
83
  }
78
84
 
79
- // A reactive flag for whether the @-picker is showing. Deliberately NOT tldraw's open-menu registry:
80
- // registering there mounts MenuClickCapture, which covers the canvas with a click-capture overlay to
81
- // make it inert while a menu is open. But the picker is an inline autocomplete, not a modal — the
82
- // canvas must stay pannable/zoomable beneath it — so we track "open" ourselves instead.
85
+ // Deliberately NOT tldraw's open-menu registry: registering there mounts MenuClickCapture, which
86
+ // makes the canvas inert. The picker is an inline autocomplete, not a modal the canvas must stay
87
+ // pannable beneath it so track "open" ourselves.
83
88
  const mentionPickerOpen = atom('isMentionPickerOpen', false)
84
89
 
85
90
  /**
@@ -104,10 +109,8 @@ export interface MentionSuggestionOptions {
104
109
 
105
110
  /**
106
111
  * Build the TipTap `suggestion` config for the \@-picker. `getSuggestions(query)` is the host's
107
- * resolver — it returns the members matching the query (sync or async); the SDK owns neither the
108
- * roster nor the filtering. The plugin runs outside React, so `render` mounts `MentionPopup` via a
109
- * `ReactRenderer`, forwards navigation keys through the popup's imperative handle, and lets it call
110
- * `command` to insert.
112
+ * resolver — the SDK owns neither the roster nor the filtering. The plugin runs outside React, so
113
+ * `render` mounts `MentionPopup` via a `ReactRenderer` and forwards navigation keys to it.
111
114
  * @public
112
115
  */
113
116
  export function createMentionSuggestion(
@@ -121,7 +124,6 @@ export function createMentionSuggestion(
121
124
  let renderer: ReactRenderer<MentionPopupHandle, MentionPopupProps> | null = null
122
125
  let container: HTMLElement | null = null
123
126
  let editorEl: HTMLElement | null = null
124
- let canvasEl: Element | null = null
125
127
  let stopCameraReaction: (() => void) | null = null
126
128
  // The composer field's top-left in page space, plus the popup's screen width. Captured on a
127
129
  // fresh read so camera moves can re-derive the popup's screen position from the page anchor
@@ -147,20 +149,17 @@ export function createMentionSuggestion(
147
149
  applyScreen(rect.left, rect.bottom, rect.width)
148
150
  }
149
151
 
150
- // Re-derive the popup's screen position from the remembered page anchor — pure camera math,
151
- // always current. Reading the field's DOM rect here instead would lag by a frame: the canvas
152
- // composer re-positions itself on a React commit (a `useValue(pageToViewport(...))`), which
153
- // lands after a camera reaction runs, so the rect is still last frame's during a pan.
152
+ // Re-derived from the remembered page anchor — pure camera math, always current. Reading the field's
153
+ // DOM rect would lag a frame: the composer re-positions on a React commit, after the camera reaction.
154
154
  const reposition = () => {
155
155
  if (!anchorPage || !options.editor) return
156
156
  const s = options.editor.pageToScreen(anchorPage)
157
157
  applyScreen(s.x, s.y, popupWidth)
158
158
  }
159
159
 
160
- // The popup is `position: fixed`, but its anchor — a canvas composer rides the camera:
161
- // panning or zooming moves the composer with no scroll/resize event to hook. Re-anchor when
162
- // the camera actually changes (via a tldraw reaction) rather than polling every frame, plus
163
- // on window scroll/resize for the off-canvas case (which re-read the field directly).
160
+ // The popup is `position: fixed`, but a canvas composer rides the camera, which moves it with no
161
+ // scroll/resize event to hook. Re-anchor from a tldraw reaction rather than polling every frame,
162
+ // plus on window scroll/resize for the off-canvas case.
164
163
  const startFollowing = () => {
165
164
  window.addEventListener('scroll', place, true)
166
165
  window.addEventListener('resize', place)
@@ -179,29 +178,13 @@ export function createMentionSuggestion(
179
178
  anchorPage = null
180
179
  }
181
180
 
182
- // Dismiss the roster on Escape, or when the composer loses focus by hiding it and
183
- // clearing the open flag, so isMentionPickerOpen() stays accurate and the thread's own
184
- // Escape/outside-click dismissal can take over. The TipTap suggestion stays active, so typing
185
- // re-shows the roster via onUpdate.
181
+ // Dismiss the roster on Escape or blur, clearing the open flag so isMentionPickerOpen() stays
182
+ // accurate and the thread's own dismissal can take over. Typing re-shows it via onUpdate.
186
183
  const hide = () => {
187
184
  if (container) container.style.display = 'none'
188
185
  mentionPickerOpen.set(false)
189
186
  }
190
187
 
191
- // Wheel/panning over the popup drives the canvas beneath it, so scrolling to pan or zoom
192
- // isn't swallowed by the roster — the same passthrough the rest of the comments UI gets from
193
- // `usePassThroughWheelEvents`. The list still scrolls itself when the roster overflows (we
194
- // only redispatch when it can't). Done imperatively because the popup lives outside React.
195
- const onWheel = (e: WheelEvent) => {
196
- if ((e as any).isSpecialRedispatchedEvent || !canvasEl) return
197
- const list = container?.querySelector('.tlui-cmt-mention-list')
198
- if (list && list.scrollHeight > list.clientHeight) return
199
- e.preventDefault()
200
- const redispatched = new WheelEvent('wheel', e)
201
- ;(redispatched as any).isSpecialRedispatchedEvent = true
202
- canvasEl.dispatchEvent(redispatched)
203
- }
204
-
205
188
  return {
206
189
  onStart: (props) => {
207
190
  renderer = new ReactRenderer(MentionPopup, {
@@ -220,12 +203,9 @@ export function createMentionSuggestion(
220
203
  container = document.createElement('div')
221
204
  container.className = 'tlui-cmt-mention-popup'
222
205
  container.appendChild(renderer.element)
223
- // Mount inside the tldraw container so the popup inherits the theme variables
224
- // (--tl-color-*); portaling to document.body would strip them and lose the panel.
225
- const themed = editorEl.closest('.tl-container')
226
- ;(themed ?? document.body).appendChild(container)
227
- canvasEl = themed?.querySelector('.tl-canvas') ?? null
228
- container.addEventListener('wheel', onWheel, { passive: false })
206
+ // Mounted inside the tldraw container so the popup inherits the theme variables and the pass-through
207
+ // hooks can find the canvas. Outside one, fall back to the body — the picker still works.
208
+ ;(editorEl.closest('.tl-container') ?? document.body).appendChild(container)
229
209
  place()
230
210
  startFollowing()
231
211
  mentionPickerOpen.set(true)
@@ -243,10 +223,8 @@ export function createMentionSuggestion(
243
223
  place()
244
224
  },
245
225
  onKeyDown: (props) => {
246
- // Once the roster is hidden (a prior Escape), the TipTap suggestion is still active but
247
- // this handler goes inert every key passes through to the composer/thread (so a second
248
- // Escape closes them, and Arrow/Enter don't select from an invisible list). Typing
249
- // re-shows the roster via onUpdate.
226
+ // Once the roster is hidden, the suggestion stays active but this handler goes inert — keys pass
227
+ // through so a second Escape closes the composer. Typing re-shows the roster via onUpdate.
250
228
  if (!isMentionPickerOpen()) return false
251
229
  if (props.event.key === 'Escape') {
252
230
  // Dismiss only the roster: hide it and stop the key so the composer/thread beneath
@@ -272,12 +250,10 @@ export function createMentionSuggestion(
272
250
  onExit: () => {
273
251
  stopFollowing()
274
252
  if (editorEl) editorEl.removeEventListener('blur', hide)
275
- if (container) container.removeEventListener('wheel', onWheel)
276
253
  if (container) container.remove()
277
254
  if (renderer) renderer.destroy()
278
255
  renderer = null
279
256
  container = null
280
- canvasEl = null
281
257
  mentionPickerOpen.set(false)
282
258
  },
283
259
  }