@@ -78,25 +78,52 @@ export function CharacterPicker({ editor, opts, onClose }) {
7878 useEffect ( ( ) => {
7979 if ( ! editor ) return ;
8080
81- // Calculate position relative to selection
8281 const editorDOM = editor . options . element ;
83- const editorRect = editorDOM . getBoundingClientRect ( ) ;
84- const bodyRect = document . body . getBoundingClientRect ( ) ;
85- const { from } = editor . state . selection ;
86- const start = editor . view . coordsAtPos ( from ) ;
82+ const editorViewDom = editor . view . dom ;
8783
88- let top = editorRect . top + Math . abs ( bodyRect . top ) + editorRect . height + 60 ;
84+ // Position is computed in viewport coordinates (the dialog uses position: fixed),
85+ // so coordsAtPos / getBoundingClientRect values can be used directly without
86+ // adding scroll offsets. The dialog is then clamped to the viewport so it does
87+ // not get cut off by fixed page headers/footers.
88+ const updatePosition = ( ) => {
89+ if ( ! containerRef . current ) return ;
8990
90- if ( editorRect . y > containerRef . current . offsetHeight ) {
91- top = top - ( containerRef . current . offsetHeight + editorRect . height ) - 80 ;
92- }
91+ const editorRect = editorDOM . getBoundingClientRect ( ) ;
92+ const { from } = editor . state . selection ;
93+ const start = editor . view . coordsAtPos ( from ) ;
9394
94- setPosition ( {
95- top : top ,
96- left : start . left ,
97- } ) ;
95+ const dialogHeight = containerRef . current . offsetHeight ;
96+ const dialogWidth = containerRef . current . offsetWidth ;
9897
99- const editorViewDom = editor . view . dom ;
98+ // prefer below the editor; flip above when there isn't room below.
99+ const spaceBelow = window . innerHeight - ( editorRect . bottom + 60 ) ;
100+ let top =
101+ spaceBelow >= dialogHeight || editorRect . top < dialogHeight + 80
102+ ? editorRect . bottom + 60
103+ : editorRect . top - dialogHeight - 20 ;
104+
105+ let left = start . left ;
106+
107+ const margin = 8 ;
108+ top = Math . max ( margin , Math . min ( top , window . innerHeight - dialogHeight - margin ) ) ;
109+ left = Math . max ( margin , Math . min ( left , window . innerWidth - dialogWidth - margin ) ) ;
110+
111+ setPosition ( { top, left } ) ;
112+ } ;
113+
114+ updatePosition ( ) ;
115+
116+ let frame = null ;
117+ const scheduleUpdate = ( ) => {
118+ if ( frame !== null ) return ;
119+ frame = requestAnimationFrame ( ( ) => {
120+ frame = null ;
121+ updatePosition ( ) ;
122+ } ) ;
123+ } ;
124+
125+ window . addEventListener ( 'scroll' , scheduleUpdate , true ) ;
126+ window . addEventListener ( 'resize' , scheduleUpdate ) ;
100127
101128 const handleClickOutside = ( e ) => {
102129 if ( containerRef . current && ! containerRef . current . contains ( e . target ) && ! editorViewDom . contains ( e . target ) ) {
@@ -110,6 +137,9 @@ export function CharacterPicker({ editor, opts, onClose }) {
110137
111138 return ( ) => {
112139 clearTimeout ( timeoutId ) ;
140+ if ( frame !== null ) cancelAnimationFrame ( frame ) ;
141+ window . removeEventListener ( 'scroll' , scheduleUpdate , true ) ;
142+ window . removeEventListener ( 'resize' , scheduleUpdate ) ;
113143 document . removeEventListener ( 'click' , handleClickOutside ) ;
114144 } ;
115145 } , [ editor ] ) ;
@@ -131,11 +161,11 @@ export function CharacterPicker({ editor, opts, onClose }) {
131161 data-toolbar-for = { editor . instanceId }
132162 style = { {
133163 visibility : position . top === 0 && position . left === 0 ? 'hidden' : 'initial' ,
134- position : 'absolute ' ,
164+ position : 'fixed ' ,
135165 top : `${ position . top } px` ,
136166 left : `${ position . left } px` ,
137167 maxWidth : '500px' ,
138- zIndex : 99 ,
168+ zIndex : 1000 ,
139169 } }
140170 >
141171 < div >
0 commit comments