11import { EmojiPicker } from "frimousse" ;
2- import { useEffect , useRef , useState } from "react" ;
2+ import { useEffect , useLayoutEffect , useRef , useState } from "react" ;
33
44import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js" ;
55import { useEditorDOMElement } from "../../../hooks/useEditorDomElement.js" ;
@@ -51,6 +51,26 @@ function findButtonAtIndex(
5151 return buttons [ localIndex ] ?? null ;
5252}
5353
54+ function scrollViewportTo ( root : HTMLElement , selectedIndex : number ) {
55+ const viewport = root . querySelector < HTMLElement > ( "[frimousse-viewport]" ) ;
56+ if ( ! viewport ) {
57+ return ;
58+ }
59+
60+ const rowHeight = getRowHeight ( root ) ;
61+ const headerHeight = getCategoryHeaderHeight ( root ) ;
62+ const targetRow = Math . floor ( selectedIndex / COLUMNS ) ;
63+ const estimatedY = targetRow * rowHeight ;
64+ const viewportHeight = viewport . clientHeight ;
65+
66+ if (
67+ estimatedY < viewport . scrollTop ||
68+ estimatedY + rowHeight > viewport . scrollTop + viewportHeight - headerHeight
69+ ) {
70+ viewport . scrollTop = Math . max ( 0 , estimatedY - viewportHeight / 3 ) ;
71+ }
72+ }
73+
5474export function InlineEmojiPicker ( props : {
5575 query : string ;
5676 closeMenu : ( ) => void ;
@@ -67,41 +87,27 @@ export function InlineEmojiPicker(props: {
6787 const [ selectedIndex , setSelectedIndex ] = useState ( 0 ) ;
6888 const [ selectedEmoji , setSelectedEmoji ] = useState ( { emoji : "" , label : "" } ) ;
6989
70- const selectedIndexRef = useRef ( selectedIndex ) ;
71- selectedIndexRef . current = selectedIndex ;
72-
7390 useEffect ( ( ) => {
7491 setSelectedIndex ( 0 ) ;
7592 } , [ props . query ] ) ;
7693
77- // Apply data-selected and scroll to the target emoji.
78- // Runs via MutationObserver because frimousse virtualises rows and may
79- // re-render them at any time, destroying previous DOM mutations.
80- useEffect ( ( ) => {
94+ // Resolve the emoji character at selectedIndex. When the button is already
95+ // in the DOM (common case: arrow keys within the visible viewport), the
96+ // layout effect finds it immediately. The state change triggers a
97+ // synchronous re-render before paint, so the Emoji component applies
98+ // data-selected without a visible flash.
99+ useLayoutEffect ( ( ) => {
81100 const root = rootRef . current ;
82101 if ( ! root ) {
83102 return ;
84103 }
85104
86- const applySelection = ( ) => {
87- const idx = selectedIndexRef . current ;
88- const viewport = root . querySelector < HTMLElement > ( "[frimousse-viewport]" ) ;
89- if ( ! viewport ) {
90- return ;
91- }
92-
93- const btn = findButtonAtIndex ( root , idx ) ;
94- if ( ! btn ) {
95- return ;
96- }
97-
98- if ( ! btn . hasAttribute ( "data-selected" ) ) {
99- for ( const el of root . querySelectorAll ( "[data-selected]" ) ) {
100- el . removeAttribute ( "data-selected" ) ;
101- }
102- btn . setAttribute ( "data-selected" , "" ) ;
103- }
105+ // Scroll first so frimousse can virtualise the target row.
106+ scrollViewportTo ( root , selectedIndex ) ;
104107
108+ const btn = findButtonAtIndex ( root , selectedIndex ) ;
109+ if ( btn ) {
110+ btn . scrollIntoView ( { block : "nearest" } ) ;
105111 const emoji = btn . textContent ?? "" ;
106112 const label = btn . getAttribute ( "aria-label" ) ?? "" ;
107113 setSelectedEmoji ( ( prev ) => {
@@ -110,61 +116,55 @@ export function InlineEmojiPicker(props: {
110116 }
111117 return { emoji, label } ;
112118 } ) ;
113- } ;
114-
115- applySelection ( ) ;
116-
117- let raf = 0 ;
118- const observer = new MutationObserver ( ( ) => {
119- cancelAnimationFrame ( raf ) ;
120- raf = requestAnimationFrame ( applySelection ) ;
121- } ) ;
122- observer . observe ( root , { childList : true , subtree : true } ) ;
123- return ( ) => {
124- observer . disconnect ( ) ;
125- cancelAnimationFrame ( raf ) ;
126- } ;
127- } , [ resolvedLocale ] ) ;
119+ }
120+ } , [ selectedIndex , props . query , resolvedLocale ] ) ;
128121
129- // Scroll viewport when selection changes.
122+ // Fallback for when the target button isn't in the DOM during the layout
123+ // effect — either because frimousse is still loading emoji data on first
124+ // mount, because a search query is being processed via requestIdleCallback,
125+ // or because the viewport scroll from the layout effect triggered
126+ // virtualisation and the new rows haven't rendered yet.
127+ // Polls with requestAnimationFrame until the button appears.
130128 useEffect ( ( ) => {
131129 const root = rootRef . current ;
132130 if ( ! root ) {
133131 return ;
134132 }
135- const viewport = root . querySelector < HTMLElement > ( "[frimousse-viewport]" ) ;
136- if ( ! viewport ) {
133+
134+ const btn = findButtonAtIndex ( root , selectedIndex ) ;
135+ if ( btn ) {
136+ // Button already handled by the layout effect.
137137 return ;
138138 }
139139
140- const rowHeight = getRowHeight ( root ) ;
141- const headerHeight = getCategoryHeaderHeight ( root ) ;
142- const targetRow = Math . floor ( selectedIndex / COLUMNS ) ;
143- const estimatedY = targetRow * rowHeight ;
144- const viewportHeight = viewport . clientHeight ;
140+ // Ensure viewport is scrolled to the target area.
141+ scrollViewportTo ( root , selectedIndex ) ;
145142
146- if (
147- estimatedY < viewport . scrollTop ||
148- estimatedY + rowHeight >
149- viewport . scrollTop + viewportHeight - headerHeight
150- ) {
151- viewport . scrollTop = Math . max ( 0 , estimatedY - viewportHeight / 3 ) ;
152- }
143+ let cancelled = false ;
144+ let frameId = 0 ;
153145
154- // Clear old selection and apply new one.
155- for ( const el of root . querySelectorAll ( "[data-selected]" ) ) {
156- el . removeAttribute ( "data-selected" ) ;
157- }
158- const btn = findButtonAtIndex ( root , selectedIndex ) ;
159- if ( btn ) {
160- btn . setAttribute ( "data-selected" , "" ) ;
161- btn . scrollIntoView ( { block : "nearest" } ) ;
162- setSelectedEmoji ( {
163- emoji : btn . textContent ?? "" ,
164- label : btn . getAttribute ( "aria-label" ) ?? "" ,
165- } ) ;
166- }
167- } , [ selectedIndex , props . query ] ) ;
146+ const poll = ( ) => {
147+ if ( cancelled ) {
148+ return ;
149+ }
150+ const btn = findButtonAtIndex ( root , selectedIndex ) ;
151+ if ( btn ) {
152+ btn . scrollIntoView ( { block : "nearest" } ) ;
153+ setSelectedEmoji ( {
154+ emoji : btn . textContent ?? "" ,
155+ label : btn . getAttribute ( "aria-label" ) ?? "" ,
156+ } ) ;
157+ return ;
158+ }
159+ frameId = requestAnimationFrame ( poll ) ;
160+ } ;
161+
162+ frameId = requestAnimationFrame ( poll ) ;
163+ return ( ) => {
164+ cancelled = true ;
165+ cancelAnimationFrame ( frameId ) ;
166+ } ;
167+ } , [ selectedIndex , props . query , resolvedLocale ] ) ;
168168
169169 useEffect ( ( ) => {
170170 const handleKeyDown = ( event : KeyboardEvent ) => {
@@ -221,6 +221,7 @@ export function InlineEmojiPicker(props: {
221221
222222 const frimousseLocale = resolvedLocale as any ;
223223 const placeholder = `${ i18n ?. search ?? "Search" } …` ;
224+ const selectedChar = selectedEmoji . emoji ;
224225
225226 return (
226227 < EmojiPicker . Root
@@ -260,7 +261,11 @@ export function InlineEmojiPicker(props: {
260261 </ div >
261262 ) ,
262263 Emoji : ( { emoji, ...emojiProps } ) => (
263- < button className = "bn-frimousse-emoji" { ...emojiProps } >
264+ < button
265+ className = "bn-frimousse-emoji"
266+ data-selected = { emoji . emoji === selectedChar ? "" : undefined }
267+ { ...emojiProps }
268+ >
264269 { emoji . emoji }
265270 </ button >
266271 ) ,
0 commit comments