@lexical/react 0.52.1-nightly.20261006.0 → 0.52.1-nightly.20261007.0

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.
@@ -13,7 +13,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
13
13
  import { LexicalNodeMenuPlugin } from '@lexical/react/LexicalNodeMenuPlugin';
14
14
  import { objectKlassEquals } from '@lexical/utils';
15
15
  import { $getNodeByKey, mergeRegister, PASTE_COMMAND, $onUpdate, COMMAND_PRIORITY_BEFORE_EDITOR, COMMAND_PRIORITY_EDITOR, $getSelection, COMMAND_PRIORITY_LOW, PASTE_TAG } from 'lexical';
16
- import { useState, useCallback, useEffect, useMemo } from 'react';
16
+ import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
17
17
  import { jsx } from 'react/jsx-runtime';
18
18
 
19
19
  /**
@@ -24,6 +24,11 @@ import { jsx } from 'react/jsx-runtime';
24
24
  *
25
25
  */
26
26
 
27
+ function $getCurrentLink(key, url) {
28
+ const linkNode = $getNodeByKey(key);
29
+ return $isLinkNode(linkNode) && linkNode.isAttached() && linkNode.getURL() === url ? linkNode : null;
30
+ }
31
+
27
32
  /**
28
33
  * Watches for pasted AutoLink nodes that match any of the provided embed configurations (e.g., YouTube, Twitter URLs).
29
34
  * When a match is found, it shows a menu offering to replace the link with an embedded node.
@@ -60,13 +65,17 @@ function LexicalAutoEmbedPlugin({
60
65
  menuCommandPriority = COMMAND_PRIORITY_LOW
61
66
  }) {
62
67
  const [editor] = useLexicalComposerContext();
63
- const [nodeKey, setNodeKey] = useState(null);
64
- const [activeEmbedConfig, setActiveEmbedConfig] = useState(null);
68
+ const [embedMatch, setEmbedMatch] = useState(null);
69
+ const nodeKey = embedMatch === null ? null : embedMatch.key;
70
+ const activeEmbedConfig = embedMatch === null ? null : embedMatch.embedConfig;
71
+ // Only the most recent paste may open the menu, even when an older
72
+ // asynchronous parseUrl result resolves after a newer one.
73
+ const latestCheckRef = useRef(0);
65
74
  const reset = useCallback(() => {
66
- setNodeKey(null);
67
- setActiveEmbedConfig(null);
75
+ setEmbedMatch(null);
68
76
  }, []);
69
77
  const checkIfLinkNodeIsEmbeddable = useCallback(async key => {
78
+ const check = ++latestCheckRef.current;
70
79
  const url = editor.read('latest', function () {
71
80
  const linkNode = $getNodeByKey(key);
72
81
  if ($isLinkNode(linkNode)) {
@@ -76,11 +85,26 @@ function LexicalAutoEmbedPlugin({
76
85
  if (url === undefined) {
77
86
  return;
78
87
  }
79
- for (const embedConfig of embedConfigs) {
80
- const urlMatch = await Promise.resolve(embedConfig.parseUrl(url));
81
- if (urlMatch != null) {
82
- setActiveEmbedConfig(embedConfig);
83
- setNodeKey(key);
88
+ // When several configs match, the last one wins, so check them from
89
+ // the end and stop at the first match to call parseUrl no more often
90
+ // than needed.
91
+ for (let i = embedConfigs.length - 1; i >= 0; i--) {
92
+ const embedConfig = embedConfigs[i];
93
+ const result = await Promise.resolve(embedConfig.parseUrl(url));
94
+ if (check !== latestCheckRef.current) {
95
+ return;
96
+ }
97
+ if (result != null) {
98
+ // Edits made while parseUrl was pending may not be committed yet
99
+ if (editor.read('pending', () => $getCurrentLink(key, url) !== null)) {
100
+ setEmbedMatch({
101
+ embedConfig,
102
+ key,
103
+ result,
104
+ url
105
+ });
106
+ }
107
+ return;
84
108
  }
85
109
  }
86
110
  }, [editor, embedConfigs]);
@@ -122,31 +146,30 @@ function LexicalAutoEmbedPlugin({
122
146
  return false;
123
147
  }, COMMAND_PRIORITY_EDITOR);
124
148
  }, [editor, embedConfigs, onOpenEmbedModalForConfig]);
125
- const embedLinkViaActiveEmbedConfig = useCallback(async function () {
126
- if (activeEmbedConfig != null && nodeKey != null) {
127
- const linkNode = editor.read('latest', () => {
128
- const node = $getNodeByKey(nodeKey);
129
- if ($isLinkNode(node)) {
130
- return node;
131
- }
132
- return null;
133
- });
134
- if ($isLinkNode(linkNode)) {
135
- const result = await Promise.resolve(activeEmbedConfig.parseUrl(linkNode.__url));
136
- if (result != null) {
137
- editor.update(() => {
138
- if (!$getSelection()) {
139
- linkNode.selectEnd();
140
- }
141
- activeEmbedConfig.insertNode(editor, result);
142
- if (linkNode.isAttached()) {
143
- linkNode.remove();
144
- }
145
- });
146
- }
147
- }
149
+ const embedLinkViaActiveEmbedConfig = useCallback(() => {
150
+ if (embedMatch == null) {
151
+ return;
148
152
  }
149
- }, [activeEmbedConfig, editor, nodeKey]);
153
+ const {
154
+ embedConfig,
155
+ key,
156
+ result,
157
+ url
158
+ } = embedMatch;
159
+ editor.update(() => {
160
+ const linkNode = $getCurrentLink(key, url);
161
+ if (linkNode === null) {
162
+ return;
163
+ }
164
+ if (!$getSelection()) {
165
+ linkNode.selectEnd();
166
+ }
167
+ embedConfig.insertNode(editor, result);
168
+ if (linkNode.isAttached()) {
169
+ linkNode.remove();
170
+ }
171
+ });
172
+ }, [editor, embedMatch]);
150
173
  const options = useMemo(() => {
151
174
  return activeEmbedConfig != null && nodeKey != null ? getMenuOptions(activeEmbedConfig, embedLinkViaActiveEmbedConfig, reset) : [];
152
175
  }, [activeEmbedConfig, embedLinkViaActiveEmbedConfig, getMenuOptions, nodeKey, reset]);
@@ -6,4 +6,4 @@
6
6
  *
7
7
  */
8
8
 
9
- import{$isLinkNode as e,LinkNode as t,AutoLinkNode as n}from"@lexical/link";import{INSERT_EMBED_COMMAND as o}from"@lexical/react/LexicalAutoEmbedPluginUtils";export{AutoEmbedOption,INSERT_EMBED_COMMAND,URL_MATCHER}from"@lexical/react/LexicalAutoEmbedPluginUtils";import{useLexicalComposerContext as r}from"@lexical/react/LexicalComposerContext";import{LexicalNodeMenuPlugin as l}from"@lexical/react/LexicalNodeMenuPlugin";import{objectKlassEquals as i}from"@lexical/utils";import{$getNodeByKey as a,mergeRegister as c,PASTE_COMMAND as s,$onUpdate as u,COMMAND_PRIORITY_BEFORE_EDITOR as m,COMMAND_PRIORITY_EDITOR as d,$getSelection as p,COMMAND_PRIORITY_LOW as f,PASTE_TAG as x}from"lexical";import{useState as g,useCallback as C,useEffect as E,useMemo as b}from"react";import{jsx as M}from"react/jsx-runtime";function L({embedConfigs:L,onOpenEmbedModalForConfig:P,getMenuOptions:y,menuRenderFn:A,menuCommandPriority:R=f}){const[U]=r(),[v,D]=g(null),[O,_]=g(null),N=C(()=>{D(null),_(null)},[]),S=C(async t=>{const n=U.read("latest",function(){const n=a(t);if(e(n))return n.getURL()});if(void 0!==n)for(const e of L)null!=await Promise.resolve(e.parseUrl(n))&&(_(e),D(t))},[U,L]);E(()=>{let e=!1;const o=(t,{updateTags:n})=>{for(const[o,r]of t)"created"===r&&n.has(x)&&e?S(o):o===v&&N()};return c(U.registerCommand(s,t=>(e=i(t,ClipboardEvent)&&null!==t.clipboardData&&/^\S+$/.test(t.clipboardData.getData("text/plain")),e&&u(()=>{e=!1}),!1),m),...[t,n].map(e=>U.registerMutationListener(e,o,{skipInitialization:!0})))},[S,U,v,N]),E(()=>{if(P)return U.registerCommand(o,e=>{const t=L.find(({type:t})=>t===e);return!!t&&(P(t),!0)},d)},[U,L,P]);const F=C(async function(){if(null!=O&&null!=v){const t=U.read("latest",()=>{const t=a(v);return e(t)?t:null});if(e(t)){const e=await Promise.resolve(O.parseUrl(t.__url));null!=e&&U.update(()=>{p()||t.selectEnd(),O.insertNode(U,e),t.isAttached()&&t.remove()})}}},[O,U,v]),T=b(()=>null!=O&&null!=v?y(O,F,N):[],[O,F,y,v,N]),h=C((e,t,n)=>{U.update(()=>{e.onSelect(t),n()})},[U]);return null!=v?/*#__PURE__*/M(l,{nodeKey:v,onClose:N,onSelectOption:h,options:T,menuRenderFn:A,commandPriority:R}):null}export{L as LexicalAutoEmbedPlugin};
9
+ import{$isLinkNode as e,LinkNode as t,AutoLinkNode as n}from"@lexical/link";import{INSERT_EMBED_COMMAND as r}from"@lexical/react/LexicalAutoEmbedPluginUtils";export{AutoEmbedOption,INSERT_EMBED_COMMAND,URL_MATCHER}from"@lexical/react/LexicalAutoEmbedPluginUtils";import{useLexicalComposerContext as o}from"@lexical/react/LexicalComposerContext";import{LexicalNodeMenuPlugin as l}from"@lexical/react/LexicalNodeMenuPlugin";import{objectKlassEquals as i}from"@lexical/utils";import{$getNodeByKey as a,mergeRegister as u,PASTE_COMMAND as c,$onUpdate as m,COMMAND_PRIORITY_BEFORE_EDITOR as s,COMMAND_PRIORITY_EDITOR as d,$getSelection as p,COMMAND_PRIORITY_LOW as f,PASTE_TAG as g}from"lexical";import{useState as x,useRef as C,useCallback as b,useEffect as E,useMemo as y}from"react";import{jsx as L}from"react/jsx-runtime";function M(t,n){const r=a(t);return e(r)&&r.isAttached()&&r.getURL()===n?r:null}function A({embedConfigs:A,onOpenEmbedModalForConfig:R,getMenuOptions:P,menuRenderFn:U,menuCommandPriority:k=f}){const[v]=o(),[D,O]=x(null),h=null===D?null:D.key,N=null===D?null:D.embedConfig,S=C(0),F=b(()=>{O(null)},[]),T=b(async t=>{const n=++S.current,r=v.read("latest",function(){const n=a(t);if(e(n))return n.getURL()});if(void 0!==r)for(let e=A.length-1;e>=0;e--){const o=A[e],l=await Promise.resolve(o.parseUrl(r));if(n!==S.current)return;if(null!=l)return void(v.read("pending",()=>null!==M(t,r))&&O({embedConfig:o,key:t,result:l,url:r}))}},[v,A]);E(()=>{let e=!1;const r=(t,{updateTags:n})=>{for(const[r,o]of t)"created"===o&&n.has(g)&&e?T(r):r===h&&F()};return u(v.registerCommand(c,t=>(e=i(t,ClipboardEvent)&&null!==t.clipboardData&&/^\S+$/.test(t.clipboardData.getData("text/plain")),e&&m(()=>{e=!1}),!1),s),...[t,n].map(e=>v.registerMutationListener(e,r,{skipInitialization:!0})))},[T,v,h,F]),E(()=>{if(R)return v.registerCommand(r,e=>{const t=A.find(({type:t})=>t===e);return!!t&&(R(t),!0)},d)},[v,A,R]);const _=b(()=>{if(null==D)return;const{embedConfig:e,key:t,result:n,url:r}=D;v.update(()=>{const o=M(t,r);null!==o&&(p()||o.selectEnd(),e.insertNode(v,n),o.isAttached()&&o.remove())})},[v,D]),I=y(()=>null!=N&&null!=h?P(N,_,F):[],[N,_,P,h,F]),j=b((e,t,n)=>{v.update(()=>{e.onSelect(t),n()})},[v]);return null!=h?/*#__PURE__*/L(l,{nodeKey:h,onClose:F,onSelectOption:j,options:I,menuRenderFn:U,commandPriority:k}):null}export{A as LexicalAutoEmbedPlugin};
@@ -662,13 +662,21 @@ function LexicalTypeaheadMenuPlugin({
662
662
  }) {
663
663
  const [editor] = useLexicalComposerContext();
664
664
  const [resolution, setResolution] = useState(null);
665
- const anchorElementRef = useMenuAnchorRef(resolution, setResolution, anchorClassName, parent);
665
+ const openGenerationRef = useRef(0);
666
+ // Whether the menu is open as far as onOpen/onClose are concerned. Unlike
667
+ // `resolution`, this sees an open whose transition has not rendered yet and
668
+ // a close whose onClose promise is still pending.
669
+ const isOpenRef = useRef(false);
666
670
  const closeTypeahead = useCallback(() => {
667
- if (resolution === null) {
671
+ if (!isOpenRef.current) {
668
672
  return;
669
673
  }
674
+ isOpenRef.current = false;
675
+ const openGeneration = openGenerationRef.current;
670
676
  const finish = () => {
671
- setResolution(null);
677
+ if (openGenerationRef.current === openGeneration) {
678
+ setResolution(null);
679
+ }
672
680
  };
673
681
  let result;
674
682
  try {
@@ -680,13 +688,29 @@ function LexicalTypeaheadMenuPlugin({
680
688
  finish();
681
689
  }
682
690
  }
683
- }, [onClose, resolution]);
691
+ }, [onClose]);
684
692
  const openTypeahead = useCallback(res => {
693
+ // Track pending opens before their transition has rendered.
694
+ openGenerationRef.current++;
685
695
  setResolution(res);
686
- if (onOpen != null && resolution === null) {
687
- onOpen(res);
696
+ if (!isOpenRef.current) {
697
+ isOpenRef.current = true;
698
+ if (onOpen != null) {
699
+ onOpen(res);
700
+ }
701
+ }
702
+ }, [onOpen]);
703
+
704
+ // LexicalMenu hides the menu when its anchor scrolls out of view. Route
705
+ // that through closeTypeahead so onClose still pairs with onOpen.
706
+ const setMenuResolution = useCallback(res => {
707
+ if (res === null) {
708
+ closeTypeahead();
709
+ } else {
710
+ setResolution(res);
688
711
  }
689
- }, [onOpen, resolution]);
712
+ }, [closeTypeahead]);
713
+ const anchorElementRef = useMenuAnchorRef(resolution, setMenuResolution, anchorClassName, parent);
690
714
  useEffect(() => {
691
715
  const updateListener = () => {
692
716
  editor.read('latest', () => {
@@ -712,7 +736,7 @@ function LexicalTypeaheadMenuPlugin({
712
736
  onQueryChange(match ? match.matchingString : null);
713
737
  if (match !== null && (ignoreEntityBoundary || !isSelectionOnEntityBoundary(editor, match.leadOffset))) {
714
738
  const isRangePositioned = tryToPositionRange(match.leadOffset, range, editorWindow, editor.getRootElement());
715
- if (isRangePositioned !== null) {
739
+ if (isRangePositioned) {
716
740
  startTransition(() => openTypeahead({
717
741
  getRect: () => range.getBoundingClientRect(),
718
742
  match
@@ -6,4 +6,4 @@
6
6
  *
7
7
  */
8
8
 
9
- import{useLexicalComposerContext as t}from"@lexical/react/LexicalComposerContext";import{CAN_USE_DOM as e,getDOMShadowRoots as n,mergeRegister as l,registerEventListener as o,getRootOwnerDocument as r,COMMAND_PRIORITY_LOW as i,KEY_ARROW_DOWN_COMMAND as c,KEY_ARROW_UP_COMMAND as u,KEY_ESCAPE_COMMAND as s,KEY_TAB_COMMAND as a,KEY_ENTER_COMMAND as d,isDOMShadowRoot as p,isHTMLElement as m,getParentElement as f,$getSelection as g,$isRangeSelection as h,$isTextNode as y,getDOMSelection as x,getDOMSelectionPoints as C}from"lexical";import{useLayoutEffect as b,useEffect as v,useRef as w,useCallback as R,useState as E,useMemo as I,startTransition as S}from"react";import{SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND as O}from"@lexical/react/LexicalTypeaheadMenuPluginUtils";export{PUNCTUATION,SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,getScrollParent,useBasicTypeaheadTriggerMatch}from"@lexical/react/LexicalTypeaheadMenuPluginUtils";import{getScrollParent as T}from"@lexical/utils";import P from"react-dom";import{jsx as N,jsxs as A}from"react/jsx-runtime";export{MenuOption}from"@lexical/react/LexicalMenuOption";const k=e?b:v,D=t=>{const e=t.closest("#typeahead-menu");if(!e)return;const n=e.getBoundingClientRect();n.top+n.height>window.innerHeight&&e.scrollIntoView({block:"center"}),n.top<0&&e.scrollIntoView({block:"center"}),t.scrollIntoView({block:"nearest"})};function M(t,e){const n=t.getBoundingClientRect(),l=e.getBoundingClientRect();return n.top>=l.top-6&&n.top<=l.bottom+6}function L(e,r,i,c){const[u]=t();v(()=>{if(null!=r&&null!=e){const t=u.getRootElement(),e=null!=t?T(t,!1):document.body;let s=!1,a=M(r,e);const d=function(){s||(window.requestAnimationFrame(function(){i(),s=!1}),s=!0);const t=M(r,e);t!==a&&(a=t,null!=c&&c(t))},p=new ResizeObserver(i),m=n(t??r);return p.observe(r),l(o(window,"resize",i),o(document,"scroll",d,{capture:!0,passive:!0}),...m.map(t=>o(t,"scroll",d,{capture:!0,passive:!0})),()=>p.unobserve(r))}},[r,u,c,i,e])}function B({index:t,isSelected:e,onClick:n,onMouseEnter:l,option:o}){let r="item";return e&&(r+=" selected"),/*#__PURE__*/A("li",{tabIndex:-1,className:r,ref:o.setRefElement,role:"option","aria-selected":e,id:"typeahead-item-"+t,onMouseEnter:l,onClick:n,children:[o.icon,/*#__PURE__*/N("span",{className:"text",children:o.title})]},o.key)}function F({close:t,editor:e,anchorElementRef:n,resolution:o,options:r,menuRenderFn:p,onSelectOption:m,shouldSplitNodeWithQuery:f=!1,commandPriority:y=i,preselectFirstItem:x=!0}){const[C,b]=E(null),w=null!==C?Math.min(r.length-1,C):null,S=o.match&&o.match.matchingString;v(()=>{x&&b(0)},[S,x]);const T=R(n=>{e.update(()=>{const e=null!=o.match&&f?function(t){const e=g();if(!h(e)||!e.isCollapsed())return null;const n=e.anchor;if("text"!==n.type)return null;const l=n.getNode();if(!l.isSimpleText())return null;const o=n.offset,r=l.getTextContent().slice(0,o),i=t.replaceableString.length,c=o-function(t,e,n){let l=n;for(let n=l;n<=e.length;n++)t.slice(-n)===e.substring(0,n)&&(l=n);return l}(r,t.matchingString,i);if(c<0)return null;let u;return 0===c?[u]=l.splitText(o):[,u]=l.splitText(c,o),u}(o.match):null;m(n,e,t,o.match?o.match.matchingString:"")})},[e,f,o.match,m,t]),A=R(t=>{const n=e.getRootElement();null!==n&&(n.setAttribute("aria-activedescendant","typeahead-item-"+t),b(t))},[e]),M=R(()=>n.current&&r.length?/*#__PURE__*/P.createPortal(/*#__PURE__*/N("div",{className:"typeahead-popover mentions-menu",children:/*#__PURE__*/N("ul",{children:r.map((t,e)=>/*#__PURE__*/N(B,{index:e,isSelected:w===e,onClick:()=>{b(e),T(t)},onMouseEnter:()=>{b(e)},option:t},t.key))})}),n.current):null,[n,r,w,T,b]);v(()=>()=>{const t=e.getRootElement();null!==t&&t.removeAttribute("aria-activedescendant")},[e]),k(()=>{null===r?b(null):null===w&&x&&A(0)},[r,w,A,x]);const L=null==p&&(null===r||!r.length);v(()=>l(e.registerCommand(O,({option:t})=>!(!t.ref||null==t.ref.current||(D(t.ref.current),0)),y)),[e,A,y]),v(()=>l(e.registerCommand(c,t=>{const n=t;if(L)return!1;const l=null===w?0:w!==r.length-1?w+1:0;A(l);const o=r[l];return o?(o.ref&&o.ref.current&&e.dispatchCommand(O,{index:l,option:o}),n.preventDefault(),n.stopImmediatePropagation(),!0):(A(-1),n.preventDefault(),n.stopImmediatePropagation(),!0)},y),e.registerCommand(u,t=>{const e=t;if(L)return!1;const n=null===w?r.length-1:0!==w?w-1:r.length-1;A(n);const l=r[n];return l?(l.ref&&l.ref.current&&D(l.ref.current),e.preventDefault(),e.stopImmediatePropagation(),!0):(A(-1),e.preventDefault(),e.stopImmediatePropagation(),!0)},y),e.registerCommand(s,e=>{const n=e;return!L&&(n.preventDefault(),n.stopImmediatePropagation(),t(),!0)},y),e.registerCommand(a,t=>{const e=t;return null!==r&&null!==w&&null!=r[w]&&(e.preventDefault(),e.stopImmediatePropagation(),T(r[w]),!0)},y),e.registerCommand(d,t=>!(null===r||null===w||null==r[w]||t&&t.shiftKey||(null!==t&&(t.preventDefault(),t.stopImmediatePropagation()),T(r[w]),0)),y)),[T,t,e,r,L,w,A,y]);const F=I(()=>({options:r,selectOptionAndCleanUp:T,selectedIndex:w,setHighlightedIndex:b}),[T,w,r]);return null!=p?p(n,F,o.match?o.match.matchingString:""):M()}function $(t,e){null!=e&&(t.className=e),t.setAttribute("aria-label","Typeahead menu"),t.setAttribute("role","listbox"),t.style.display="block",t.style.position="absolute"}function U(t){if("static"!==t.position)return!0;const e=t.willChange;return"none"!==t.transform||"none"!==t.perspective||"none"!==t.filter||"none"!==t.backdropFilter||t.contain.includes("paint")||t.contain.includes("layout")||t.contain.includes("strict")||t.contain.includes("content")||e.includes("transform")||e.includes("perspective")||e.includes("filter")||e.includes("contain")}function _({options:n,onQueryChange:l,onSelectOption:o,onOpen:c,onClose:u,menuRenderFn:s,triggerFn:a,anchorClassName:d,commandPriority:b=i,parent:I,preselectFirstItem:O=!0,ignoreEntityBoundary:T=!1}){const[P]=t(),[A,k]=E(null),D=function(n,l,o,i,c=!0){const[u]=t(),s=i??function(t){if(!e)return;const n=t.getRootElement();if(null!==n){const t=n.getRootNode();return p(t)?t:n.ownerDocument.body}return document.body}(u),a=e?r(u.getRootElement()).createElement("div"):null,d=w(a),g=R(()=>{if(null===d.current||void 0===s)return;d.current.style.top=d.current.style.bottom;const t=u.getRootElement(),e=d.current,l=e.firstChild;if(null!==t&&null!==n){const{left:r,top:i,width:u,height:a}=n.getRect(),g=d.current.offsetHeight,h=function(t){const e=p(t)?t.host:t;for(let t=m(e)?e:null;null!==t;t=f(t)){const e=t.ownerDocument.defaultView;if(null===e)break;if(U(e.getComputedStyle(t))){const e=t.getBoundingClientRect();return{left:e.left+t.clientLeft-t.scrollLeft,top:e.top+t.clientTop-t.scrollTop}}}return null}(s),y=t=>null!==h?t-h.left:t+window.pageXOffset,x=t=>null!==h?t-h.top:t+(c?window.pageYOffset:0);if(e.style.top=`${x(i+g+3)}px`,e.style.left=`${y(r)}px`,e.style.height=`${a}px`,e.style.width=`${u}px`,null!==l){l.style.top=`${i}`;const n=l.getBoundingClientRect(),o=n.height,c=n.width,u=t.getBoundingClientRect();r+c>u.right&&(e.style.left=`${y(u.right-c)}px`),(i+o>window.innerHeight||i+o>u.bottom)&&i-u.top>o+a&&(e.style.top=`${x(i-o-a)}px`)}e.isConnected||($(e,o),s.append(e)),e.setAttribute("id","typeahead-menu"),t.setAttribute("aria-controls","typeahead-menu")}},[u,n,c,o,s]);v(()=>{const t=u.getRootElement();return null!==n&&g(),()=>{null!==t&&t.removeAttribute("aria-controls");const e=d.current;null!==e&&e.isConnected&&(e.remove(),e.removeAttribute("id"))}},[u,g,n]);const h=R(t=>{null!==n&&(t||l(null))},[n,l]);return L(n,d.current,g,h),null!=a&&a===d.current&&($(a,o),null!=s&&s.append(a)),d}(A,k,d,I),M=R(()=>{if(null===A)return;const t=()=>{k(null)};let e;try{e=u&&u()}finally{e?e.then(t,t):t()}},[u,A]),B=R(t=>{k(t),null!=c&&null===A&&c(t)},[c,A]);return v(()=>{const t=P.registerUpdateListener(()=>{P.read("latest",()=>{if(!P.isEditable())return void M();const t=()=>{P.isComposing()||M()},e=P._window||window,n=e.document.createRange(),o=g(),r=function(t){let e=null;return t.read("latest",()=>{const t=g();h(t)&&(e=function(t){const e=t.anchor;if("text"!==e.type)return null;const n=e.getNode();if(!n.isSimpleText())return null;const l=e.offset;return n.getTextContent().slice(0,l)}(t))}),e}(P);if(!h(o)||!o.isCollapsed()||null===r||null===n)return void t();const i=a(r,P);if(l(i?i.matchingString:null),null!==i&&(T||!function(t,e){return 0===e&&t.read("latest",()=>{const t=g();if(h(t)){const e=t.anchor.getNode().getPreviousSibling();return y(e)&&e.isTextEntity()}return!1})}(P,i.leadOffset))){const t=function(t,e,n,l){const o=x(n);if(null===o||!o.isCollapsed)return!1;const r=C(o,l),i=r.anchorNode,c=t,u=r.anchorOffset;if(null==i||null==u)return!1;try{e.setStart(i,c),e.setEnd(i,u)}catch(t){return!1}return!0}(i.leadOffset,n,e,P.getRootElement());if(null!==t)return void S(()=>B({getRect:()=>n.getBoundingClientRect(),match:i}))}t()})});return()=>{t()}},[P,a,l,A,M,B,T]),v(()=>P.registerEditableListener(t=>{t||M()}),[P,M]),null===A||null===P||null===D.current?null:/*#__PURE__*/N(F,{close:M,resolution:A,editor:P,anchorElementRef:D,options:n,menuRenderFn:s,shouldSplitNodeWithQuery:!0,onSelectOption:o,commandPriority:b,preselectFirstItem:O})}export{_ as LexicalTypeaheadMenuPlugin,L as useDynamicPositioning};
9
+ import{useLexicalComposerContext as t}from"@lexical/react/LexicalComposerContext";import{CAN_USE_DOM as e,getDOMShadowRoots as n,mergeRegister as o,registerEventListener as l,getRootOwnerDocument as r,COMMAND_PRIORITY_LOW as i,KEY_ARROW_DOWN_COMMAND as c,KEY_ARROW_UP_COMMAND as u,KEY_ESCAPE_COMMAND as s,KEY_TAB_COMMAND as a,KEY_ENTER_COMMAND as d,isDOMShadowRoot as p,isHTMLElement as m,getParentElement as f,$getSelection as g,$isRangeSelection as h,$isTextNode as y,getDOMSelection as x,getDOMSelectionPoints as C}from"lexical";import{useLayoutEffect as b,useEffect as v,useRef as w,useCallback as R,useState as E,useMemo as I,startTransition as S}from"react";import{SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND as O}from"@lexical/react/LexicalTypeaheadMenuPluginUtils";export{PUNCTUATION,SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,getScrollParent,useBasicTypeaheadTriggerMatch}from"@lexical/react/LexicalTypeaheadMenuPluginUtils";import{getScrollParent as T}from"@lexical/utils";import P from"react-dom";import{jsx as N,jsxs as A}from"react/jsx-runtime";export{MenuOption}from"@lexical/react/LexicalMenuOption";const k=e?b:v,D=t=>{const e=t.closest("#typeahead-menu");if(!e)return;const n=e.getBoundingClientRect();n.top+n.height>window.innerHeight&&e.scrollIntoView({block:"center"}),n.top<0&&e.scrollIntoView({block:"center"}),t.scrollIntoView({block:"nearest"})};function M(t,e){const n=t.getBoundingClientRect(),o=e.getBoundingClientRect();return n.top>=o.top-6&&n.top<=o.bottom+6}function L(e,r,i,c){const[u]=t();v(()=>{if(null!=r&&null!=e){const t=u.getRootElement(),e=null!=t?T(t,!1):document.body;let s=!1,a=M(r,e);const d=function(){s||(window.requestAnimationFrame(function(){i(),s=!1}),s=!0);const t=M(r,e);t!==a&&(a=t,null!=c&&c(t))},p=new ResizeObserver(i),m=n(t??r);return p.observe(r),o(l(window,"resize",i),l(document,"scroll",d,{capture:!0,passive:!0}),...m.map(t=>l(t,"scroll",d,{capture:!0,passive:!0})),()=>p.unobserve(r))}},[r,u,c,i,e])}function B({index:t,isSelected:e,onClick:n,onMouseEnter:o,option:l}){let r="item";return e&&(r+=" selected"),/*#__PURE__*/A("li",{tabIndex:-1,className:r,ref:l.setRefElement,role:"option","aria-selected":e,id:"typeahead-item-"+t,onMouseEnter:o,onClick:n,children:[l.icon,/*#__PURE__*/N("span",{className:"text",children:l.title})]},l.key)}function F({close:t,editor:e,anchorElementRef:n,resolution:l,options:r,menuRenderFn:p,onSelectOption:m,shouldSplitNodeWithQuery:f=!1,commandPriority:y=i,preselectFirstItem:x=!0}){const[C,b]=E(null),w=null!==C?Math.min(r.length-1,C):null,S=l.match&&l.match.matchingString;v(()=>{x&&b(0)},[S,x]);const T=R(n=>{e.update(()=>{const e=null!=l.match&&f?function(t){const e=g();if(!h(e)||!e.isCollapsed())return null;const n=e.anchor;if("text"!==n.type)return null;const o=n.getNode();if(!o.isSimpleText())return null;const l=n.offset,r=o.getTextContent().slice(0,l),i=t.replaceableString.length,c=l-function(t,e,n){let o=n;for(let n=o;n<=e.length;n++)t.slice(-n)===e.substring(0,n)&&(o=n);return o}(r,t.matchingString,i);if(c<0)return null;let u;return 0===c?[u]=o.splitText(l):[,u]=o.splitText(c,l),u}(l.match):null;m(n,e,t,l.match?l.match.matchingString:"")})},[e,f,l.match,m,t]),A=R(t=>{const n=e.getRootElement();null!==n&&(n.setAttribute("aria-activedescendant","typeahead-item-"+t),b(t))},[e]),M=R(()=>n.current&&r.length?/*#__PURE__*/P.createPortal(/*#__PURE__*/N("div",{className:"typeahead-popover mentions-menu",children:/*#__PURE__*/N("ul",{children:r.map((t,e)=>/*#__PURE__*/N(B,{index:e,isSelected:w===e,onClick:()=>{b(e),T(t)},onMouseEnter:()=>{b(e)},option:t},t.key))})}),n.current):null,[n,r,w,T,b]);v(()=>()=>{const t=e.getRootElement();null!==t&&t.removeAttribute("aria-activedescendant")},[e]),k(()=>{null===r?b(null):null===w&&x&&A(0)},[r,w,A,x]);const L=null==p&&(null===r||!r.length);v(()=>o(e.registerCommand(O,({option:t})=>!(!t.ref||null==t.ref.current||(D(t.ref.current),0)),y)),[e,A,y]),v(()=>o(e.registerCommand(c,t=>{const n=t;if(L)return!1;const o=null===w?0:w!==r.length-1?w+1:0;A(o);const l=r[o];return l?(l.ref&&l.ref.current&&e.dispatchCommand(O,{index:o,option:l}),n.preventDefault(),n.stopImmediatePropagation(),!0):(A(-1),n.preventDefault(),n.stopImmediatePropagation(),!0)},y),e.registerCommand(u,t=>{const e=t;if(L)return!1;const n=null===w?r.length-1:0!==w?w-1:r.length-1;A(n);const o=r[n];return o?(o.ref&&o.ref.current&&D(o.ref.current),e.preventDefault(),e.stopImmediatePropagation(),!0):(A(-1),e.preventDefault(),e.stopImmediatePropagation(),!0)},y),e.registerCommand(s,e=>{const n=e;return!L&&(n.preventDefault(),n.stopImmediatePropagation(),t(),!0)},y),e.registerCommand(a,t=>{const e=t;return null!==r&&null!==w&&null!=r[w]&&(e.preventDefault(),e.stopImmediatePropagation(),T(r[w]),!0)},y),e.registerCommand(d,t=>!(null===r||null===w||null==r[w]||t&&t.shiftKey||(null!==t&&(t.preventDefault(),t.stopImmediatePropagation()),T(r[w]),0)),y)),[T,t,e,r,L,w,A,y]);const F=I(()=>({options:r,selectOptionAndCleanUp:T,selectedIndex:w,setHighlightedIndex:b}),[T,w,r]);return null!=p?p(n,F,l.match?l.match.matchingString:""):M()}function $(t,e){null!=e&&(t.className=e),t.setAttribute("aria-label","Typeahead menu"),t.setAttribute("role","listbox"),t.style.display="block",t.style.position="absolute"}function U(t){if("static"!==t.position)return!0;const e=t.willChange;return"none"!==t.transform||"none"!==t.perspective||"none"!==t.filter||"none"!==t.backdropFilter||t.contain.includes("paint")||t.contain.includes("layout")||t.contain.includes("strict")||t.contain.includes("content")||e.includes("transform")||e.includes("perspective")||e.includes("filter")||e.includes("contain")}function _({options:n,onQueryChange:o,onSelectOption:l,onOpen:c,onClose:u,menuRenderFn:s,triggerFn:a,anchorClassName:d,commandPriority:b=i,parent:I,preselectFirstItem:O=!0,ignoreEntityBoundary:T=!1}){const[P]=t(),[A,k]=E(null),D=w(0),M=w(!1),B=R(()=>{if(!M.current)return;M.current=!1;const t=D.current,e=()=>{D.current===t&&k(null)};let n;try{n=u&&u()}finally{n?n.then(e,e):e()}},[u]),_=R(t=>{D.current++,k(t),M.current||(M.current=!0,null!=c&&c(t))},[c]),H=function(n,o,l,i,c=!0){const[u]=t(),s=i??function(t){if(!e)return;const n=t.getRootElement();if(null!==n){const t=n.getRootNode();return p(t)?t:n.ownerDocument.body}return document.body}(u),a=e?r(u.getRootElement()).createElement("div"):null,d=w(a),g=R(()=>{if(null===d.current||void 0===s)return;d.current.style.top=d.current.style.bottom;const t=u.getRootElement(),e=d.current,o=e.firstChild;if(null!==t&&null!==n){const{left:r,top:i,width:u,height:a}=n.getRect(),g=d.current.offsetHeight,h=function(t){const e=p(t)?t.host:t;for(let t=m(e)?e:null;null!==t;t=f(t)){const e=t.ownerDocument.defaultView;if(null===e)break;if(U(e.getComputedStyle(t))){const e=t.getBoundingClientRect();return{left:e.left+t.clientLeft-t.scrollLeft,top:e.top+t.clientTop-t.scrollTop}}}return null}(s),y=t=>null!==h?t-h.left:t+window.pageXOffset,x=t=>null!==h?t-h.top:t+(c?window.pageYOffset:0);if(e.style.top=`${x(i+g+3)}px`,e.style.left=`${y(r)}px`,e.style.height=`${a}px`,e.style.width=`${u}px`,null!==o){o.style.top=`${i}`;const n=o.getBoundingClientRect(),l=n.height,c=n.width,u=t.getBoundingClientRect();r+c>u.right&&(e.style.left=`${y(u.right-c)}px`),(i+l>window.innerHeight||i+l>u.bottom)&&i-u.top>l+a&&(e.style.top=`${x(i-l-a)}px`)}e.isConnected||($(e,l),s.append(e)),e.setAttribute("id","typeahead-menu"),t.setAttribute("aria-controls","typeahead-menu")}},[u,n,c,l,s]);v(()=>{const t=u.getRootElement();return null!==n&&g(),()=>{null!==t&&t.removeAttribute("aria-controls");const e=d.current;null!==e&&e.isConnected&&(e.remove(),e.removeAttribute("id"))}},[u,g,n]);const h=R(t=>{null!==n&&(t||o(null))},[n,o]);return L(n,d.current,g,h),null!=a&&a===d.current&&($(a,l),null!=s&&s.append(a)),d}(A,R(t=>{null===t?B():k(t)},[B]),d,I);return v(()=>{const t=P.registerUpdateListener(()=>{P.read("latest",()=>{if(!P.isEditable())return void B();const t=()=>{P.isComposing()||B()},e=P._window||window,n=e.document.createRange(),l=g(),r=function(t){let e=null;return t.read("latest",()=>{const t=g();h(t)&&(e=function(t){const e=t.anchor;if("text"!==e.type)return null;const n=e.getNode();if(!n.isSimpleText())return null;const o=e.offset;return n.getTextContent().slice(0,o)}(t))}),e}(P);if(!h(l)||!l.isCollapsed()||null===r||null===n)return void t();const i=a(r,P);if(o(i?i.matchingString:null),null!==i&&(T||!function(t,e){return 0===e&&t.read("latest",()=>{const t=g();if(h(t)){const e=t.anchor.getNode().getPreviousSibling();return y(e)&&e.isTextEntity()}return!1})}(P,i.leadOffset))){const t=function(t,e,n,o){const l=x(n);if(null===l||!l.isCollapsed)return!1;const r=C(l,o),i=r.anchorNode,c=t,u=r.anchorOffset;if(null==i||null==u)return!1;try{e.setStart(i,c),e.setEnd(i,u)}catch(t){return!1}return!0}(i.leadOffset,n,e,P.getRootElement());if(t)return void S(()=>_({getRect:()=>n.getBoundingClientRect(),match:i}))}t()})});return()=>{t()}},[P,a,o,A,B,_,T]),v(()=>P.registerEditableListener(t=>{t||B()}),[P,B]),null===A||null===P||null===H.current?null:/*#__PURE__*/N(F,{close:B,resolution:A,editor:P,anchorElementRef:H,options:n,menuRenderFn:s,shouldSplitNodeWithQuery:!0,onSelectOption:l,commandPriority:b,preselectFirstItem:O})}export{_ as LexicalTypeaheadMenuPlugin,L as useDynamicPositioning};
package/package.json CHANGED
@@ -8,28 +8,28 @@
8
8
  "rich-text"
9
9
  ],
10
10
  "license": "MIT",
11
- "version": "0.52.1-nightly.20261006.0",
11
+ "version": "0.52.1-nightly.20261007.0",
12
12
  "dependencies": {
13
13
  "@floating-ui/react": "^0.27.19",
14
- "@lexical/a11y": "0.52.1-nightly.20261006.0",
15
- "@lexical/devtools-core": "0.52.1-nightly.20261006.0",
16
- "@lexical/dragon": "0.52.1-nightly.20261006.0",
17
- "@lexical/extension": "0.52.1-nightly.20261006.0",
18
- "@lexical/hashtag": "0.52.1-nightly.20261006.0",
19
- "@lexical/history": "0.52.1-nightly.20261006.0",
20
- "@lexical/internal": "0.52.1-nightly.20261006.0",
21
- "@lexical/link": "0.52.1-nightly.20261006.0",
22
- "@lexical/list": "0.52.1-nightly.20261006.0",
23
- "@lexical/mark": "0.52.1-nightly.20261006.0",
24
- "@lexical/markdown": "0.52.1-nightly.20261006.0",
25
- "@lexical/overflow": "0.52.1-nightly.20261006.0",
26
- "@lexical/plain-text": "0.52.1-nightly.20261006.0",
27
- "@lexical/rich-text": "0.52.1-nightly.20261006.0",
28
- "@lexical/table": "0.52.1-nightly.20261006.0",
29
- "@lexical/text": "0.52.1-nightly.20261006.0",
30
- "@lexical/utils": "0.52.1-nightly.20261006.0",
31
- "@lexical/yjs": "0.52.1-nightly.20261006.0",
32
- "lexical": "0.52.1-nightly.20261006.0"
14
+ "@lexical/a11y": "0.52.1-nightly.20261007.0",
15
+ "@lexical/devtools-core": "0.52.1-nightly.20261007.0",
16
+ "@lexical/dragon": "0.52.1-nightly.20261007.0",
17
+ "@lexical/extension": "0.52.1-nightly.20261007.0",
18
+ "@lexical/hashtag": "0.52.1-nightly.20261007.0",
19
+ "@lexical/history": "0.52.1-nightly.20261007.0",
20
+ "@lexical/internal": "0.52.1-nightly.20261007.0",
21
+ "@lexical/link": "0.52.1-nightly.20261007.0",
22
+ "@lexical/list": "0.52.1-nightly.20261007.0",
23
+ "@lexical/mark": "0.52.1-nightly.20261007.0",
24
+ "@lexical/markdown": "0.52.1-nightly.20261007.0",
25
+ "@lexical/overflow": "0.52.1-nightly.20261007.0",
26
+ "@lexical/plain-text": "0.52.1-nightly.20261007.0",
27
+ "@lexical/rich-text": "0.52.1-nightly.20261007.0",
28
+ "@lexical/table": "0.52.1-nightly.20261007.0",
29
+ "@lexical/text": "0.52.1-nightly.20261007.0",
30
+ "@lexical/utils": "0.52.1-nightly.20261007.0",
31
+ "@lexical/yjs": "0.52.1-nightly.20261007.0",
32
+ "lexical": "0.52.1-nightly.20261007.0"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "react": ">=18.x",
@@ -33,7 +33,14 @@ import {
33
33
  PASTE_TAG,
34
34
  type TextNode,
35
35
  } from 'lexical';
36
- import {type JSX, useCallback, useEffect, useMemo, useState} from 'react';
36
+ import {
37
+ type JSX,
38
+ useCallback,
39
+ useEffect,
40
+ useMemo,
41
+ useRef,
42
+ useState,
43
+ } from 'react';
37
44
 
38
45
  export {
39
46
  AutoEmbedOption,
@@ -84,6 +91,22 @@ type LexicalAutoEmbedPluginProps<TEmbedConfig extends EmbedConfig> = {
84
91
  menuCommandPriority?: CommandListenerPriority;
85
92
  };
86
93
 
94
+ type EmbedMatch<TEmbedConfig extends EmbedConfig> = {
95
+ embedConfig: TEmbedConfig;
96
+ key: NodeKey;
97
+ result: Parameters<TEmbedConfig['insertNode']>[1];
98
+ url: string;
99
+ };
100
+
101
+ function $getCurrentLink(key: NodeKey, url: string): LinkNode | null {
102
+ const linkNode = $getNodeByKey(key);
103
+ return $isLinkNode(linkNode) &&
104
+ linkNode.isAttached() &&
105
+ linkNode.getURL() === url
106
+ ? linkNode
107
+ : null;
108
+ }
109
+
87
110
  /**
88
111
  * Watches for pasted AutoLink nodes that match any of the provided embed configurations (e.g., YouTube, Twitter URLs).
89
112
  * When a match is found, it shows a menu offering to replace the link with an embedded node.
@@ -121,17 +144,22 @@ export function LexicalAutoEmbedPlugin<TEmbedConfig extends EmbedConfig>({
121
144
  }: LexicalAutoEmbedPluginProps<TEmbedConfig>): JSX.Element | null {
122
145
  const [editor] = useLexicalComposerContext();
123
146
 
124
- const [nodeKey, setNodeKey] = useState<NodeKey | null>(null);
125
- const [activeEmbedConfig, setActiveEmbedConfig] =
126
- useState<TEmbedConfig | null>(null);
147
+ const [embedMatch, setEmbedMatch] = useState<EmbedMatch<TEmbedConfig> | null>(
148
+ null,
149
+ );
150
+ const nodeKey = embedMatch === null ? null : embedMatch.key;
151
+ const activeEmbedConfig = embedMatch === null ? null : embedMatch.embedConfig;
152
+ // Only the most recent paste may open the menu, even when an older
153
+ // asynchronous parseUrl result resolves after a newer one.
154
+ const latestCheckRef = useRef(0);
127
155
 
128
156
  const reset = useCallback(() => {
129
- setNodeKey(null);
130
- setActiveEmbedConfig(null);
157
+ setEmbedMatch(null);
131
158
  }, []);
132
159
 
133
160
  const checkIfLinkNodeIsEmbeddable = useCallback(
134
161
  async (key: NodeKey) => {
162
+ const check = ++latestCheckRef.current;
135
163
  const url = editor.read('latest', function () {
136
164
  const linkNode = $getNodeByKey(key);
137
165
  if ($isLinkNode(linkNode)) {
@@ -141,11 +169,23 @@ export function LexicalAutoEmbedPlugin<TEmbedConfig extends EmbedConfig>({
141
169
  if (url === undefined) {
142
170
  return;
143
171
  }
144
- for (const embedConfig of embedConfigs) {
145
- const urlMatch = await Promise.resolve(embedConfig.parseUrl(url));
146
- if (urlMatch != null) {
147
- setActiveEmbedConfig(embedConfig);
148
- setNodeKey(key);
172
+ // When several configs match, the last one wins, so check them from
173
+ // the end and stop at the first match to call parseUrl no more often
174
+ // than needed.
175
+ for (let i = embedConfigs.length - 1; i >= 0; i--) {
176
+ const embedConfig = embedConfigs[i];
177
+ const result = await Promise.resolve(embedConfig.parseUrl(url));
178
+ if (check !== latestCheckRef.current) {
179
+ return;
180
+ }
181
+ if (result != null) {
182
+ // Edits made while parseUrl was pending may not be committed yet
183
+ if (
184
+ editor.read('pending', () => $getCurrentLink(key, url) !== null)
185
+ ) {
186
+ setEmbedMatch({embedConfig, key, result, url});
187
+ }
188
+ return;
149
189
  }
150
190
  }
151
191
  },
@@ -211,37 +251,25 @@ export function LexicalAutoEmbedPlugin<TEmbedConfig extends EmbedConfig>({
211
251
  );
212
252
  }, [editor, embedConfigs, onOpenEmbedModalForConfig]);
213
253
 
214
- const embedLinkViaActiveEmbedConfig = useCallback(
215
- async function () {
216
- if (activeEmbedConfig != null && nodeKey != null) {
217
- const linkNode = editor.read('latest', () => {
218
- const node = $getNodeByKey(nodeKey);
219
- if ($isLinkNode(node)) {
220
- return node;
221
- }
222
- return null;
223
- });
224
-
225
- if ($isLinkNode(linkNode)) {
226
- const result = await Promise.resolve(
227
- activeEmbedConfig.parseUrl(linkNode.__url),
228
- );
229
- if (result != null) {
230
- editor.update(() => {
231
- if (!$getSelection()) {
232
- linkNode.selectEnd();
233
- }
234
- activeEmbedConfig.insertNode(editor, result);
235
- if (linkNode.isAttached()) {
236
- linkNode.remove();
237
- }
238
- });
239
- }
240
- }
254
+ const embedLinkViaActiveEmbedConfig = useCallback(() => {
255
+ if (embedMatch == null) {
256
+ return;
257
+ }
258
+ const {embedConfig, key, result, url} = embedMatch;
259
+ editor.update(() => {
260
+ const linkNode = $getCurrentLink(key, url);
261
+ if (linkNode === null) {
262
+ return;
241
263
  }
242
- },
243
- [activeEmbedConfig, editor, nodeKey],
244
- );
264
+ if (!$getSelection()) {
265
+ linkNode.selectEnd();
266
+ }
267
+ embedConfig.insertNode(editor, result);
268
+ if (linkNode.isAttached()) {
269
+ linkNode.remove();
270
+ }
271
+ });
272
+ }, [editor, embedMatch]);
245
273
 
246
274
  const options = useMemo(() => {
247
275
  return activeEmbedConfig != null && nodeKey != null
@@ -24,6 +24,7 @@ import {
24
24
  startTransition,
25
25
  useCallback,
26
26
  useEffect,
27
+ useRef,
27
28
  useState,
28
29
  } from 'react';
29
30
 
@@ -166,19 +167,21 @@ export function LexicalTypeaheadMenuPlugin<TOption extends MenuOption>({
166
167
  }: TypeaheadMenuPluginProps<TOption>): JSX.Element | null {
167
168
  const [editor] = useLexicalComposerContext();
168
169
  const [resolution, setResolution] = useState<MenuResolution | null>(null);
169
- const anchorElementRef = useMenuAnchorRef(
170
- resolution,
171
- setResolution,
172
- anchorClassName,
173
- parent,
174
- );
175
-
170
+ const openGenerationRef = useRef(0);
171
+ // Whether the menu is open as far as onOpen/onClose are concerned. Unlike
172
+ // `resolution`, this sees an open whose transition has not rendered yet and
173
+ // a close whose onClose promise is still pending.
174
+ const isOpenRef = useRef(false);
176
175
  const closeTypeahead = useCallback(() => {
177
- if (resolution === null) {
176
+ if (!isOpenRef.current) {
178
177
  return;
179
178
  }
179
+ isOpenRef.current = false;
180
+ const openGeneration = openGenerationRef.current;
180
181
  const finish = () => {
181
- setResolution(null);
182
+ if (openGenerationRef.current === openGeneration) {
183
+ setResolution(null);
184
+ }
182
185
  };
183
186
  let result;
184
187
  try {
@@ -190,16 +193,40 @@ export function LexicalTypeaheadMenuPlugin<TOption extends MenuOption>({
190
193
  finish();
191
194
  }
192
195
  }
193
- }, [onClose, resolution]);
196
+ }, [onClose]);
194
197
 
195
198
  const openTypeahead = useCallback(
196
199
  (res: MenuResolution) => {
200
+ // Track pending opens before their transition has rendered.
201
+ openGenerationRef.current++;
197
202
  setResolution(res);
198
- if (onOpen != null && resolution === null) {
199
- onOpen(res);
203
+ if (!isOpenRef.current) {
204
+ isOpenRef.current = true;
205
+ if (onOpen != null) {
206
+ onOpen(res);
207
+ }
208
+ }
209
+ },
210
+ [onOpen],
211
+ );
212
+
213
+ // LexicalMenu hides the menu when its anchor scrolls out of view. Route
214
+ // that through closeTypeahead so onClose still pairs with onOpen.
215
+ const setMenuResolution = useCallback(
216
+ (res: MenuResolution | null) => {
217
+ if (res === null) {
218
+ closeTypeahead();
219
+ } else {
220
+ setResolution(res);
200
221
  }
201
222
  },
202
- [onOpen, resolution],
223
+ [closeTypeahead],
224
+ );
225
+ const anchorElementRef = useMenuAnchorRef(
226
+ resolution,
227
+ setMenuResolution,
228
+ anchorClassName,
229
+ parent,
203
230
  );
204
231
 
205
232
  useEffect(() => {
@@ -246,7 +273,7 @@ export function LexicalTypeaheadMenuPlugin<TOption extends MenuOption>({
246
273
  editorWindow,
247
274
  editor.getRootElement(),
248
275
  );
249
- if (isRangePositioned !== null) {
276
+ if (isRangePositioned) {
250
277
  startTransition(() =>
251
278
  openTypeahead({
252
279
  getRect: () => range.getBoundingClientRect(),